@sealcode/sealgen 0.6.1 → 0.7.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 (78) hide show
  1. package/.nyc_output/1c5dff80-7861-427a-b1bb-d0686211f098.json +1 -0
  2. package/.nyc_output/a38a0d08-1f1f-4973-b5e2-efc434c1593d.json +1 -0
  3. package/.nyc_output/processinfo/1c5dff80-7861-427a-b1bb-d0686211f098.json +1 -0
  4. package/.nyc_output/processinfo/a38a0d08-1f1f-4973-b5e2-efc434c1593d.json +1 -0
  5. package/.nyc_output/processinfo/index.json +1 -0
  6. package/.xunit +3 -0
  7. package/@types/forms/controls/field-group.d.ts +17 -0
  8. package/@types/forms/controls/form-control.d.ts +1 -1
  9. package/@types/forms/controls/form-field-control.d.ts +1 -1
  10. package/@types/forms/controls/simple-input.d.ts +2 -1
  11. package/@types/forms/form.d.ts +19 -6
  12. package/@types/forms/multiform.d.ts +9 -11
  13. package/@types/index.d.ts +1 -0
  14. package/@types/page/mountable-with-fields.d.ts +32 -0
  15. package/@types/page/mountable.d.ts +2 -27
  16. package/@types/page/page.d.ts +2 -2
  17. package/@types/templates/multiform.d.ts +1 -0
  18. package/@types/templates/redirect.d.ts +1 -0
  19. package/@types/utils/input-wrapper.d.ts +4 -0
  20. package/coverage/clover.xml +79 -0
  21. package/lib/add-route.js +27 -8
  22. package/lib/add-route.js.map +1 -1
  23. package/lib/forms/controls/checkboxed-list-input.js +12 -9
  24. package/lib/forms/controls/checkboxed-list-input.js.map +1 -1
  25. package/lib/forms/controls/dropdown.js +14 -12
  26. package/lib/forms/controls/dropdown.js.map +1 -1
  27. package/lib/forms/controls/field-group.js +26 -0
  28. package/lib/forms/controls/field-group.js.map +1 -0
  29. package/lib/forms/controls/form-control.js.map +1 -1
  30. package/lib/forms/controls/form-field-control.js.map +1 -1
  31. package/lib/forms/controls/simple-input.js +6 -8
  32. package/lib/forms/controls/simple-input.js.map +1 -1
  33. package/lib/forms/controls/textarea.js +13 -15
  34. package/lib/forms/controls/textarea.js.map +1 -1
  35. package/lib/forms/form.js +95 -57
  36. package/lib/forms/form.js.map +1 -1
  37. package/lib/forms/multiform.js +52 -62
  38. package/lib/forms/multiform.js.map +1 -1
  39. package/lib/generate-routes.js +7 -3
  40. package/lib/generate-routes.js.map +1 -1
  41. package/lib/index.js +1 -0
  42. package/lib/index.js.map +1 -1
  43. package/lib/page/list.js +1 -1
  44. package/lib/page/list.js.map +1 -1
  45. package/lib/page/mountable-with-fields.js +63 -0
  46. package/lib/page/mountable-with-fields.js.map +1 -0
  47. package/lib/page/mountable.js +2 -49
  48. package/lib/page/mountable.js.map +1 -1
  49. package/lib/page/page.js +2 -2
  50. package/lib/page/page.js.map +1 -1
  51. package/lib/templates/multiform.js +59 -0
  52. package/lib/templates/multiform.js.map +1 -0
  53. package/lib/templates/redirect.js +27 -0
  54. package/lib/templates/redirect.js.map +1 -0
  55. package/lib/tests/manual/multiform.js +14 -5
  56. package/lib/tests/manual/multiform.js.map +1 -1
  57. package/lib/utils/input-wrapper.js +20 -0
  58. package/lib/utils/input-wrapper.js.map +1 -0
  59. package/package.json +4 -3
  60. package/src/add-route.ts +30 -11
  61. package/src/forms/controls/checkboxed-list-input.ts +17 -10
  62. package/src/forms/controls/dropdown.ts +28 -23
  63. package/src/forms/controls/form-control.ts +3 -1
  64. package/src/forms/controls/form-field-control.ts +4 -1
  65. package/src/forms/controls/simple-input.ts +19 -12
  66. package/src/forms/controls/textarea.ts +15 -14
  67. package/src/forms/form.ts +122 -76
  68. package/src/forms/multiform.ts +211 -0
  69. package/src/generate-routes.ts +7 -3
  70. package/src/index.ts +1 -0
  71. package/src/page/list.ts +1 -1
  72. package/src/page/mountable-with-fields.ts +112 -0
  73. package/src/page/mountable.ts +4 -96
  74. package/src/page/page.ts +7 -3
  75. package/src/templates/multiform.ts +58 -0
  76. package/src/templates/redirect.ts +22 -0
  77. package/src/tests/manual/multiform.ts +106 -0
  78. package/src/utils/input-wrapper.ts +18 -0
