@sealcode/sealgen 0.6.1 → 0.7.1

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 (91) 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/components/components.d.ts +1 -0
  8. package/@types/components/tabs.d.ts +2 -0
  9. package/@types/forms/controls/controls.d.ts +1 -0
  10. package/@types/forms/controls/field-group.d.ts +17 -0
  11. package/@types/forms/controls/form-control.d.ts +1 -1
  12. package/@types/forms/controls/form-field-control.d.ts +1 -1
  13. package/@types/forms/controls/simple-input.d.ts +2 -1
  14. package/@types/forms/form.d.ts +19 -6
  15. package/@types/forms/multiform.d.ts +9 -11
  16. package/@types/index.d.ts +2 -0
  17. package/@types/page/mountable-with-fields.d.ts +32 -0
  18. package/@types/page/mountable.d.ts +2 -27
  19. package/@types/page/page.d.ts +2 -2
  20. package/@types/templates/multiform.d.ts +1 -0
  21. package/@types/templates/redirect.d.ts +1 -0
  22. package/@types/utils/input-wrapper.d.ts +4 -0
  23. package/coverage/clover.xml +79 -0
  24. package/lib/add-route.js +27 -8
  25. package/lib/add-route.js.map +1 -1
  26. package/lib/components/components.js +14 -0
  27. package/lib/components/components.js.map +1 -0
  28. package/lib/components/tabs.js +92 -0
  29. package/lib/components/tabs.js.map +1 -0
  30. package/lib/forms/controls/checkboxed-list-input.js +12 -9
  31. package/lib/forms/controls/checkboxed-list-input.js.map +1 -1
  32. package/lib/forms/controls/controls.js +3 -1
  33. package/lib/forms/controls/controls.js.map +1 -1
  34. package/lib/forms/controls/dropdown.js +14 -12
  35. package/lib/forms/controls/dropdown.js.map +1 -1
  36. package/lib/forms/controls/field-group.js +26 -0
  37. package/lib/forms/controls/field-group.js.map +1 -0
  38. package/lib/forms/controls/form-control.js.map +1 -1
  39. package/lib/forms/controls/form-field-control.js.map +1 -1
  40. package/lib/forms/controls/simple-input.js +6 -8
  41. package/lib/forms/controls/simple-input.js.map +1 -1
  42. package/lib/forms/controls/textarea.js +13 -15
  43. package/lib/forms/controls/textarea.js.map +1 -1
  44. package/lib/forms/form.js +95 -57
  45. package/lib/forms/form.js.map +1 -1
  46. package/lib/forms/multiform.js +52 -62
  47. package/lib/forms/multiform.js.map +1 -1
  48. package/lib/generate-routes.js +7 -3
  49. package/lib/generate-routes.js.map +1 -1
  50. package/lib/index.js +3 -1
  51. package/lib/index.js.map +1 -1
  52. package/lib/page/list.js +1 -1
  53. package/lib/page/list.js.map +1 -1
  54. package/lib/page/mountable-with-fields.js +63 -0
  55. package/lib/page/mountable-with-fields.js.map +1 -0
  56. package/lib/page/mountable.js +2 -49
  57. package/lib/page/mountable.js.map +1 -1
  58. package/lib/page/page.js +2 -2
  59. package/lib/page/page.js.map +1 -1
  60. package/lib/templates/multiform.js +59 -0
  61. package/lib/templates/multiform.js.map +1 -0
  62. package/lib/templates/redirect.js +27 -0
  63. package/lib/templates/redirect.js.map +1 -0
  64. package/lib/tests/manual/multiform.js +14 -5
  65. package/lib/tests/manual/multiform.js.map +1 -1
  66. package/lib/utils/input-wrapper.js +20 -0
  67. package/lib/utils/input-wrapper.js.map +1 -0
  68. package/package.json +4 -3
  69. package/src/add-route.ts +30 -11
  70. package/src/components/components.ts +1 -0
  71. package/src/components/tabs.ts +95 -0
  72. package/src/forms/controls/checkboxed-list-input.ts +17 -10
  73. package/src/forms/controls/controls.ts +1 -0
  74. package/src/forms/controls/dropdown.ts +28 -23
  75. package/src/forms/controls/field-group.ts +32 -0
  76. package/src/forms/controls/form-control.ts +3 -1
  77. package/src/forms/controls/form-field-control.ts +4 -1
  78. package/src/forms/controls/simple-input.ts +19 -12
  79. package/src/forms/controls/textarea.ts +15 -14
  80. package/src/forms/form.ts +122 -76
  81. package/src/forms/multiform.ts +211 -0
  82. package/src/generate-routes.ts +7 -3
  83. package/src/index.ts +2 -0
  84. package/src/page/list.ts +1 -1
  85. package/src/page/mountable-with-fields.ts +112 -0
  86. package/src/page/mountable.ts +4 -96
  87. package/src/page/page.ts +7 -3
  88. package/src/templates/multiform.ts +58 -0
  89. package/src/templates/redirect.ts +22 -0
  90. package/src/tests/manual/multiform.ts +106 -0
  91. package/src/utils/input-wrapper.ts +18 -0
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,5 @@ 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";
23
+ export * as Components from "./components/components";
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
  }
