ascua-scaffold 0.3.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.
Files changed (4) hide show
  1. package/README.md +14 -5
  2. package/generate.js +155 -43
  3. package/index.js +13 -5
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -54,10 +54,18 @@ y vuelve a generar el módulo de brasa con las consultas nuevas.
54
54
  enum, `type="date"` para fechas, `field.email` para una columna que se
55
55
  llame `email`, `<textarea>` para `description`, `body` o `notes`. Lo
56
56
  nullable es opcional y vacío se guarda como `NULL`.
57
+ - **Relaciones**: una columna con clave foránea (`category_id integer
58
+ REFERENCES categories`) se elige de un `<select>` con las filas de la otra
59
+ tabla, por su nombre —la columna `name`, `title`, `email`… que tenga—, y la
60
+ lista muestra ese nombre en vez del número. Las opciones salen de una
61
+ consulta generada, `ProductCategoryIdOptions`, que puedes cambiar.
57
62
  - **Formularios de HTML**, sin islas: funcionan sin una línea de JavaScript.
58
63
  Un error de validación vuelve a la página con un 400, cada mensaje bajo su
59
- campo y lo escrito en su sitio. Un `UNIQUE` repetido, con un 409 y el error
60
- bajo esa columna, no con un 500.
64
+ campo y lo escrito en su sitio.
65
+ - **Lo que la base rechaza vuelve al formulario** con un 409, no con un 500:
66
+ un `UNIQUE` repetido («Already taken.»), una clave foránea que no existe
67
+ («Does not exist.») y borrar una fila que otras usan («Still used by other
68
+ rows.»), con el error bajo su columna. En Postgres y en MySQL.
61
69
  - **La clave de la URL se valida** antes de llegar a la base: `/products/abc`
62
70
  es un 404. Un `bigint` entra como texto, para no perder precisión.
63
71
 
@@ -80,8 +88,8 @@ un `<form>` de HTML, así que también funciona sin JavaScript.
80
88
  (`ProductForm`) y se guardan todas en una sola transacción
81
89
  (`transaction()` de brasa): si una falla, no se escribe ninguna, y el
82
90
  error aparece en su celda.
83
- - Un `UNIQUE` repetido vuelve con un 409 y el error bajo la celda de esa
84
- fila.
91
+ - Lo que la base rechaza —un `UNIQUE` repetido, una clave foránea que no
92
+ existe— vuelve con un 409 y el error en esa fila.
85
93
 
86
94
  ## El admin
87
95
 
@@ -112,7 +120,8 @@ con las tablas.
112
120
 
113
121
  - Un sitio con servidor: `ascua({ site: { mode: "server" } })` en
114
122
  `vite.config.ts` (`create-ascua --template server`).
115
- - `brasa.json` y las migraciones de brasa (`brasa init`).
123
+ - `brasa.json` y las migraciones de brasa (`brasa init`). Las relaciones
124
+ piden brasa-sql 0.2.0 o más nuevo, que describe las claves foráneas.
116
125
  - Una tabla con clave primaria de una sola columna: entero, texto o uuid. Si
117
126
  es `serial` o tiene `DEFAULT`, la pone la base; si no, se pide al crear y
118
127
  no se cambia al editar.
package/generate.js CHANGED
@@ -71,7 +71,7 @@ export class ScaffoldError extends Error {}
71
71
  * Qué columnas van en cada parte del recurso. Lanza `ScaffoldError` si la
72
72
  * tabla no se puede generar.
73
73
  */