@@ -1,5 +1,7 @@
1
1
  import { Context } from "koa";
2
+ import { FlatTemplatable } from "tempstream";
2
3
  import { attribute } from "../../sanitize";
4
+ import { getRequiredClass, inputWrapper } from "../../utils/input-wrapper";
3
5
  import { FormField } from "../fields/field";
4
6
  import { FormDataValue } from "../form";
5
7
  import { FormFieldControl } from "./controls";
@@ -37,8 +39,11 @@ export class SimpleInput extends FormFieldControl {
37
39
 
38
40
  async render<Fields extends Record<string, FormField>>(
39
41
  ctx: Context,
40
- data: Record<string, FormDataValue>
41
- ) {
42
+ data: Record<string, FormDataValue>,
43
+ _: unknown[],
44
+ field_prefix: string,
45
+ form_id: string
46
+ ): Promise<FlatTemplatable> {
42
47
  const id = this.options.id || this.field.name;
43
48
  const label = this.options.label || this.field.name;
44
49
  const type = this.options.type || "text";
@@ -52,16 +57,14 @@ export class SimpleInput extends FormFieldControl {
52
57
  const readonly = this.options.readonly || false;
53
58
  const required = this.field.required;
54
59
 
55
- return /* HTML */ `<div
56
- class="form-input form-input__wrapper ${required
57
- ? "form-input--required"
58
- : ""}"
59
- >
60
+ return /* HTML */ inputWrapper(
61
+ [id, type, getRequiredClass(required)],
62
+ `
60
63
  <label for="${id}">${label}</label>
61
64
  <input
62
65
  id="${id}"
63
66
  type="${type}"
64
- name="${this.field.name}"
67
+ name="${field_prefix}${this.field.name}"
65
68
  value="${attribute(
66
69
  !valid && raw
67
70
  ? raw.toString()
@@ -70,15 +73,19 @@ export class SimpleInput extends FormFieldControl {
70
73
  ((parsed as any)?.toString() as string) || ""
71
74
  : ""
72
75
  )}"
76
+ ${form_id ? `form="${form_id}"` : ""}
73
77
  placeholder="${placeholder}"
74
78
  ${readonly ? "readonly" : ""}
75
79
  ${required ? "required" : ""}
76
80
  ${!this.options.autocomplete ? `autocomplete="off"` : ""}
77
81
  ${this.options.step ? `step="${this.options.step}"` : ""}
78
82
  />
79
- ${~valid && !this.options.hide_errors
80
- ? `<div class="input__error">${message}</div>`
81
- : ""}
82
- </div>`;
83
+ ${
84
+ ~valid && !this.options.hide_errors
85
+ ? `<div class="input__error">${message}</div>`
86
+ : ""
87
+ }
88
+ `
89
+ );
83
90
  }
84
91
  }
@@ -1,6 +1,7 @@
1
1
  import { Context } from "koa";
2
2
  import { FlatTemplatable, tempstream } from "tempstream";
3
3
  import { attribute } from "../../sanitize";
4
+ import { getRequiredClass, inputWrapper } from "../../utils/input-wrapper";
4
5
  import { FormField } from "../fields/field";
5
6
  import { FormDataValue } from "../form";
6
7
  import { FormFieldControl } from "./form-field-control";
