@sealcode/sealgen 0.8.48 → 0.8.49
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/.log/ti-107027.log +576 -0
- package/.log/ti-129414.log +1355 -0
- package/.log/ti-135707.log +1049 -0
- package/.log/ti-136226.log +1049 -0
- package/.log/ti-147652.log +533 -0
- package/.log/ti-157088.log +2938 -0
- package/.log/ti-250667.log +548 -0
- package/.log/ti-29438.log +165 -0
- package/.log/ti-29894.log +149 -0
- package/.log/ti-33785.log +149 -0
- package/.log/ti-34262.log +149 -0
- package/.log/ti-35719.log +149 -0
- package/.log/ti-41629.log +518 -0
- package/.log/ti-441248.log +517 -0
- package/.log/ti-5352.log +517 -0
- package/.log/ti-76335.log +639 -0
- package/.log/ti-811639.log +517 -0
- package/.log/ti-81805.log +564 -0
- package/.log/ti-86459.log +517 -0
- package/.log/tsserver.log +6881 -0
- package/.log/tsserver.sync-conflict-20221010-222251-JUX7UR2.log +9412 -0
- package/package.json +1 -1
- package/src/add-collection.ts +31 -0
- package/src/add-route.test.ts +58 -0
- package/src/add-route.ts +89 -0
- package/src/build.ts +94 -0
- package/src/cli.ts +36 -0
- package/src/components/components.ts +2 -0
- package/src/components/tabs.ts +95 -0
- package/src/components/toggle-modal.ts +52 -0
- package/src/example.test.ts +3 -0
- package/src/find-scss-includes.ts +32 -0
- package/src/forms/controls/array.ts +226 -0
- package/src/forms/controls/autocomplete.ts +33 -0
- package/src/forms/controls/big-radio-group.ts +48 -0
- package/src/forms/controls/checkbox-group.ts +71 -0
- package/src/forms/controls/checkbox.ts +34 -0
- package/src/forms/controls/checkboxed-list-input.ts +50 -0
- package/src/forms/controls/collapsible-checkbox-group.ts +28 -0
- package/src/forms/controls/collapsible-field-group.ts +94 -0
- package/src/forms/controls/controls.ts +30 -0
- package/src/forms/controls/derived-frame.ts +91 -0
- package/src/forms/controls/derived.ts +108 -0
- package/src/forms/controls/dropdown.ts +87 -0
- package/src/forms/controls/editable-collection-subset.ts +97 -0
- package/src/forms/controls/field-group.ts +69 -0
- package/src/forms/controls/file.ts +47 -0
- package/src/forms/controls/form-control.ts +24 -0
- package/src/forms/controls/form-field-control.ts +22 -0
- package/src/forms/controls/form-header.ts +19 -0
- package/src/forms/controls/form-paragraph.ts +11 -0
- package/src/forms/controls/frame.ts +26 -0
- package/src/forms/controls/hidden-input.ts +22 -0
- package/src/forms/controls/html.ts +38 -0
- package/src/forms/controls/multiple-files.ts +256 -0
- package/src/forms/controls/multiple-photos.ts +44 -0
- package/src/forms/controls/number.ts +38 -0
- package/src/forms/controls/photo.ts +74 -0
- package/src/forms/controls/radio.ts +43 -0
- package/src/forms/controls/reverse-single-reference.ts +50 -0
- package/src/forms/controls/simple-input.ts +148 -0
- package/src/forms/controls/textarea.ts +60 -0
- package/src/forms/controls/tickable.ts +127 -0
- package/src/forms/fields/boolean.test.ts +36 -0
- package/src/forms/fields/boolean.ts +63 -0
- package/src/forms/fields/checkboxed-list.ts +66 -0
- package/src/forms/fields/collection-field.ts +62 -0
- package/src/forms/fields/email.test.ts +24 -0
- package/src/forms/fields/email.ts +20 -0
- package/src/forms/fields/enum-multiple.test.ts +31 -0
- package/src/forms/fields/enum-multiple.ts +62 -0
- package/src/forms/fields/enum.test.ts +25 -0
- package/src/forms/fields/enum.ts +56 -0
- package/src/forms/fields/existing-sealious-value.ts +22 -0
- package/src/forms/fields/field.ts +172 -0
- package/src/forms/fields/file.ts +89 -0
- package/src/forms/fields/multiple-files.ts +20 -0
- package/src/forms/fields/number.test.ts +34 -0
- package/src/forms/fields/number.ts +70 -0
- package/src/forms/fields/pick-from-list.test.ts +19 -0
- package/src/forms/fields/pick-from-list.ts +52 -0
- package/src/forms/fields/proxy-field.ts +24 -0
- package/src/forms/fields/regexp.test.ts +25 -0
- package/src/forms/fields/regexp.ts +53 -0
- package/src/forms/fields/reverse-single-reference.ts +20 -0
- package/src/forms/fields/simple-form-field.ts +30 -0
- package/src/forms/fields/structured-array.ts +27 -0
- package/src/forms/form.test.ts +351 -0
- package/src/forms/form.ts +294 -0
- package/src/forms/forms.scss +85 -0
- package/src/forms/multiform.test.ts +540 -0
- package/src/forms/multiform.ts +236 -0
- package/src/generate-collections.ts +77 -0
- package/src/generate-routes.ts +121 -0
- package/src/index.ts +25 -0
- package/src/make-env.ts +15 -0
- package/src/make-hidden-inputs.ts +24 -0
- package/src/mount.ts +85 -0
- package/src/page/access-control.ts +18 -0
- package/src/page/list.ts +240 -0
- package/src/page/mountable-with-fields.ts +158 -0
- package/src/page/mountable.ts +13 -0
- package/src/page/page.ts +46 -0
- package/src/page/props-parser.ts +38 -0
- package/src/page/stateful-page.ts +202 -0
- package/src/run.ts +0 -0
- package/src/sanitize.ts +9 -0
- package/src/templates/collection.ts +11 -0
- package/src/templates/form.ts +60 -0
- package/src/templates/list.ts +75 -0
- package/src/templates/multiform.ts +58 -0
- package/src/templates/page.ts +25 -0
- package/src/templates/post.ts +21 -0
- package/src/templates/redirect.ts +22 -0
- package/src/templates/route-test.ts +27 -0
- package/src/templates/stateful-page.ts +65 -0
- package/src/test_utils/locator_is_visible.ts +17 -0
- package/src/utils/assert-throws.ts +18 -0
- package/src/utils/base64.ts +7 -0
- package/src/utils/browser-creator.js +10 -0
- package/src/utils/escape-url-params.ts +14 -0
- package/src/utils/exec.ts +30 -0
- package/src/utils/format-external-url.ts +13 -0
- package/src/utils/import-path.ts +25 -0
- package/src/utils/input-wrapper.ts +21 -0
- package/src/utils/list-collections.ts +16 -0
- package/src/utils/make-slug.ts +5 -0
- package/src/utils/natural-numbers.ts +5 -0
- package/src/utils/prettier.ts +13 -0
- package/src/utils/question.ts +23 -0
- package/src/utils/render-attributes.ts +17 -0
- package/src/utils/sleep.ts +3 -0
- package/src/utils/translate.ts +13 -0
- package/src/utils/url-with-new-params.ts +9 -0
- package/src/utils/utils.ts +23 -0
- package/src/utils/walk.ts +20 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import Router from "@koa/router";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import Koa, { BaseContext } from "koa";
|
|
4
|
+
import { Page } from "playwright";
|
|
5
|
+
import { Controls, Fields, Form, FormDataValue, Mountable } from "../index";
|
|
6
|
+
import { mount } from "../mount";
|
|
7
|
+
import { locator_is_visible } from "../test_utils/locator_is_visible";
|
|
8
|
+
import { getBrowser } from "../utils/browser-creator";
|
|
9
|
+
import { assertThrowsAsync } from "../utils/utils";
|
|
10
|
+
|
|
11
|
+
const fields = {
|
|
12
|
+
text: new Fields.SimpleFormField(true),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function form_factory(canAccessFun?: Mountable["canAccess"]): Form<typeof fields, void> {
|
|
16
|
+
const result = new (class extends Form<typeof fields, void> {
|
|
17
|
+
fields = fields;
|
|
18
|
+
submitButtonText = "Submit";
|
|
19
|
+
controls = [
|
|
20
|
+
new Controls.SimpleInput(fields.text, {
|
|
21
|
+
label: "This is a test:",
|
|
22
|
+
type: "password",
|
|
23
|
+
}),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
async onSubmit() {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
if (canAccessFun) result.canAccess = canAccessFun;
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("form test", () => {
|
|
35
|
+
let page: Page;
|
|
36
|
+
|
|
37
|
+
before(async () => {
|
|
38
|
+
const browser = await getBrowser();
|
|
39
|
+
const context = await browser.newContext();
|
|
40
|
+
page = await context.newPage();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("basic tests", async () => {
|
|
44
|
+
let server: ReturnType<Koa["listen"]>;
|
|
45
|
+
|
|
46
|
+
before(async () => {
|
|
47
|
+
const app = new Koa();
|
|
48
|
+
const router = new Router();
|
|
49
|
+
|
|
50
|
+
mount(router, "/", form_factory(), true);
|
|
51
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
52
|
+
server = app.listen(8080);
|
|
53
|
+
await page.goto("http://localhost:8080");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
after(async () => {
|
|
57
|
+
server.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not allow to submit an empty form when there's a required field", async () => {
|
|
61
|
+
await page.getByRole("button", { name: "Submit", exact: true }).click();
|
|
62
|
+
await assertThrowsAsync(async () => {
|
|
63
|
+
return page.getByText("Done").click({ timeout: 500 });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("allows to submit a form when all required fields have a value", async () => {
|
|
68
|
+
await page.getByPlaceholder("password").click();
|
|
69
|
+
await page.getByPlaceholder("password").fill("testpasswd");
|
|
70
|
+
await page.getByRole("button", { name: "Submit", exact: true }).click();
|
|
71
|
+
await page.getByText("Done").click();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not allow submitting an empty form by circumventing HTML-based validation", async () => {
|
|
75
|
+
const res_axios = await axios.post(
|
|
76
|
+
"http://localhost:8080",
|
|
77
|
+
{
|
|
78
|
+
text: "",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
validateStatus: (status: number) => {
|
|
82
|
+
if (status == 422) return true;
|
|
83
|
+
return false;
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
if (!res_axios.data.includes("Some fields are invalid")) {
|
|
88
|
+
throw new Error("when sending a empty request with axios, the error didnt appear");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("canAccess tests", async () => {
|
|
94
|
+
let server: ReturnType<Koa["listen"]>;
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
server.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("allows visit when configured when canAccess returns true", async () => {
|
|
101
|
+
const app = new Koa();
|
|
102
|
+
const router = new Router();
|
|
103
|
+
|
|
104
|
+
mount(
|
|
105
|
+
router,
|
|
106
|
+
"/",
|
|
107
|
+
form_factory(
|
|
108
|
+
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
|
|
109
|
+
return { canAccess: true, message: "" };
|
|
110
|
+
}
|
|
111
|
+
),
|
|
112
|
+
true
|
|
113
|
+
);
|
|
114
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
115
|
+
server = app.listen(8080);
|
|
116
|
+
const response = await page.goto("http://localhost:8080");
|
|
117
|
+
if (response?.status() != 200) {
|
|
118
|
+
throw new Error(`Should return 200 status and it returns ${response?.status()}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("declines access when canAccess returns false", async () => {
|
|
123
|
+
const app = new Koa();
|
|
124
|
+
const router = new Router();
|
|
125
|
+
|
|
126
|
+
before(async () => {
|
|
127
|
+
mount(
|
|
128
|
+
router,
|
|
129
|
+
"/",
|
|
130
|
+
form_factory(
|
|
131
|
+
async (
|
|
132
|
+
ctx: Koa.Context
|
|
133
|
+
): Promise<{ canAccess: boolean; message: string }> => {
|
|
134
|
+
return { canAccess: false, message: "" };
|
|
135
|
+
}
|
|
136
|
+
),
|
|
137
|
+
true
|
|
138
|
+
);
|
|
139
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
beforeEach(async () => {
|
|
143
|
+
server = app.listen(8080);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("prevents the form from rendering", async () => {
|
|
147
|
+
const response = await page.goto("http://localhost:8080");
|
|
148
|
+
if (response?.status() != 403) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Should return 403 status and it returns ${response?.status()}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not allow submitting of the form through axios", async () => {
|
|
156
|
+
await axios.post(
|
|
157
|
+
"http://localhost:8080",
|
|
158
|
+
{
|
|
159
|
+
text: "sample",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
validateStatus: (status: number) => {
|
|
163
|
+
if (status == 403) return true;
|
|
164
|
+
return false;
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("passes the context to canAccess (false case)", async () => {
|
|
172
|
+
const app = new Koa();
|
|
173
|
+
const router = new Router();
|
|
174
|
+
|
|
175
|
+
mount(
|
|
176
|
+
router,
|
|
177
|
+
"/",
|
|
178
|
+
form_factory(
|
|
179
|
+
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
|
|
180
|
+
return ctx.$context && ctx.$context.user_id
|
|
181
|
+
? { canAccess: true, message: "" }
|
|
182
|
+
: { canAccess: false, message: "" };
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
true
|
|
186
|
+
);
|
|
187
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
188
|
+
server = app.listen(8080);
|
|
189
|
+
const response = await page.goto("http://localhost:8080");
|
|
190
|
+
if (response?.status() != 403) {
|
|
191
|
+
throw new Error(`Should return 403 status and it returns ${response?.status()}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("passes the context to canAccess (true case)", async () => {
|
|
196
|
+
const app = new Koa();
|
|
197
|
+
const router = new Router();
|
|
198
|
+
|
|
199
|
+
router.use(async (ctx: BaseContext, next: any) => {
|
|
200
|
+
ctx.$context = {
|
|
201
|
+
user_id: "miguel",
|
|
202
|
+
} as any;
|
|
203
|
+
await next();
|
|
204
|
+
});
|
|
205
|
+
mount(
|
|
206
|
+
router,
|
|
207
|
+
"/",
|
|
208
|
+
form_factory(
|
|
209
|
+
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
|
|
210
|
+
return ctx.$context && ctx.$context.user_id
|
|
211
|
+
? { canAccess: true, message: "" }
|
|
212
|
+
: { canAccess: false, message: "" };
|
|
213
|
+
}
|
|
214
|
+
),
|
|
215
|
+
true
|
|
216
|
+
);
|
|
217
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
218
|
+
server = app.listen(8080);
|
|
219
|
+
const response = await page.goto("http://localhost:8080");
|
|
220
|
+
if (response?.status() != 200) {
|
|
221
|
+
throw new Error(`Should return 200 status and it returns ${response?.status()}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("validation e2e", async () => {
|
|
227
|
+
describe("validation message", async () => {
|
|
228
|
+
let server: ReturnType<Koa["listen"]>;
|
|
229
|
+
|
|
230
|
+
before(async () => {
|
|
231
|
+
const app = new Koa();
|
|
232
|
+
const router = new Router();
|
|
233
|
+
|
|
234
|
+
mount(
|
|
235
|
+
router,
|
|
236
|
+
"/",
|
|
237
|
+
new (class extends Form<typeof fields, void> {
|
|
238
|
+
fields = fields;
|
|
239
|
+
submitButtonText = "Submit";
|
|
240
|
+
controls = [
|
|
241
|
+
new Controls.SimpleInput(fields.text, {
|
|
242
|
+
label: "This is a test:",
|
|
243
|
+
type: "password",
|
|
244
|
+
}),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
async onSubmit() {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async validateValues(
|
|
252
|
+
ctx: Koa.Context,
|
|
253
|
+
data: Record<string, FormDataValue>
|
|
254
|
+
): Promise<{ valid: boolean; error: string }> {
|
|
255
|
+
if (data.text === "incorrect")
|
|
256
|
+
return {
|
|
257
|
+
valid: false,
|
|
258
|
+
error: "Incorrect input",
|
|
259
|
+
};
|
|
260
|
+
return { valid: true, error: "" };
|
|
261
|
+
}
|
|
262
|
+
})(),
|
|
263
|
+
true
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
267
|
+
server = app.listen(8080);
|
|
268
|
+
await page.goto("http://localhost:8080");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
after(async () => {
|
|
272
|
+
server.close();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("shows up when incorrect input is given", async () => {
|
|
276
|
+
await page.getByPlaceholder("password").click();
|
|
277
|
+
await page.getByPlaceholder("password").fill("incorrect");
|
|
278
|
+
await page.getByRole("button", { name: "Submit" }).click();
|
|
279
|
+
if (!(await locator_is_visible(page.getByText("Incorrect input"))))
|
|
280
|
+
throw new Error("validation message doens't show up when input is incorrect");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("doesn't show when correct input is given", async () => {
|
|
284
|
+
await page.getByPlaceholder("password").click();
|
|
285
|
+
await page.getByPlaceholder("password").fill("correct");
|
|
286
|
+
await page.getByRole("button", { name: "Submit" }).click();
|
|
287
|
+
await assertThrowsAsync(async () => {
|
|
288
|
+
return page.getByText("Incorrect input").click({ timeout: 500 });
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("field specific validation message", async () => {
|
|
294
|
+
let server: ReturnType<Koa["listen"]>;
|
|
295
|
+
|
|
296
|
+
before(async () => {
|
|
297
|
+
const app = new Koa();
|
|
298
|
+
const router = new Router();
|
|
299
|
+
const fields = {
|
|
300
|
+
text: new Fields.EmailField(true),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
mount(
|
|
304
|
+
router,
|
|
305
|
+
"/",
|
|
306
|
+
new (class extends Form<typeof fields, void> {
|
|
307
|
+
fields = fields;
|
|
308
|
+
submitButtonText = "Submit";
|
|
309
|
+
controls = [
|
|
310
|
+
new Controls.SimpleInput(fields.text, {
|
|
311
|
+
label: "This is a test:",
|
|
312
|
+
type: "text",
|
|
313
|
+
}),
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
async onSubmit() {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
})(),
|
|
320
|
+
true
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
app.use(router.routes()).use(router.allowedMethods());
|
|
324
|
+
server = app.listen(8080);
|
|
325
|
+
await page.goto("http://localhost:8080");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
after(async () => {
|
|
329
|
+
server.close();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("shows up when incorrect input is given", async () => {
|
|
333
|
+
await page.getByPlaceholder("text").click();
|
|
334
|
+
await page.getByPlaceholder("text").fill("notanemail");
|
|
335
|
+
await page.getByRole("button", { name: "Submit" }).click();
|
|
336
|
+
await page.getByText("Please enter a proper email address").click();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("doesn't show up when correct input is given", async () => {
|
|
340
|
+
await page.getByPlaceholder("text").click();
|
|
341
|
+
await page.getByPlaceholder("text").fill("yes@an.email");
|
|
342
|
+
await page.getByRole("button", { name: "Submit" }).click();
|
|
343
|
+
assertThrowsAsync(async () => {
|
|
344
|
+
return page
|
|
345
|
+
.getByText("Please enter a proper email address")
|
|
346
|
+
.click({ timeout: 500 });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { Context } from "koa";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { File as SealiousFile } from "sealious";
|
|
4
|
+
import Router from "@koa/router";
|
|
5
|
+
import { FlatTemplatable, tempstream } from "tempstream";
|
|
6
|
+
import {
|
|
7
|
+
hasFieldOfType,
|
|
8
|
+
hasShape,
|
|
9
|
+
is,
|
|
10
|
+
predicates,
|
|
11
|
+
} from "@sealcode/ts-predicates";
|
|
12
|
+
import { FormField } from "./fields/field";
|
|
13
|
+
import {
|
|
14
|
+
Fields,
|
|
15
|
+
MountableWithFields,
|
|
16
|
+
PageErrorMessage,
|
|
17
|
+
} from "../page/mountable-with-fields";
|
|
18
|
+
|
|
19
|
+
export type FormDataPrimitive = string | string[] | number | undefined;
|
|
20
|
+
export type FormDataValue =
|
|
21
|
+
| FormDataPrimitive
|
|
22
|
+
| Record<string, FormDataPrimitive>
|
|
23
|
+
| SealiousFile;
|
|
24
|
+
|
|
25
|
+
export type FieldValueType<F extends FormField> = F extends FormField<infer R>
|
|
26
|
+
? R
|
|
27
|
+
: never;
|
|
28
|
+
|
|
29
|
+
export type FormFieldsToValues<
|
|
30
|
+
F extends Record<string, FormField<boolean, keyof F>>
|
|
31
|
+
> = {
|
|
32
|
+
[Property in keyof F]: FieldValueType<F[Property]>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type FormMessage = { type: "info" | "success" | "error"; text: string };
|
|
36
|
+
|
|
37
|
+
export type FormData<Fieldnames extends string = string> = {
|
|
38
|
+
raw_values: Record<Fieldnames, FormDataValue>;
|
|
39
|
+
messages: FormMessage[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type FormReaction =
|
|
43
|
+
| { action: "stay"; content: FlatTemplatable; messages?: FormMessage[] }
|
|
44
|
+
| { action: "redirect"; url: string; messages?: FormMessage[] };
|
|
45
|
+
|
|
46
|
+
export abstract class Form<
|
|
47
|
+
F extends Fields,
|
|
48
|
+
SubmitResult extends any
|
|
49
|
+
> extends MountableWithFields<F> {
|
|
50
|
+
defaultSuccessMessage = "Done";
|
|
51
|
+
submitButtonText = "Wyślij";
|
|
52
|
+
action = "./";
|
|
53
|
+
useTurbo = true;
|
|
54
|
+
form_id = randomUUID();
|
|
55
|
+
|
|
56
|
+
async canAccess(
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
58
|
+
_: Context
|
|
59
|
+
): Promise<{ canAccess: boolean; message: string }> {
|
|
60
|
+
return { canAccess: true, message: "" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async renderError(
|
|
64
|
+
_: Context,
|
|
65
|
+
error: PageErrorMessage
|
|
66
|
+
): Promise<FlatTemplatable> {
|
|
67
|
+
return tempstream/* HTML */ `<div>${error.message}</div>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
makeSubmitButton(): FlatTemplatable {
|
|
71
|
+
return /* HTML */ `<input
|
|
72
|
+
type="submit"
|
|
73
|
+
value="${this.submitButtonText}"
|
|
74
|
+
formaction="${this.action}"
|
|
75
|
+
${this.form_id ? `form="${this.form_id}"` : ""}
|
|
76
|
+
/>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async render(
|
|
80
|
+
ctx: Context,
|
|
81
|
+
data: FormData,
|
|
82
|
+
show_field_errors: boolean
|
|
83
|
+
): Promise<FlatTemplatable> {
|
|
84
|
+
return tempstream/* HTML */ `<div class="form-container">
|
|
85
|
+
${this.makeOpenFormTag(ctx)}
|
|
86
|
+
${!this.controls.some((control) => control.role == "messages")
|
|
87
|
+
? this.renderMessages(ctx, data)
|
|
88
|
+
: ""}
|
|
89
|
+
${this.renderControls(
|
|
90
|
+
this.makeFormControlContext(ctx, data, show_field_errors)
|
|
91
|
+
)}
|
|
92
|
+
${this.controls.some((control) => control.role == "submit")
|
|
93
|
+
? ""
|
|
94
|
+
: this.makeSubmitButton()}
|
|
95
|
+
${this.makeCloseFormTag()}
|
|
96
|
+
</div>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
100
|
+
public async makeFormClasses(_ctx: Context): Promise<string[]> {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public makeOpenFormTag(ctx: Context): FlatTemplatable {
|
|
105
|
+
return tempstream`<form enctype="multipart/form-data" method="POST" id="${
|
|
106
|
+
this.form_id
|
|
107
|
+
}" action="${this.action}" class="${this.makeFormClasses(ctx).then(
|
|
108
|
+
(classes) => classes.join(" ")
|
|
109
|
+
)}" ${
|
|
110
|
+
this.useTurbo ? "" : `data-turbo="false"`
|
|
111
|
+
}></form><div class="form">`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
public makeCloseFormTag(): FlatTemplatable {
|
|
115
|
+
return `</div>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public async onValuesInvalid(
|
|
119
|
+
ctx: Context,
|
|
120
|
+
form_messages: FormMessage[],
|
|
121
|
+
_field_errors: Partial<Record<keyof Fields, string>>
|
|
122
|
+
): Promise<FormReaction> {
|
|
123
|
+
const messages = form_messages.length
|
|
124
|
+
? form_messages
|
|
125
|
+
: [<const>{ type: "error", text: "Some fields are invalid" }];
|
|
126
|
+
return {
|
|
127
|
+
action: "stay",
|
|
128
|
+
content: await this.render(
|
|
129
|
+
ctx,
|
|
130
|
+
{
|
|
131
|
+
raw_values: await this.extractRawValues(ctx),
|
|
132
|
+
messages,
|
|
133
|
+
},
|
|
134
|
+
"show_field_errors" && true
|
|
135
|
+
),
|
|
136
|
+
messages,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public async onError(
|
|
141
|
+
ctx: Context,
|
|
142
|
+
data: FormData,
|
|
143
|
+
error: unknown
|
|
144
|
+
): Promise<FormReaction> {
|
|
145
|
+
let error_message = "Unknown error has occured";
|
|
146
|
+
if (
|
|
147
|
+
is(error, predicates.object) &&
|
|
148
|
+
hasShape({ message: predicates.string }, error)
|
|
149
|
+
) {
|
|
150
|
+
error_message = error.message;
|
|
151
|
+
}
|
|
152
|
+
const messages = [<const>{ type: "error", text: error_message }];
|
|
153
|
+
return {
|
|
154
|
+
action: "stay",
|
|
155
|
+
content: await this.render(
|
|
156
|
+
ctx,
|
|
157
|
+
{
|
|
158
|
+
raw_values: data.raw_values,
|
|
159
|
+
messages,
|
|
160
|
+
},
|
|
161
|
+
"show_field_errors" && true
|
|
162
|
+
),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public abstract onSubmit(
|
|
167
|
+
ctx: Context,
|
|
168
|
+
data: FormData
|
|
169
|
+
): SubmitResult | Promise<SubmitResult>;
|
|
170
|
+
|
|
171
|
+
public async onSuccess(
|
|
172
|
+
ctx: Context,
|
|
173
|
+
data: FormData,
|
|
174
|
+
_submitResult: SubmitResult
|
|
175
|
+
): Promise<FormReaction> {
|
|
176
|
+
const messages = [
|
|
177
|
+
<const>{ type: "success", text: this.defaultSuccessMessage },
|
|
178
|
+
];
|
|
179
|
+
return {
|
|
180
|
+
action: "stay",
|
|
181
|
+
content: await this.render(
|
|
182
|
+
ctx,
|
|
183
|
+
{
|
|
184
|
+
raw_values: data.raw_values,
|
|
185
|
+
messages,
|
|
186
|
+
},
|
|
187
|
+
"show_field_errors" && false
|
|
188
|
+
),
|
|
189
|
+
messages,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getRawValuesOnSuccess(ctx: Context) {
|
|
194
|
+
return this.extractRawValues(ctx);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async handlePost(ctx: Context): Promise<FormReaction> {
|
|
198
|
+
const raw_values = await this.extractRawValues(ctx);
|
|
199
|
+
const { valid, form_messages, field_errors } = await this.validate(
|
|
200
|
+
ctx,
|
|
201
|
+
raw_values
|
|
202
|
+
);
|
|
203
|
+
if (!valid) {
|
|
204
|
+
return this.onValuesInvalid(ctx, form_messages, field_errors);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
ctx.status = 303;
|
|
208
|
+
const result = await this.onSubmit(ctx, {
|
|
209
|
+
raw_values,
|
|
210
|
+
messages: [],
|
|
211
|
+
});
|
|
212
|
+
return this.onSuccess(
|
|
213
|
+
ctx,
|
|
214
|
+
{
|
|
215
|
+
raw_values: await this.getRawValuesOnSuccess(ctx),
|
|
216
|
+
messages: [],
|
|
217
|
+
},
|
|
218
|
+
result
|
|
219
|
+
);
|
|
220
|
+
} catch (e: unknown) {
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.dir(e, { depth: 5 });
|
|
223
|
+
const message =
|
|
224
|
+
is(e, predicates.object) &&
|
|
225
|
+
hasFieldOfType(e, "message", predicates.string)
|
|
226
|
+
? e?.message
|
|
227
|
+
: is(e, predicates.string)
|
|
228
|
+
? e
|
|
229
|
+
: "Wystąpił błąd";
|
|
230
|
+
|
|
231
|
+
return this.onError(
|
|
232
|
+
ctx,
|
|
233
|
+
{
|
|
234
|
+
raw_values,
|
|
235
|
+
messages: [
|
|
236
|
+
{
|
|
237
|
+
type: "error",
|
|
238
|
+
text: message,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
e
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public mount(router: Router, path: string): void {
|
|
248
|
+
router.use(path, async (ctx, next) => {
|
|
249
|
+
const result = await this.canAccess(ctx);
|
|
250
|
+
if (!result.canAccess) {
|
|
251
|
+
ctx.body = this.renderError(ctx, {
|
|
252
|
+
type: "access",
|
|
253
|
+
message: result.message,
|
|
254
|
+
});
|
|
255
|
+
ctx.status = 403;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await next();
|
|
259
|
+
});
|
|
260
|
+
router.get(path, async (ctx) => {
|
|
261
|
+
ctx.type = "html";
|
|
262
|
+
ctx.body = await this.render(
|
|
263
|
+
ctx,
|
|
264
|
+
{
|
|
265
|
+
raw_values: await this.extractRawValues(ctx),
|
|
266
|
+
messages: [],
|
|
267
|
+
},
|
|
268
|
+
"show_field_errors" && false
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
router.post(path, async (ctx) => {
|
|
272
|
+
const reaction = await this.handlePost(ctx);
|
|
273
|
+
if (reaction.action == "stay") {
|
|
274
|
+
ctx.status = 422;
|
|
275
|
+
ctx.body = reaction.content;
|
|
276
|
+
} else if (reaction.action == "redirect") {
|
|
277
|
+
ctx.status = 303;
|
|
278
|
+
ctx.redirect(reaction.url);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
const subrouter = new Router(); // for use with other subroutes that individual controls might register
|
|
282
|
+
for (const control of this.controls) {
|
|
283
|
+
control.mount(subrouter);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
router.use(path, subrouter.routes(), subrouter.allowedMethods());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async extractRawValues(
|
|
290
|
+
ctx: Context
|
|
291
|
+
): Promise<Record<string, FormDataValue>> {
|
|
292
|
+
return ctx.$body;
|
|
293
|
+
}
|
|
294
|
+
}
|