@@ -0,0 +1,112 @@
1
+ import { Context } from "koa";
2
+ import { FlatTemplatable, tempstream } from "tempstream";
3
+ import { FormControl } from "../forms/controls/form-control";
4
+ import { FormField } from "../forms/fields/field";
5
+ import { FormData, FormDataValue, FormMessage } from "../forms/form";
6
+ import { Mountable } from "./mountable";
7
+
8
+ export type PageErrorMessage = { type: "access" | "internal"; message: string };
9
+
10
+ export type Fields = Record<string, FormField<unknown>>;
11
+
12
+ export abstract class MountableWithFields<
13
+ F extends Fields = Fields
14
+ > extends Mountable {
15
+ fields: F;
16
+ field_names_prefix = ""; // useful for multiform, where many forms are merged into one and field assignment is made using the prefix
17
+ form_id = ""; // all fields within this mountable will be tied to form of this id
18
+ abstract controls: FormControl[];
19
+
20
+ constructor() {
21
+ super();
22
+ if (!this.fields) this.fields = {} as F;
23
+ }
24
+
25
+ init(): void {
26
+ for (const [name, field] of Object.entries(this.fields)) {
27
+ field.init(name);
28
+ }
29
+ }
30
+
31
+ // this one is meant to be overwritten
32
+ async validateValues(
33
+ ctx: Context,
34
+ data: Record<string, FormDataValue>
35
+ ): Promise<{ valid: boolean; error: string }> {
36
+ return {
37
+ valid: true,
38
+ error: "",
39
+ };
40
+ }
41
+
42
+ async validate(
43
+ ctx: Context,
44
+ values: Record<string, FormDataValue>
45
+ ): Promise<{
46
+ valid: boolean;
47
+ field_errors: Partial<Record<keyof Fields, string>>;
48
+ form_messages: FormMessage[];
49
+ }> {
50
+ const field_errors = {} as Record<keyof Fields, string>;
51
+ let valid = true;
52
+ const form_messages = [] as FormMessage[];
53
+ await Promise.all(
54
+ Object.entries(values)
55
+ .filter(([fieldname]) => fieldname in this.fields)
56
+ .map(async ([key]: [keyof F, FormDataValue]) => {
57
+ const field = this.fields[key];
58
+ const { valid: fieldvalid, message: fieldmessage } =
59
+ await field.getValue(ctx, values);
60
+ if (!fieldvalid) {
61
+ valid = false;
62
+ field_errors[field.name] = fieldmessage;
63
+ }
64
+ })
65
+ );
66
+ const formValidationResult = await this.validateValues(ctx, values);
67
+ if (!formValidationResult.valid) {
68
+ form_messages.push({
69
+ type: "error",
70
+ text: formValidationResult.error,
71
+ });
72
+ valid = false;
73
+ }
74
+ return { valid, field_errors, form_messages };
75
+ }
76
+
77
+ public renderControls(
78
+ ctx: Context,
79
+ data: FormData<keyof Fields>
80
+ ): FlatTemplatable {
81
+ return tempstream/* HTML */ `${this.controls.map((control) =>
82
+ control.render(
83
+ ctx,
84
+ data.raw_values,
85
+ data.messages,
86
+ this.field_names_prefix,
87
+ this.form_id
88
+ )
89
+ )}`;
90
+ }
91
+
92
+ async renderError(
93
+ _: Context,
94
+ error: PageErrorMessage
95
+ ): Promise<FlatTemplatable> {
96
+ return error.message;
97
+ }
98
+
99
+ public renderMessages(
100
+ _: Context,
101
+ data: FormData<keyof Fields>
102
+ ): FlatTemplatable {
103
+ return tempstream/* HTML */ `<div class="form-messages">
104
+ ${data.messages.map(
105
+ (message) =>
106
+ `<div class="form-message form-message--${message.type}">${message.text}</div>`
107
+ )}
108
+ </div>`;
109
+ }
110
+
111
+ abstract extractRawValues(ctx: Context): Record<string, FormDataValue>;
112
+ }