@@ -18,20 +19,20 @@ export class Textarea extends FormFieldControl {
18
19
  data: Record<string, FormDataValue>
19
20
  ): Promise<FlatTemplatable> {
20
21
  const { raw } = await this.field.getValue(ctx, data);
21
- return tempstream/* HTML */ `<div
22
- class="form-input form-input__wrapper"
23
- >
24
- <label for="${this.field.name}"
25
- >${this.options.label || this.field.name}</label
26
- >
27
- <textarea
28
- name="${this.field.name}"
29
- id="${this.field.name}"
30
- rows="${this.options.rows || 5}"
31
- cols="${this.options.cols || 33}"
32
- >
22
+ const { name, required } = this.field;
23
+ return inputWrapper(
24
+ [name, getRequiredClass(required)],
25
+ /* HTML */ ` <label for="${name}"
26
+ >${this.options.label || name}</label
27
+ >
28
+ <textarea
29
+ name="${name}"
30
+ id="${name}"
31
+ rows="${this.options.rows || 5}"
32
+ cols="${this.options.cols || 33}"
33
+ >
33
34
  ${attribute((raw || "").toString())}</textarea
34
- >
35
- </div>`;
35
+ >`
36
+ );
36
37
  }
37
38
  }
package/src/forms/form.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context } from "koa";
2
2
  import Router from "@koa/router";