74
- export function analizar(tabla, { mysql }) {
74
+ export function analizar(tabla, { mysql, tablas = [] }) {
75
75
  if (tabla.view) throw new ScaffoldError(t(`\`${tabla.name}\` es una vista: no se puede editar`, `\`${tabla.name}\` is a view: it cannot be edited`));
76
76
  if (tabla.primaryKey.length !== 1) {
77
77
  throw new ScaffoldError(
@@ -115,9 +115,33 @@ export function analizar(tabla, { mysql }) {
115
115
  if (alEditar.length === 0) throw new ScaffoldError(t(`\`${tabla.name}\` no tiene columnas que editar`, `\`${tabla.name}\` has no columns to edit`));
116
116
 
117
117
  const visibles = tabla.columns.filter((c) => c !== clave && EDITABLES.has(c.kind) && !c.array).slice(0, 4);
118
- return { clave, claveAutomatica, alCrear, alEditar, fuera, visibles, mysql };
118
+
119
+ // Las columnas que apuntan a otra tabla se eligen de una lista.
120
+ const relaciones = [];
121
+ for (const c of alCrear) {
122
+ const destino = c.references && tablas.find((x) => x.name === c.references.table);
123
+ if (!destino) continue;
124
+ relaciones.push({ columna: c, tabla: destino.name, valor: c.references.column, etiqueta: etiquetaDe(destino, c.references.column) });
125
+ }
126
+ return { clave, claveAutomatica, alCrear, alEditar, fuera, visibles, relaciones, mysql };
127
+ }
128
+
129
+ /** Las columnas que se prefieren para nombrar una fila de otra tabla. */
130
+ const ROTULOS = ["name", "title", "label", "full_name", "display_name", "username", "email", "code", "slug"];
131
+
132
+ /** Con qué columna se muestra una fila de `tabla` en una lista: su nombre, si tiene, o la clave. */
133
+ function etiquetaDe(tabla, valor) {
134
+ const texto = tabla.columns.filter((c) => c.kind === "text" && !c.array);
135
+ const preferida = ROTULOS.map((r) => texto.find((c) => c.name.toLowerCase() === r)).find(Boolean);
136
+ return (preferida ?? texto.find((c) => c.name !== valor) ?? { name: valor }).name;
119
137
  }
120
138
 
139
+ /** La relación de una columna, si apunta a otra tabla. */
140
+ const relacionDe = (a, c) => a.relaciones.find((r) => r.columna === c);
141
+
142
+ /** El nombre de la consulta con las opciones de una columna: ProductCategoryIdOptions. */
143
+ export const consultaOpciones = (n, r) => `${n.uno}${pascal(r.columna.name)}Options`;
144
+
121
145
  /** Los nombres de las consultas del recurso. */
122
146
  export function nombres(tabla) {
123
147
  const plural = pascal(tabla.name);
@@ -154,7 +178,14 @@ export function consultasSql(tabla, a, n) {
154
178
  `-- name: ${n.get} :one\nSELECT * FROM ${tab} WHERE ${pk} = :${a.clave.name};\n\n` +
155
179
  `${crear}\n\n` +
156
180
  actualizar +
157
- `-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n`
181
+ `-- name: ${n.delete} :execrows\nDELETE FROM ${tab} WHERE ${pk} = :${a.clave.name};\n` +
182
+ a.relaciones
183
+ .map(
184
+ (r) =>
185
+ `\n-- name: ${consultaOpciones(n, r)} :many\n-- The choices for ${r.columna.name}, by ${r.etiqueta}.\n` +
186
+ `SELECT ${q(r.valor)} AS value, ${q(r.etiqueta)} AS label FROM ${q(r.tabla)}\nORDER BY ${q(r.etiqueta)}\nLIMIT 1000;\n`,
187
+ )
188
+ .join("")
158
189
  );
159
190
  }
160
191
 
@@ -183,14 +214,23 @@ function campo(c, valoresEnum) {
183
214
  }
184
215
 
185
216
  /** El control HTML de una columna, dentro del `view` de `…Fields`. */
186
- function control(c, valoresEnum) {
217
+ function control(c, valoresEnum, relacion) {
187
218
  const nombre = c.name;
188
219
  const v = `values${acceso(nombre)}`;
189
220
  const texto = `String(${v} ?? "")`;
190
221
  // El esquema exige todo lo NOT NULL salvo casillas y texto con DEFAULT (que acepta vacío).
191
222
  const req = !c.nullable && c.kind !== "boolean" && !(c.kind === "text" && c.hasDefault) ? " required" : "";
192
223
  let html;
193
- switch (c.kind) {
224
+ switch (relacion ? "relacion" : c.kind) {
225
+ case "relacion": {
226
+ const vacia = c.nullable ? `<option value=""></option>` : `<option value="" disabled prop:selected=\${!${v}}>Choose…</option>`;
227
+ html =
228
+ `<select name="${nombre}"${req}>${vacia}\n` +
229
+ ` <For each=\${() => props.options${acceso(nombre)}} key=\${(o: Option) => o.value}\n` +
230
+ ` render=\${(o: Option) => view\`<option value=\${o.value} prop:selected=\${${v} === o.value}>\${o.label}</option>\`}/>\n` +
231
+ ` </select>`;
232
+ break;
233
+ }
194
234
  case "boolean":
195
235
  html = `<input type="checkbox" name="${nombre}" prop:checked=\${${v} === "on"}>`;
196
236
  break;
@@ -246,26 +286,42 @@ function aTexto(c, fila) {
246
286
  }
247
287
 
248
288
  /**
249
- * `duplicate()` del recurso: un UNIQUE o una clave repetidos vuelven al
250
- * formulario con un 409 en vez de un 500. Postgres dice la columna en el
251
- * `detail`; MySQL da el nombre del índice, así que ahí el error es general.
289
+ * `rejected()` del recurso: lo que la base rechaza por una restricción vuelve
290
+ * al formulario con un 409 y el error bajo su columna, en vez de un 500. Un
291
+ * UNIQUE repetido, una clave foránea que no existe y borrar algo que otras
292
+ * filas usan. Postgres dice la columna en el `detail`; MySQL, en el mensaje
293
+ * de la clave foránea, y en un UNIQUE solo el índice: ahí el error es general.
252
294
  */
253
295
  function duplicado(mysql) {
254
296
  const deteccion = mysql
255
- ? ` if (e?.code !== "ER_DUP_ENTRY") throw error;
256
- const field = "";`
257
- : ` if (e?.code !== "23505") throw error;
258
- // "Key (sku)=(H-1) already exists."
259
- const column = /^Key \\(([^)]+)\\)=/.exec(String(e.detail))?.[1];
260
- const field = column && !column.includes(",") ? column : "";`;
297
+ ? ` const code = e?.code;
298
+ // "… FOREIGN KEY (\`supplier_id\`) REFERENCES …"
299
+ const column = /FOREIGN KEY \\(\`([^\`]+)\`\\)/.exec(String(e?.message))?.[1] ?? "";
300
+ if (code === "ER_DUP_ENTRY") return reject("", "Already taken.");
301
+ if (code === "ER_NO_REFERENCED_ROW_2") return reject(column, "Does not exist.");
302
+ if (code === "ER_ROW_IS_REFERENCED_2") return reject("", "Still used by other rows.");
303
+ throw error;`
304
+ : ` // "Key (sku)=(H-1) already exists.", "Key (supplier_id)=(9) is not present in table …"
305
+ const named = /^Key \\(([^)]+)\\)=/.exec(String(e?.detail))?.[1];
306
+ const column = named && !named.includes(",") ? named : "";
307
+ if (e?.code === "23505") return reject(column, "Already taken.");
308
+ if (e?.code === "23503") {
309
+ // Deleting a row that others point to says "still referenced".
310
+ if (/still referenced/.test(String(e.detail))) return reject("", "Still used by other rows.");
311
+ return reject(column, "Does not exist.");
312
+ }
313
+ throw error;`;
261
314
  return `/**
262
- * A duplicate of a UNIQUE column or of the key, as the same form again with
263
- * the error under that field (409). Any other error is thrown again.
315
+ * What the database rejected because of a constraint, as the same form again
316
+ * with the error under its field (409): a repeated UNIQUE value, a key of
317
+ * another table that does not exist, deleting a row others still use. Any
318
+ * other error is thrown again.
264
319
  */
265
- export function duplicate(error: unknown, formData: FormData) {
266
- const e = error as { code?: unknown; detail?: unknown } | null;
320
+ export function rejected(error: unknown, formData: FormData) {
321
+ const e = error as { code?: unknown; detail?: unknown; message?: unknown } | null;
322
+ const reject = (field: string, message: string) =>
323
+ fail(409, { ok: false as const, errors: { [field]: message }, values: texts(formData) });
267
324
  ${deteccion}
268
- return fail(409, { ok: false as const, errors: { [field]: "Already taken." }, values: texts(formData) });
269
325
  }
270
326
 
271
327
  /** The text fields of a form, to fill it in again. */
@@ -285,7 +341,30 @@ export function recurso(tabla, a, n, { db, enums }) {
285
341
  const conClave = !a.claveAutomatica;
286
342
 
287
343
  const esquema = a.alEditar.map((c) => ` ${propiedad(c.name)}: ${campo(c, valoresEnum(c))},`).join("\n");
288
- const controles = (cols) => cols.map((c) => control(c, valoresEnum(c))).join("\n");
344
+ const controles = (cols) => cols.map((c) => control(c, valoresEnum(c), relacionDe(a, c))).join("\n");
345
+ const funcionesOpciones = a.relaciones.map((r) => camel(consultaOpciones(n, r)));
346
+ const opciones = a.relaciones.length
347
+ ? `
348
+ /** The choices of the columns that point to other tables, as text. */
349
+ export async function ${camel(n.uno)}Options(db: Queryable): Promise<Options> {
350
+ const [${a.relaciones.map((r) => camel(r.columna.name)).join(", ")}] = await Promise.all([
351
+ ${funcionesOpciones.map((f) => ` ${f}(db),`).join("\n")}
352
+ ]);
353
+ const text = (rows: { value: unknown; label: unknown }[]) => rows.map((o) => ({ value: String(o.value), label: String(o.label ?? o.value) }));
354
+ return {
355
+ ${a.relaciones.map((r) => ` ${propiedad(r.columna.name)}: text(${camel(r.columna.name)}),`).join("\n")}
356
+ };
357
+ }
358
+ `
359
+ : `
360
+ /** The choices of the columns that point to other tables: ${tabla.name} has none. */
361
+ export async function ${camel(n.uno)}Options(_db: Queryable): Promise<Options> {
362
+ return {};
363
+ }
364
+ `;
365
+ const tipoOpciones = a.relaciones.length
366
+ ? `{ ${a.relaciones.map((r) => `${propiedad(r.columna.name)}: Option[]`).join("; ")} }`
367
+ : "Record<string, never>";
289
368
  const aParams = a.alEditar
290
369
  .map((c) => {
291
370
  const v = `data${acceso(c.name)}`;
@@ -326,7 +405,7 @@ export const New${n.uno}Form = ${n.uno}Form;
326
405
  import type { Infer } from "ascua";
327
406
  import { fail, field, fields } from "vite-plugin-ascua/site";
328
407
 
329
- import { ${[...importsEnum, `type ${n.get}Row`].join(", ")} } from "${db}";
408
+ import { ${[...importsEnum, ...funcionesOpciones, "type Queryable", `type ${n.get}Row`].join(", ")} } from "${db}";
330
409
 
331
410
  const shape = {
332
411
  ${esquema}
@@ -338,6 +417,9 @@ ${nuevoEsquema}
338
417
  export type ${n.uno}Data = Infer<typeof ${n.uno}Form>;
339
418
  export type FormValues = Partial<Record<string, string | string[]>>;
340
419
  export type FormErrors = Partial<Record<string, string>>;
420
+ /** One choice of a <select>: the value it sends and what it shows. */
421
+ export type Option = { value: string; label: string };
422
+ export type Options = ${tipoOpciones};
341
423
  ${fechas}
342
424
  /** The form data as query parameters: empty optional fields become NULL. */
343
425
  export function ${camel(n.uno)}Params(data: ${n.uno}Data) {
@@ -346,7 +428,7 @@ ${aParams}
346
428
  };
347
429
  }
348
430
 
349
- ${duplicado(a.mysql)}
431
+ ${duplicado(a.mysql)}${opciones}
350
432
  /** A row as the values of the form. */
351
433
  export function ${camel(n.uno)}Values(row: ${n.get}Row): FormValues {
352
434
  return {
@@ -355,7 +437,7 @@ ${aValores}
355
437
  }
356
438
 
357
439
  /** The fields of the form, with the error of each one under it. */
358
- export function ${n.uno}Fields(props: { values: FormValues; errors: FormErrors${conClave ? "; withKey?: boolean" : ""} }) {
440
+ export function ${n.uno}Fields(props: { values: FormValues; errors: FormErrors; options: Options${conClave ? "; withKey?: boolean" : ""} }) {
359
441
  const { values, errors } = props;
360
442
  return view\`
361
443
  <div class="fields">
@@ -393,16 +475,21 @@ export function rutas(tabla, a, n, { db, pool, resource, base }) {
393
475
  const params = `${camel(n.uno)}Params`;
394
476
  const values = `${camel(n.uno)}Values`;
395
477
  const campos = `${n.uno}Fields`;
478
+ const opcionesFn = `${camel(n.uno)}Options`;
396
479
 
397
480
  const cabeceras = [pk, ...a.visibles.map((c) => c.name)].map((c) => `<th>${etiqueta(c)}</th>`).join("");
398
- const celdas = a.visibles.map((c) => `<td>\${show(r${acceso(c.name)})}</td>`).join("");
481
+ // Una columna que apunta a otra tabla se lee por su nombre, no por su clave.
482
+ const conOpciones = a.visibles.some((c) => relacionDe(a, c));
483
+ const celdas = a.visibles
484
+ .map((c) => (relacionDe(a, c) ? `<td>\${label(data.options${acceso(c.name)}, r${acceso(c.name)})}</td>` : `<td>\${show(r${acceso(c.name)})}</td>`))
485
+ .join("");
399
486
 
400
487
  const index = `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
401
488
  import type { LoadContext } from "vite-plugin-ascua/site";
402
489
 
403
490
  import { ${camel(n.list)}, type ${n.list}Row } from "${db.index}";
404
491
  import { db } from "${pool.index}";
405
-
492
+ ${conOpciones ? `import { ${opcionesFn}, type Option } from "${resource.nested}";\n` : ""}
406
493
  const PAGE = 50;
407
494
 
408
495
  export const title = "${titulo}";
@@ -411,7 +498,7 @@ export async function load(_params: Record<string, string>, { url }: LoadContext
411
498
  const page = Math.max(1, Math.floor(Number(url.searchParams.get("page"))) || 1);
412
499
  // One more than a page, to know whether there is a next one.
413
500
  const rows = await ${camel(n.list)}(await db(), { limit: PAGE + 1, offset: (page - 1) * PAGE });
414
- return { page, rows: rows.slice(0, PAGE), more: rows.length > PAGE };
501
+ return { page, rows: rows.slice(0, PAGE), more: rows.length > PAGE${conOpciones ? `, options: await ${opcionesFn}(await db())` : ""} };
415
502
  }
416
503
 
417
504
  /** How a value of any column reads in a cell. */
@@ -421,6 +508,12 @@ function show(value: unknown): string {
421
508
  if (typeof value === "boolean") return value ? "yes" : "no";
422
509
  return String(value);
423
510
  }
511
+ ${conOpciones ? `
512
+ /** A key of another table, by its name. */
513
+ function label(options: Option[], value: unknown): string {
514
+ return options.find((o) => o.value === String(value))?.label ?? show(value);
515
+ }
516
+ ` : ""}
424
517
 
425
518
  export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof load>> }) {
426
519
  return view\`
@@ -449,7 +542,7 @@ export default function ${n.plural}({ data }: { data: Awaited<ReturnType<typeof
449
542
  // Crear dentro de try: un UNIQUE repetido vuelve al formulario. La
450
543
  // redirección va fuera, porque también se lanza.
451
544
  const intento = (asignar, despues) =>
452
- `${asignar ? `let ${asignar};\n ` : ""}try {\n ${asignar ? `${asignar} = ` : ""}await ${camel(n.create)}(await db(), ${datos});\n } catch (error) {\n return duplicate(error, formData);\n }\n throw redirect(\`${base}/\${${despues}}\`);`;
545
+ `${asignar ? `let ${asignar};\n ` : ""}try {\n ${asignar ? `${asignar} = ` : ""}await ${camel(n.create)}(await db(), ${datos});\n } catch (error) {\n return rejected(error, formData);\n }\n throw redirect(\`${base}/\${${despues}}\`);`;
453
546
  const despuesDeCrear = a.mysql
454
547
  ? a.claveAutomatica
455
548
  ? intento("id", "id")
@@ -461,10 +554,14 @@ import { fail, redirect, validate, type ActionContext, type FormResult } from "v
461
554
 
462
555
  import { ${camel(n.create)} } from "${db.nested}";
463
556
  import { db } from "${pool.nested}";
464
- import { ${nuevaForma}, ${campos}, ${params}, duplicate } from "${resource.nested}";
557
+ import { ${nuevaForma}, ${campos}, ${params}, ${opcionesFn}, rejected } from "${resource.nested}";
465
558
 
466
559
  export const title = "New ${uno}";
467
560
 
561
+ export async function load() {
562
+ return { options: await ${opcionesFn}(await db()) };
563
+ }
564
+
468
565
  export const actions = {
469
566
  async default({ formData }: ActionContext) {
470
567
  const result = await validate(${nuevaForma}, formData);
@@ -473,13 +570,13 @@ export const actions = {
473
570
  },
474
571
  };
475
572
 
476
- export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nuevaForma}> }) {
573
+ export default function New${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${nuevaForma}> }) {
477
574
  const failed = form?.ok === false ? form : undefined;
478
575
  return view\`
479
576
  <section>
480
577
  <h1>New ${uno}</h1>
481
578
  <form method="post">
482
- <${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
579
+ <${campos} values=\${failed?.values ?? {}} errors=\${failed?.errors ?? {}} options=\${data.options}${a.claveAutomatica ? "" : " withKey=\${true}"}/>
483
580
  <button>Create</button>
484
581
  </form>
485
582
  <p><a href="${base}">Back</a></p>
@@ -497,7 +594,7 @@ export default function New${n.uno}({ form }: { form?: FormResult<typeof ${nueva
497
594
  try {
498
595
  changed = await ${camel(n.update)}(await db(), { ...${params}(result.data), ${propiedad(pk)}: id });
499
596
  } catch (error) {
500
- return duplicate(error, formData);
597
+ return rejected(error, formData);
501
598
  }
502
599
  if (changed === 0) throw notFound();
503
600
  throw redirect("${base}");
@@ -509,7 +606,7 @@ import { fail, notFound, redirect, validate, type ActionContext, type FormResult
509
606
 
510
607
  import { ${[camel(n.get), a.alEditar.length ? camel(n.update) : null, camel(n.delete)].filter(Boolean).join(", ")} } from "${db.nested}";
511
608
  import { db } from "${pool.nested}";
512
- import { ${forma}, ${campos}, ${params}, ${values}, duplicate } from "${resource.nested}";
609
+ import { ${forma}, ${campos}, ${params}, ${values}, ${opcionesFn}, rejected } from "${resource.nested}";
513
610
 
514
611
  type Params = { id: string };
515
612
 
@@ -524,23 +621,29 @@ export const title = ({ id }: Params) => \`${etiqueta(singular(tabla.name))} \${
524
621
  export async function load({ id }: Params) {
525
622
  const row = await ${camel(n.get)}(await db(), { ${propiedad(pk)}: key(id) });
526
623
  if (!row) throw notFound();
527
- return row;
624
+ return { row, options: await ${opcionesFn}(await db()) };
528
625
  }
529
626
 
530
627
  export const actions = {${actualizar}
531
- async delete({ params }: ActionContext<Params>) {
532
- await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
628
+ async delete({ params, formData }: ActionContext<Params>) {
629
+ try {
630
+ await ${camel(n.delete)}(await db(), { ${propiedad(pk)}: key(params.id) });
631
+ } catch (error) {
632
+ return rejected(error, formData);
633
+ }
533
634
  throw redirect("${base}");
534
635
  },
535
636
  };
536
637
 
537
638
  export default function Edit${n.uno}({ data, form }: { data: Awaited<ReturnType<typeof load>>; form?: FormResult<typeof ${forma}> }) {
538
639
  const failed = form?.ok === false ? form : undefined;
640
+ // A rejected delete sends no fields: the form keeps showing the row.
641
+ const shown = failed && Object.keys(failed.values).length > 0 ? failed.values : ${values}(data.row);
539
642
  return view\`
540
643
  <section>
541
- <h1>${etiqueta(singular(tabla.name))} \${String(${fila("data")})}</h1>
644
+ <h1>${etiqueta(singular(tabla.name))} \${String(${fila("data.row")})}</h1>
542
645
  ${a.alEditar.length ? `<form method="post">
543
- <${campos} values=\${failed?.values ?? ${values}(data)} errors=\${failed?.errors ?? {}}/>
646
+ <${campos} values=\${shown} errors=\${failed?.errors ?? {}} options=\${data.options}/>
544
647
  <button>Save</button>
545
648
  </form>
546
649
  ` : ""}<form method="post" action="?/delete">
@@ -599,12 +702,19 @@ export function readGrid(formData: FormData, columns: readonly string[]): { rows
599
702
  }
600
703
 
601
704
  /** Una celda editable de la grilla, con su error debajo. */
602
- function celda(c, valoresEnum) {
705
+ function celda(c, valoresEnum, relacion) {
603
706
  const nombre = `\${\`c\${r.index}.${c.name}\`}`;
604
707
  const v = `r.values${acceso(c.name)}`;
605
708
  const texto = `String(${v} ?? "")`;
606
709
  let html;
607
- switch (c.kind) {
710
+ switch (relacion ? "relacion" : c.kind) {
711
+ case "relacion": {
712
+ const vacia = c.nullable ? `<option value=""></option>` : "";
713
+ html =
714
+ `<select name=${nombre}>${vacia}<For each=\${() => data.options${acceso(c.name)}} key=\${(o: Option) => o.value} ` +
715
+ `render=\${(o: Option) => view\`<option value=\${o.value} prop:selected=\${${v} === o.value}>\${o.label}</option>\`}/></select>`;
716
+ break;
717
+ }
608
718
  case "boolean":
609
719
  html = `<input type="checkbox" name=${nombre} prop:checked=\${${v} === "on"}>`;
610
720
  break;
@@ -653,7 +763,8 @@ export function rejilla(tabla, a, n, { db, pool, resource, grid, base, enums })
653
763
  const importsEnum = [...new Set(a.alEditar.filter((c) => c.kind === "enum" && enums.has(c.sqlType)).map((c) => `${camel(c.sqlType)}Values`))];
654
764
  const columnas = a.alEditar.map((c) => JSON.stringify(c.name)).join(", ");
655
765
  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 ");
766
+ const celdas = a.alEditar.map((c) => celda(c, valoresEnumDe(c, enums), relacionDe(a, c))).join("\n ");
767
+ const opcionesFn = `${camel(n.uno)}Options`;
657
768
 
658
769
  return `// Generated by ascua-scaffold. Yours to edit: it is not generated again.
659
770
  //
@@ -665,7 +776,7 @@ import { fail, notFound, redirect, validate, type ActionContext, type LoadContex
665
776
  import { ${[...importsEnum, camel(n.list), "transaction", camel(n.update)].join(", ")} } from "${db}";
666
777
  import { db } from "${pool}";
667
778
  import { readGrid, type FormErrors, type FormValues } from "${grid}";
668
- import { ${forma}, ${params}, ${values}, duplicate, type ${n.uno}Data } from "${resource}";
779
+ import { ${forma}, ${params}, ${values}, ${opcionesFn}, rejected, type ${n.uno}Data, type Option } from "${resource}";
669
780
 
670
781
  const PAGE = 50;
671
782
  const COLUMNS = [${columnas}] as const;
@@ -685,6 +796,7 @@ export async function load(_params: Record<string, string>, { url }: LoadContext
685
796
  page,
686
797
  more: rows.length > PAGE,
687
798
  rows: rows.slice(0, PAGE).map((row) => ({ key: String(row${acceso(pk)}), values: ${values}(row) })),
799
+ options: await ${opcionesFn}(await db()),
688
800
  };
689
801
  }
690
802
 
@@ -716,8 +828,8 @@ export const actions = {
716
828
  }
717
829
  });
718
830
  } catch (error) {
719
- // A repeated UNIQUE value: the error goes under that row's cell.
720
- const taken = duplicate(error, new FormData());
831
+ // A repeated UNIQUE value or a missing key: the error goes under that row's cell.
832
+ const taken = rejected(error, new FormData());
721
833
  return fail(409, { errors: { [current]: taken.data.errors }, values } satisfies Failed);
722
834
  }
723
835
  throw redirect(url.pathname + url.search);
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, ayudaRejilla, consultasSql, datosAdmin, guardiaAdmin, marcoAdmin, nombres, panelAdmin, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
19
+ import { analizar, ayudaRejilla, camel, consultaOpciones, consultasSql, datosAdmin, guardiaAdmin, marcoAdmin, nombres, panelAdmin, piscina, recurso, rejilla, rutas, ScaffoldError, t } from "./generate.js";
20
20
 
21
21
  const AYUDA = t(
22
22
  `Uso: ascua-scaffold <tabla> [opciones]
@@ -146,7 +146,7 @@ async function proyecto(args) {
146
146
  * y su `resources/<tabla>.ts` los conserva y solo recibe rutas nuevas.
147
147
  */
148
148
  function planTabla(p, tabla, { prefijo, force, reutilizar }) {
149
- const a = analizar(tabla, { mysql: p.mysql });
149
+ const a = analizar(tabla, { mysql: p.mysql, tablas: p.salida.tables });
150
150
  const n = nombres(tabla);
151
151
  const carpeta = tabla.name.replace(/_/g, "-").toLowerCase();
152
152
  if (carpeta === "grid") {
@@ -158,9 +158,16 @@ function planTabla(p, tabla, { prefijo, force, reutilizar }) {
158
158
 
159
159
  const sql = join(p.dirConsultas, `${tabla.name}.sql`);
160
160
  const archivoRecurso = join(p.raiz, "src", "resources", `${carpeta}.ts`);
161
- const consultas = [n.list, n.get, n.create, n.update, n.delete];
161
+ const consultas = [n.list, n.get, n.create, n.update, n.delete, ...a.relaciones.map((r) => consultaOpciones(n, r))];
162
162
  const hay = consultas.filter((q) => p.salida.queries.some((x) => x.name === q));
163
- const reusa = reutilizar && !force && hay.length === consultas.length && existsSync(archivoRecurso);
163
+ // Un recurso de un ascua-scaffold anterior a las relaciones no trae `<x>Options`.
164
+ const conOpciones = existsSync(archivoRecurso) && readFileSync(archivoRecurso, "utf8").includes(`function ${camel(n.uno)}Options(`);
165
+ const reusa = reutilizar && !force && hay.length === consultas.length && conOpciones;
166
+ if (reutilizar && !force && hay.length === consultas.length && existsSync(archivoRecurso) && !conOpciones) {
167
+ throw new ScaffoldError(
168
+ t(`${relative(p.raiz, archivoRecurso)} es de un ascua-scaffold anterior: regenéralo con \`ascua-scaffold ${tabla.name} --force\``, `${relative(p.raiz, archivoRecurso)} is from an older ascua-scaffold: regenerate it with \`ascua-scaffold ${tabla.name} --force\``),
169
+ );
170
+ }
164
171
 
165
172
  if (!reusa) {
166
173
  // Con --force, las del archivo que se va a reescribir no cuentan.
@@ -299,7 +306,8 @@ function admin(p, args) {
299
306
  }
300
307
 
301
308
  const escritos = escribir(p, planes, extra);
302
- terminar(p, escritos, t(`Listo: /admin con ${incluidas.length} tablas (${planes.length} nuevas).`, `Done: /admin with ${incluidas.length} tables (${planes.length} new).`));
309
+ const cuantas = incluidas.length === 1 ? t("1 tabla", "1 table") : t(`${incluidas.length} tablas`, `${incluidas.length} tables`);
310
+ terminar(p, escritos, t(`Listo: /admin con ${cuantas} (${planes.length} nuevas).`, `Done: /admin with ${cuantas} (${planes.length} new).`));
303
311
  for (const plan of planes.filter((x) => x.reusa)) {
304
312
  console.log(t(` ${plan.tabla}: usa las consultas y el formulario que ya tenía`, ` ${plan.tabla}: uses the queries and form it already had`));
305
313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ascua-scaffold",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",