3
- import { FlatTemplatable, Templatable, tempstream } from "tempstream";
3
+ import { FlatTemplatable, tempstream } from "tempstream";
4
4
  import {
5
5
  hasFieldOfType,
6
6
  hasShape,
@@ -8,7 +8,11 @@ import {
8
8
  predicates,
9
9
  } from "@sealcode/ts-predicates";
10
10
  import { FormField } from "./fields/field";
11
- import { Fields, Mountable, PageErrorMessage } from "../page/mountable";
11
+ import {
12
+ Fields,
13
+ MountableWithFields,
14
+ PageErrorMessage,
15
+ } from "../page/mountable-with-fields";
12
16
 
13
17
  export type FormDataPrimitive = string | string[] | undefined;
14
18
  export type FormDataValue =
@@ -30,9 +34,14 @@ export type FormData<Fieldnames extends string = string> = {
30
34
  messages: FormMessage[];
31
35
  };
32
36
 
33
- export abstract class Form<F extends Fields> extends Mountable<F> {
37
+ export type FormReaction =
38
+ | { action: "stay"; content: FlatTemplatable; messages?: FormMessage[] }
39
+ | { action: "redirect"; url: string; messages?: FormMessage[] };
40
+
41
+ export abstract class Form<F extends Fields> extends MountableWithFields<F> {
34
42
  defaultSuccessMessage = "Done";
35
43
  submitButtonText = "Wyślij";
44
+ action = "./";
36
45
 
37
46
  async canAccess(
38
47
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -48,40 +57,54 @@ export abstract class Form<F extends Fields> extends Mountable<F> {
48
57
  return tempstream/* HTML */ `<div>${error.message}</div>`;
49
58
  }
50
59
 
60
+ makeSubmitButton(): FlatTemplatable {
61
+ return /* HTML */ `<input
62
+ type="submit"
63
+ value="${this.submitButtonText}"
64
+ formaction="${this.action}"
65
+ ${this.form_id ? `form="${this.form_id}"` : ""}
66
+ />`;
67
+ }
68
+
51
69
  async render(ctx: Context, data: FormData): Promise<FlatTemplatable> {
52
- return tempstream/* HTML */ `${this.makeFormTag()} ${
53
- !this.controls.some((control) => control.role == "messages")
54
- ? this.renderMessages(ctx, data)
55
- : ""
56
- } ${this.renderControls(ctx, data)}<input type="submit" value="${
57
- this.submitButtonText
58
- }"/></form>`;
70
+ return tempstream/* HTML */ `${this.makeOpenFormTag()}
71
+ ${!this.controls.some((control) => control.role == "messages")
72
+ ? this.renderMessages(ctx, data)
73
+ : ""}
74
+ ${this.renderControls(ctx, data)} ${this.makeSubmitButton()}
75
+ ${this.makeCloseFormTag()}`;
59
76
  }
60
77
 
61
- public makeFormTag(): FlatTemplatable {
62
- return `<form method="POST" action="./">`;
78
+ public makeOpenFormTag(): FlatTemplatable {
79
+ return `<form method="POST" action="${this.action}">`;
80
+ }
81
+
82
+ public makeCloseFormTag(): FlatTemplatable {
83
+ return `</form>`;
63
84
  }
64
85
 
65
86
  public async onValuesInvalid(
66
87
  ctx: Context,
67
88
  form_messages: FormMessage[]
68
- ): Promise<void> {
69
- ctx.status = 422;
70
- ctx.body = await this.render(ctx, {
71
- raw_values: ctx.$body,
72
-
73
- messages: form_messages.length
74
- ? form_messages
75
- : [{ type: "error", text: "Some fields are invalid" }],
76
- });
89
+ ): Promise<FormReaction> {
90
+ const messages = form_messages.length
91
+ ? form_messages
92
+ : [<const>{ type: "error", text: "Some fields are invalid" }];
93
+ return {
94
+ action: "stay",
95
+ content: await this.render(ctx, {
96
+ raw_values: this.extractRawValues(ctx),
97
+ messages,
98
+ }),
99
+ messages,
100
+ };
77
101
  }
78
102
 
79
103
  public async onError(
80
104
  ctx: Context,
81
105
  data: FormData,
82
106
  error: unknown
83
- ): Promise<void> {
84
- ctx.status = 422;
107
+ ): Promise<FormReaction> {
85
108
  let error_message = "Unknown error has occured";
86
109
  if (
87
110
  is(error, predicates.object) &&
@@ -89,10 +112,15 @@ export abstract class Form<F extends Fields> extends Mountable<F> {
89
112
  ) {
90
113
  error_message = error.message;
91
114
  }
92
- ctx.body = await this.render(ctx, {
93
- raw_values: data.raw_values,
94
- messages: [{ type: "error", text: error_message }],
95
- });
115
+ const messages = [<const>{ type: "error", text: error_message }];
116
+ return {
117
+ action: "stay",
118
+ content: await this.render(ctx, {
119
+ raw_values: data.raw_values,
120
+ messages: [],
121
+ }),
122
+ messages,
123
+ };
96
124
  }
97
125
 
98
126
  public abstract onSubmit(
@@ -100,12 +128,66 @@ export abstract class Form<F extends Fields> extends Mountable<F> {
100
128
  data: FormData
101
129
  ): void | Promise<void>;
102
130
 
103
- public async onSuccess(ctx: Context, data: FormData): Promise<void> {
104
- ctx.body = await this.render(ctx, {
105
- raw_values: data.raw_values,
106
- messages: [{ type: "success", text: this.defaultSuccessMessage }],
107
- });
108
- ctx.status = 422;
131
+ public async onSuccess(
132
+ ctx: Context,
133
+ data: FormData
134
+ ): Promise<FormReaction> {
135
+ const messages = [
136
+ <const>{ type: "success", text: this.defaultSuccessMessage },
137
+ ];
138
+ return {
139
+ action: "stay",
140
+ content: await this.render(ctx, {
141
+ raw_values: data.raw_values,
142
+ messages,
143
+ }),
144
+ messages,
145
+ };
146
+ }
147
+
148
+ async handlePost(ctx: Context): Promise<FormReaction> {
149
+ const { valid, form_messages } = await this.validate(
150
+ ctx,
151
+ this.extractRawValues(ctx)
152
+ );
153
+ if (!valid) {
154
+ return this.onValuesInvalid(ctx, form_messages);
155
+ }
156
+ try {
157
+ ctx.status = 303;
158
+ await this.onSubmit(ctx, {
159
+ raw_values: this.extractRawValues(ctx),
160
+ messages: [],
161
+ });
162
+ return this.onSuccess(ctx, {
163
+ raw_values: this.extractRawValues(ctx),
164
+ messages: [],
165
+ });
166
+ } catch (e: unknown) {
167
+ // eslint-disable-next-line no-console
168
+ console.dir(e, { depth: 5 });
169
+ const message =
170
+ is(e, predicates.object) &&
171
+ hasFieldOfType(e, "message", predicates.string)
172
+ ? e?.message
173
+ : is(e, predicates.string)
174
+ ? e
175
+ : "Wystąpił błąd";
176
+
177
+ return this.onError(
178
+ ctx,
179
+ {
180
+ raw_values: this.extractRawValues(ctx),
181
+ messages: [
182
+ {
183
+ type: "error",
184
+ text: message,
185
+ },
186
+ ],
187
+ },
188
+ e
189
+ );
190
+ }
109
191
  }
110
192
 
111
193
  public mount(router: Router, path: string): void {
@@ -124,54 +206,18 @@ export abstract class Form<F extends Fields> extends Mountable<F> {
124
206
  router.get(path, async (ctx) => {
125
207
  ctx.type = "html";
126
208
  ctx.body = await this.render(ctx, {
127
- raw_values: ctx.$body,
209
+ raw_values: this.extractRawValues(ctx),
128
210
  messages: [],
129
211
  });
130
212
  });
131
213
  router.post(path, async (ctx) => {
132
- const { valid, form_messages } = await this.validate(
133
- ctx,
134
- ctx.$body
135
- );
136
- if (!valid) {
137
- await this.onValuesInvalid(ctx, form_messages);
138
- return;
139
- }
140
- try {
141
- ctx.status = 303;
142
- await this.onSubmit(ctx, {
143
- raw_values: ctx.$body,
144
- messages: [],
145
- });
146
- await this.onSuccess(ctx, {
147
- raw_values: ctx.$body,
148
- messages: [],
149
- });
150
- } catch (e: unknown) {
151
- // eslint-disable-next-line no-console
152
- console.dir(e, { depth: 5 });
153
- const message =
154
- is(e, predicates.object) &&
155
- hasFieldOfType(e, "message", predicates.string)
156
- ? e?.message
157
- : is(e, predicates.string)
158
- ? e
159
- : "Wystąpił błąd";
160
-
214
+ const reaction = await this.handlePost(ctx);
215
+ if (reaction.action == "stay") {
161
216
  ctx.status = 422;
162
- await this.onError(
163
- ctx,
164
- {
165
- raw_values: ctx.$body,
166
- messages: [
167
- {
168
- type: "error",
169
- text: message,
170
- },
171
- ],
172
- },
173
- e
174
- );
217
+ ctx.body = reaction.content;
218
+ } else if (reaction.action == "redirect") {
219
+ ctx.status = 303;
220
+ ctx.redirect(reaction.url);
175
221
  }
176
222
  });
177
223
  }
@@ -0,0 +1,211 @@
1
+ import Router from "@koa/router";
2
+ import { is, predicates } from "@sealcode/ts-predicates";
3
+ import { Context } from "koa";
4
+ import { FlatTemplatable, tempstream } from "tempstream";
5
+ import { Form, FormDataValue, FormMessage } from "..";
6
+ import { Fields, MountableWithFields } from "../page/mountable-with-fields";
7
+ import { attribute } from "../sanitize";
8
+
9
+ const FIELD_PREFIX_SEPARATOR = "___";
10
+
11
+ export class Multiform extends MountableWithFields {
12
+ controls = [];
13
+ public name: string;
14
+ public forms: Record<string, Form<Fields>>;
15
+
16
+ init(): void {
17
+ super.init();
18
+ for (const [key, form] of Object.entries(this.forms)) {
19
+ if (key !== attribute(key)) {
20
+ throw new Error(
21
+ `Form name "${key}" is not url-safe. Try: "${attribute(
22
+ key
23
+ )}"`
24
+ );
25
+ }
26
+ form.makeOpenFormTag = () => `<div>`;
27
+ form.makeCloseFormTag = () => `</div>`;
28
+ form.field_names_prefix = key + FIELD_PREFIX_SEPARATOR;
29
+ form.form_id = this.name;
30
+ form.action = "./" + key;
31
+ form.init();
32
+ form.extractRawValues = (context) =>
33
+ this.getSubformRawValues(context, key);
34
+ const submit_button_id = this.name + "_" + key + "_submit";
35
+
36
+ form.makeSubmitButton = () => /* HTML */ `<input
37
+ type="submit"
38
+ value="${form.submitButtonText}"
39
+ formaction="${form.action}"
40
+ id="${submit_button_id}"
41
+ ${form.form_id ? `form="${form.form_id}"` : ""}
42
+ /> `;
43
+ }
44
+ for (const form of Object.values(this.forms)) {
45
+ for (const field of Object.values(form.fields)) {
46
+ if (field.name.includes(FIELD_PREFIX_SEPARATOR)) {
47
+ throw new Error(
48
+ `A field name within multiform cannot contain '${FIELD_PREFIX_SEPARATOR}'`
49
+ );
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ extractRawValues(ctx: Context): Record<string, FormDataValue> {
56
+ return ctx.$body || {};
57
+ }
58
+
59
+ async canAccess(): Promise<{ canAccess: boolean; message: string }> {
60
+ return {
61
+ canAccess: true,
62
+ message:
63
+ "this view includes multiple forms and each of them have their unique access rules",
64
+ };
65
+ }
66
+
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ getSubformsToRender(ctx: Context): Array<readonly [string, Form<any>]> {
69
+ const requested_frame = ctx.headers["turbo-frame"];
70
+ if (
71
+ !is(
72
+ requested_frame,
73
+ predicates.or(predicates.undefined, predicates.string)
74
+ )
75
+ ) {
76
+ throw new Error("Wrong turbo-frame header value type");
77
+ }
78
+ const forms_to_render =
79
+ requested_frame && this.forms[requested_frame]
80
+ ? [<const>[requested_frame, this.forms[requested_frame]]]
81
+ : Object.entries(this.forms);
82
+ return forms_to_render;
83
+ }
84
+
85
+ async renderSubform(
86
+ ctx: Context,
87
+ sub_form_name: string
88
+ ): Promise<FlatTemplatable> {
89
+ const form = this.forms[sub_form_name];
90
+ const result = await form.canAccess(ctx);
91
+ if (!result.canAccess) {
92
+ return result.message;
93
+ }
94
+ return form.render(ctx, {
95
+ raw_values: this.getSubformRawValues(ctx, sub_form_name),
96
+ messages: [],
97
+ });
98
+ }
99
+
100
+ getSubformRawValues(
101
+ ctx: Context,
102
+ sub_form_name: string
103
+ ): Record<string, FormDataValue> {
104
+ const result = Object.fromEntries(
105
+ Object.entries(this.extractRawValues(ctx))
106
+ .filter(([key]) =>
107
+ key.startsWith(sub_form_name + FIELD_PREFIX_SEPARATOR)
108
+ )
109
+ .map(([key, value]) => [
110
+ key.slice((sub_form_name + FIELD_PREFIX_SEPARATOR).length),
111
+ value,
112
+ ])
113
+ );
114
+ return result;
115
+ }
116
+
117
+ async render(
118
+ ctx: Context,
119
+ messages: FormMessage[],
120
+ prerenderedForms: Record<string, FlatTemplatable | undefined> = {}
121
+ ): Promise<FlatTemplatable> {
122
+ return tempstream/* HTML */ `${this.renderMessages(ctx, {
123
+ raw_values: {},
124
+ messages,
125
+ })}
126
+ <div class="forms">
127
+ <form
128
+ id="${this.name}"
129
+ novalidate
130
+ ${
131
+ /* novalidate is here because all the subforms are actuallly one form and errors in one will prevent submitting all of the forms */ ""
132
+ }
133
+ method="POST"
134
+ ></form>
135
+ ${this.getSubformsToRender(ctx).map(([form_name]) => {
136
+ const frame_form_id =
137
+ this.name + "_" + form_name + "_frame_form";
138
+ return tempstream/* HTML */ `<turbo-frame
139
+ id="${form_name}"
140
+ contains-subform
141
+ subform-id="${frame_form_id}"
142
+ >
143
+ ${prerenderedForms[form_name] ||
144
+ this.renderSubform(ctx, form_name)}
145
+ <form id="${frame_form_id}" method="POST"></form>
146
+ </turbo-frame>`;
147
+ })}
148
+ </div>
149
+ ${this.makeBottomScript()}`;
150
+ }
151
+
152
+ makeBottomScript(): string {
153
+ // this script assigns each field to its corresponding form, instead of
154
+ // the html-only version where all fields are attached to one meta-form
155
+ return /* HTML */ ` <script>
156
+ (function () {
157
+ if (!window.subform_handlers) {
158
+ window.subform_handlers = {};
159
+ }
160
+ if (window.subform_handlers["${this.name}"]) {
161
+ return;
162
+ }
163
+ const handler = () => {
164
+ document
165
+ .querySelectorAll("turbo-frame[contains-subform]")
166
+ .forEach((frame) => {
167
+ frame_form_id = frame.getAttribute("subform-id");
168
+ frame.querySelectorAll("input").forEach((input) => {
169
+ input.setAttribute("form", frame_form_id);
170
+ });
171
+ });
172
+ };
173
+ window.subform_handlers["${this.name}"] = handler;
174
+ document.documentElement.addEventListener(
175
+ "turbo:load",
176
+ handler
177
+ );
178
+ document.documentElement.addEventListener(
179
+ "turbo:frame-render",
180
+ handler
181
+ );
182
+ })();
183
+ </script>`;
184
+ }
185
+
186
+ mount(router: Router, path: string): void {
187
+ router.get(path, async (ctx) => {
188
+ ctx.type = "html";
189
+ ctx.body = await this.render(ctx, [], {});
190
+ });
191
+
192
+ for (const [key, form] of Object.entries(this.forms)) {
193
+ router.post(
194
+ path + (path.endsWith("/") ? "" : "/") + key,
195
+ async (ctx) => {
196
+ const reaction = await form.handlePost(ctx);
197
+ if (reaction.action == "stay") {
198
+ ctx.status = 422;
199
+ const form_content = reaction.content;
200
+ ctx.body = this.render(ctx, [], {
201
+ [key]: form_content,
202
+ });
203
+ } else if (reaction.action == "redirect") {
204
+ ctx.status = 303;
205
+ ctx.redirect(reaction.url);
206
+ }
207
+ }
208
+ );
209
+ }
210
+ }
211
+ }
@@ -12,7 +12,7 @@ const target_locreq = _locreq(process.cwd());
12
12
 
13
13
  async function extractActionName(full_file_path: string): Promise<string> {
14
14
  const file_content = await fs.readFile(full_file_path, "utf-8");
15
- const result = file_content.match(/export const actionName = "(\w+)"/);
15
+ const result = /export const actionName = "(\w+)"/.exec(file_content);
16
16
  if (result === null) {
17
17
  throw new Error("Missing 'export const actionName' statement?");
18
18
  }
@@ -32,7 +32,9 @@ export async function generateRoutes(): Promise<void> {
32
32
  (f) =>
33
33
  f.endsWith(".page.ts") ||
34
34
  f.endsWith(".form.ts") ||
35
- f.endsWith(".list.ts")
35
+ f.endsWith(".multiform.ts") ||
36
+ f.endsWith(".list.ts") ||
37
+ f.endsWith(".redirect.ts")
36
38
  )
37
39
  .map(async (fullpath) => ({
38
40
  fullpath,
@@ -85,7 +87,9 @@ function mount(router: Router, URL: string, mountable: Mountable) {
85
87
  mountable.init();
86
88
  mountable.mount(router, URL);
87
89
  // to automatically add trailing slashes:
88
- router.get(URL.slice(0, -1), (ctx) => ctx.redirect(URL));
90
+ router.get(URL.slice(0, -1), (ctx) => {
91
+ ctx.redirect(ctx.originalUrl + "/");
92
+ });
89
93
  router.use(URL, (ctx) => ctx.set("content-type", "text/html;charset=utf-8"));
90
94
  }
91
95
 
package/src/index.ts CHANGED
@@ -19,3 +19,4 @@ export * from "./utils/utils";
19
19
  export * from "./page/list";
20
20
  export * from "./utils/translate";
21
21
  export * as sanitize from "./sanitize";
22
+ export * from "./forms/multiform";
package/src/page/list.ts CHANGED
@@ -142,7 +142,7 @@ export abstract class ListPage<
142
142
  ])}
143
143
  ${this.filterControls.map((control) =>
144
144
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
145
- control.render(ctx, values)
145
+ control.render(ctx, values, [], "", "")
146
146
  )}
147
147
  </form>`;
148
148
  }