@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.
Files changed (136) hide show
  1. package/.log/ti-107027.log +576 -0
  2. package/.log/ti-129414.log +1355 -0
  3. package/.log/ti-135707.log +1049 -0
  4. package/.log/ti-136226.log +1049 -0
  5. package/.log/ti-147652.log +533 -0
  6. package/.log/ti-157088.log +2938 -0
  7. package/.log/ti-250667.log +548 -0
  8. package/.log/ti-29438.log +165 -0
  9. package/.log/ti-29894.log +149 -0
  10. package/.log/ti-33785.log +149 -0
  11. package/.log/ti-34262.log +149 -0
  12. package/.log/ti-35719.log +149 -0
  13. package/.log/ti-41629.log +518 -0
  14. package/.log/ti-441248.log +517 -0
  15. package/.log/ti-5352.log +517 -0
  16. package/.log/ti-76335.log +639 -0
  17. package/.log/ti-811639.log +517 -0
  18. package/.log/ti-81805.log +564 -0
  19. package/.log/ti-86459.log +517 -0
  20. package/.log/tsserver.log +6881 -0
  21. package/.log/tsserver.sync-conflict-20221010-222251-JUX7UR2.log +9412 -0
  22. package/package.json +1 -1
  23. package/src/add-collection.ts +31 -0
  24. package/src/add-route.test.ts +58 -0
  25. package/src/add-route.ts +89 -0
  26. package/src/build.ts +94 -0
  27. package/src/cli.ts +36 -0
  28. package/src/components/components.ts +2 -0
  29. package/src/components/tabs.ts +95 -0
  30. package/src/components/toggle-modal.ts +52 -0
  31. package/src/example.test.ts +3 -0
  32. package/src/find-scss-includes.ts +32 -0
  33. package/src/forms/controls/array.ts +226 -0
  34. package/src/forms/controls/autocomplete.ts +33 -0
  35. package/src/forms/controls/big-radio-group.ts +48 -0
  36. package/src/forms/controls/checkbox-group.ts +71 -0
  37. package/src/forms/controls/checkbox.ts +34 -0
  38. package/src/forms/controls/checkboxed-list-input.ts +50 -0
  39. package/src/forms/controls/collapsible-checkbox-group.ts +28 -0
  40. package/src/forms/controls/collapsible-field-group.ts +94 -0
  41. package/src/forms/controls/controls.ts +30 -0
  42. package/src/forms/controls/derived-frame.ts +91 -0
  43. package/src/forms/controls/derived.ts +108 -0
  44. package/src/forms/controls/dropdown.ts +87 -0
  45. package/src/forms/controls/editable-collection-subset.ts +97 -0
  46. package/src/forms/controls/field-group.ts +69 -0
  47. package/src/forms/controls/file.ts +47 -0
  48. package/src/forms/controls/form-control.ts +24 -0
  49. package/src/forms/controls/form-field-control.ts +22 -0
  50. package/src/forms/controls/form-header.ts +19 -0
  51. package/src/forms/controls/form-paragraph.ts +11 -0
  52. package/src/forms/controls/frame.ts +26 -0
  53. package/src/forms/controls/hidden-input.ts +22 -0
  54. package/src/forms/controls/html.ts +38 -0
  55. package/src/forms/controls/multiple-files.ts +256 -0
  56. package/src/forms/controls/multiple-photos.ts +44 -0
  57. package/src/forms/controls/number.ts +38 -0
  58. package/src/forms/controls/photo.ts +74 -0
  59. package/src/forms/controls/radio.ts +43 -0
  60. package/src/forms/controls/reverse-single-reference.ts +50 -0
  61. package/src/forms/controls/simple-input.ts +148 -0
  62. package/src/forms/controls/textarea.ts +60 -0
  63. package/src/forms/controls/tickable.ts +127 -0
  64. package/src/forms/fields/boolean.test.ts +36 -0
  65. package/src/forms/fields/boolean.ts +63 -0
  66. package/src/forms/fields/checkboxed-list.ts +66 -0
  67. package/src/forms/fields/collection-field.ts +62 -0
  68. package/src/forms/fields/email.test.ts +24 -0
  69. package/src/forms/fields/email.ts +20 -0
  70. package/src/forms/fields/enum-multiple.test.ts +31 -0
  71. package/src/forms/fields/enum-multiple.ts +62 -0
  72. package/src/forms/fields/enum.test.ts +25 -0
  73. package/src/forms/fields/enum.ts +56 -0
  74. package/src/forms/fields/existing-sealious-value.ts +22 -0
  75. package/src/forms/fields/field.ts +172 -0
  76. package/src/forms/fields/file.ts +89 -0
  77. package/src/forms/fields/multiple-files.ts +20 -0
  78. package/src/forms/fields/number.test.ts +34 -0
  79. package/src/forms/fields/number.ts +70 -0
  80. package/src/forms/fields/pick-from-list.test.ts +19 -0
  81. package/src/forms/fields/pick-from-list.ts +52 -0
  82. package/src/forms/fields/proxy-field.ts +24 -0
  83. package/src/forms/fields/regexp.test.ts +25 -0
  84. package/src/forms/fields/regexp.ts +53 -0
  85. package/src/forms/fields/reverse-single-reference.ts +20 -0
  86. package/src/forms/fields/simple-form-field.ts +30 -0
  87. package/src/forms/fields/structured-array.ts +27 -0
  88. package/src/forms/form.test.ts +351 -0
  89. package/src/forms/form.ts +294 -0
  90. package/src/forms/forms.scss +85 -0
  91. package/src/forms/multiform.test.ts +540 -0
  92. package/src/forms/multiform.ts +236 -0
  93. package/src/generate-collections.ts +77 -0
  94. package/src/generate-routes.ts +121 -0
  95. package/src/index.ts +25 -0
  96. package/src/make-env.ts +15 -0
  97. package/src/make-hidden-inputs.ts +24 -0
  98. package/src/mount.ts +85 -0
  99. package/src/page/access-control.ts +18 -0
  100. package/src/page/list.ts +240 -0
  101. package/src/page/mountable-with-fields.ts +158 -0
  102. package/src/page/mountable.ts +13 -0
  103. package/src/page/page.ts +46 -0
  104. package/src/page/props-parser.ts +38 -0
  105. package/src/page/stateful-page.ts +202 -0
  106. package/src/run.ts +0 -0
  107. package/src/sanitize.ts +9 -0
  108. package/src/templates/collection.ts +11 -0
  109. package/src/templates/form.ts +60 -0
  110. package/src/templates/list.ts +75 -0
  111. package/src/templates/multiform.ts +58 -0
  112. package/src/templates/page.ts +25 -0
  113. package/src/templates/post.ts +21 -0
  114. package/src/templates/redirect.ts +22 -0
  115. package/src/templates/route-test.ts +27 -0
  116. package/src/templates/stateful-page.ts +65 -0
  117. package/src/test_utils/locator_is_visible.ts +17 -0
  118. package/src/utils/assert-throws.ts +18 -0
  119. package/src/utils/base64.ts +7 -0
  120. package/src/utils/browser-creator.js +10 -0
  121. package/src/utils/escape-url-params.ts +14 -0
  122. package/src/utils/exec.ts +30 -0
  123. package/src/utils/format-external-url.ts +13 -0
  124. package/src/utils/import-path.ts +25 -0
  125. package/src/utils/input-wrapper.ts +21 -0
  126. package/src/utils/list-collections.ts +16 -0
  127. package/src/utils/make-slug.ts +5 -0
  128. package/src/utils/natural-numbers.ts +5 -0
  129. package/src/utils/prettier.ts +13 -0
  130. package/src/utils/question.ts +23 -0
  131. package/src/utils/render-attributes.ts +17 -0
  132. package/src/utils/sleep.ts +3 -0
  133. package/src/utils/translate.ts +13 -0
  134. package/src/utils/url-with-new-params.ts +9 -0
  135. package/src/utils/utils.ts +23 -0
  136. package/src/utils/walk.ts +20 -0
@@ -0,0 +1,240 @@
1
+ import { Context } from "koa";
2
+ import {
3
+ Collection,
4
+ CollectionItem,
5
+ ItemList,
6
+ Context as SealiousContext,
7
+ CollectionInput,
8
+ } from "sealious";
9
+
10
+ import { Templatable, tempstream, FlatTemplatable } from "tempstream";
11
+ import { peopleWhoCan } from "./access-control";
12
+ import { Page } from "./page";
13
+ import { ShapeToType } from "@sealcode/ts-predicates";
14
+ import { FormControl } from "../forms/controls/controls";
15
+ import { FormField, NumberField } from "../forms/fields/field";
16
+ import { naturalNumbers, UrlWithNewParams } from "../utils/utils";
17
+ import { FormDataValue } from "..";
18
+ import { makeHiddenInputs } from "../make-hidden-inputs";
19
+
20
+ export const BasePagePropsShape = <const>{};
21
+ export type BasePageProps = ShapeToType<typeof BasePagePropsShape>;
22
+
23
+ export const BaseListPageFields = <const>{
24
+ page: new NumberField(false, 1),
25
+ itemsPerPage: new NumberField(false, 12),
26
+ };
27
+
28
+ export abstract class ListPage<
29
+ ItemType,
30
+ F extends typeof BaseListPageFields
31
+ > extends Page<F> {
32
+ abstract getItems(
33
+ ctx: Context,
34
+ page: number,
35
+ itemsPerPage: number,
36
+ values: Record<string, FormDataValue>
37
+ ): Promise<ItemType[]>;
38
+
39
+ abstract getTotalPages(
40
+ ctx: Context,
41
+ itemsPerPage: number,
42
+ values: Record<string, FormDataValue>
43
+ ): Promise<number>;
44
+
45
+ abstract renderItem(
46
+ ctx: Context,
47
+ item: ItemType,
48
+ index: number
49
+ ): Promise<FlatTemplatable>;
50
+
51
+ filterFields: Record<string, FormField> = {};
52
+ filterControls: FormControl[] = [];
53
+
54
+ init(): void {
55
+ super.init();
56
+ for (const [fieldname, field] of Object.entries(this.filterFields)) {
57
+ field.init(fieldname);
58
+ }
59
+ }
60
+
61
+ renderListContainer(_: Context, content: Templatable): FlatTemplatable {
62
+ return tempstream`<div>${content}</div>`;
63
+ }
64
+
65
+ async getPaginationConfig(ctx: Context) {
66
+ const values = await this.extractRawValues(ctx);
67
+ const { parsed: page } = await this.fields.page.getValue(ctx, values);
68
+ const { parsed: itemsPerPage } =
69
+ await this.fields.itemsPerPage.getValue(ctx, values);
70
+ return { page, itemsPerPage };
71
+ }
72
+
73
+ async renderItems(
74
+ ctx: Context,
75
+ values?: Record<string, FormDataValue>,
76
+ items?: ItemType[]
77
+ ): Promise<FlatTemplatable> {
78
+ if (!values) {
79
+ values = await this.extractRawValues(ctx);
80
+ }
81
+ const { itemsPerPage, page } = await this.getPaginationConfig(ctx);
82
+ const items_promise = this.getItems(ctx, page, itemsPerPage, values);
83
+ return tempstream`${(items
84
+ ? Promise.resolve(items)
85
+ : items_promise
86
+ ).then((items) =>
87
+ items.map((item, index) => this.renderItem(ctx, item, index))
88
+ )}`;
89
+ }
90
+
91
+ async render(ctx: Context): Promise<FlatTemplatable> {
92
+ const values = await this.extractRawValues(ctx);
93
+
94
+ return tempstream`${this.renderPagination(ctx, values)}
95
+ ${this.renderFilters(ctx)}
96
+ ${this.renderListContainer(ctx, this.renderItems(ctx, values))}`;
97
+ }
98
+
99
+ async renderPagination(
100
+ ctx: Context,
101
+ values: Record<string, FormDataValue>
102
+ ): Promise<FlatTemplatable> {
103
+ const { itemsPerPage, page } = await this.getPaginationConfig(ctx);
104
+ const totalIems = await this.getTotalPages(ctx, itemsPerPage, values);
105
+
106
+ return tempstream/* HTML */ `<center>
107
+ ${page > 1 ? this.renderPageButton(ctx, 1, "Pierwsza strona") : ""}
108
+ ${page > 1
109
+ ? this.renderPageButton(ctx, page - 1, "Poprzednia strona")
110
+ : ""}
111
+
112
+ <select onchange="if (this.value) Turbo.visit(this.value)">
113
+ ${Array.from(naturalNumbers(1, totalIems)).map(
114
+ (n) => /* HTML */ `<option
115
+ value="${UrlWithNewParams(
116
+ ctx,
117
+ //eslint-disable-next-line @typescript-eslint/consistent-type-assertions
118
+ this.propsParser.overwriteProp(ctx, {
119
+ page: n,
120
+ } as Partial<Record<string, unknown>>)
121
+ )}"
122
+ ${page === n ? "selected" : ""}
123
+ >
124
+ ${n}
125
+ </option>`
126
+ )}
127
+ </select>
128
+ ${page < totalIems
129
+ ? this.renderPageButton(ctx, page + 1, "Następna strona")
130
+ : ""}
131
+ ${page < totalIems
132
+ ? this.renderPageButton(ctx, totalIems, "Ostatnia strona")
133
+ : ""}
134
+ </center>`;
135
+ }
136
+
137
+ private renderPageButton(ctx: Context, page: number, text: string) {
138
+ return /* HTML */ `<a
139
+ href="${UrlWithNewParams(
140
+ ctx,
141
+ //eslint-disable-next-line @typescript-eslint/consistent-type-assertions
142
+ this.propsParser.overwriteProp(ctx, {
143
+ page,
144
+ } as Partial<Record<string, unknown>>)
145
+ )}"
146
+ >${text}</a
147
+ >`;
148
+ }
149
+
150
+ async getFilterValues(ctx: Context): Promise<Record<string, unknown>> {
151
+ const filter = {} as Record<string, unknown>;
152
+ const raw_values = await this.extractRawValues(ctx);
153
+ for (const [fieldname, field] of Object.entries(this.filterFields)) {
154
+ // eslint-disable-next-line no-await-in-loop
155
+ const { parsed } = await field.getValue(ctx, raw_values);
156
+ filter[fieldname] = field.mapToFilter(parsed);
157
+ }
158
+ return filter;
159
+ }
160
+
161
+ async renderFilters(ctx: Context): Promise<FlatTemplatable> {
162
+ const values = await this.extractRawValues(ctx);
163
+ return tempstream/* HTML */ `<form method="GET">
164
+ ${makeHiddenInputs(ctx, this.fields, values, [
165
+ "page",
166
+ ...Object.values(this.filterFields).map((f) => f.name),
167
+ ])}
168
+ ${this.filterControls.map((control) => {
169
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
170
+ return control.render(
171
+ this.makeFormControlContext(
172
+ ctx,
173
+ { raw_values: values, messages: [] },
174
+ false
175
+ )
176
+ );
177
+ })}
178
+ </form>`;
179
+ }
180
+ }
181
+
182
+ export abstract class SealiousItemListPage<
183
+ C extends Collection,
184
+ F extends typeof BaseListPageFields
185
+ > extends ListPage<CollectionItem<C>, F> {
186
+ constructor(public collection: C) {
187
+ super();
188
+ }
189
+
190
+ async getContextForList(ctx: Context): Promise<SealiousContext> {
191
+ return ctx.$context;
192
+ }
193
+
194
+ async getTotalPages(ctx: Context, itemsPerPage: number): Promise<number> {
195
+ const query = this.collection.list(await this.getContextForList(ctx));
196
+ await this.addFilter(ctx, query);
197
+ const { items } = await query.fetch();
198
+ return Math.ceil(items.length / itemsPerPage);
199
+ }
200
+
201
+ protected async addFilter(ctx: Context, list: ItemList<C>): Promise<void> {
202
+ const filters = await this.getFilterValues(ctx);
203
+
204
+ for (const key in filters) {
205
+ if (filters[key] === undefined || filters[key] == "") {
206
+ delete filters[key];
207
+ }
208
+ }
209
+ if (Object.keys(filters).length) {
210
+ list.filter(filters as Partial<CollectionInput<C>>);
211
+ }
212
+ }
213
+
214
+ async makeQuery(ctx: Context): Promise<ItemList<C>> {
215
+ return this.collection.list(await this.getContextForList(ctx));
216
+ }
217
+
218
+ async getItems(
219
+ ctx: Context,
220
+ page: number,
221
+ itemsPerPage: number
222
+ ): Promise<CollectionItem<C>[]> {
223
+ const query = await this.makeQuery(ctx);
224
+ await this.addFilter(ctx, query);
225
+ const { items } = await query
226
+ .paginate({ items: itemsPerPage, page })
227
+ .fetch();
228
+ return items;
229
+ }
230
+
231
+ async renderItem(
232
+ _: Context,
233
+ item: CollectionItem<C>,
234
+ index: number
235
+ ): Promise<FlatTemplatable> {
236
+ return `<div>${item.id}</div>`;
237
+ }
238
+
239
+ canAccess = peopleWhoCan("list", this.collection);
240
+ }
@@ -0,0 +1,158 @@
1
+ import { ShapeToType } from "@sealcode/ts-predicates";
2
+ import { Context } from "koa";
3
+ import { FlatTemplatable, tempstream } from "tempstream";
4
+ import {
5
+ FormControl,
6
+ FormControlContext,
7
+ } from "../forms/controls/form-control";
8
+ import { FieldsToShape, FormField } from "../forms/fields/field";
9
+ import { FormData, FormDataValue, FormMessage } from "../forms/form";
10
+ import { Mountable } from "./mountable";
11
+
12
+ export type PageErrorMessage = { type: "access" | "internal"; message: string };
13
+
14
+ export type Fields = Record<string, FormField<boolean, any>>;
15
+
16
+ export abstract class MountableWithFields<
17
+ F extends Fields = Fields
18
+ > extends Mountable {
19
+ fields: F;
20
+ field_names_prefix = ""; // useful for multiform, where many forms are merged into one and field assignment is made using the prefix
21
+ form_id = ""; // all fields within this mountable will be tied to form of this id
22
+ abstract controls: FormControl[];
23
+
24
+ constructor() {
25
+ super();
26
+ if (!this.fields) this.fields = {} as F;
27
+ }
28
+
29
+ init(): void {
30
+ for (const [name, field] of Object.entries(this.fields)) {
31
+ field.init(name);
32
+ }
33
+ }
34
+
35
+ makeFormControlContext(
36
+ ctx: Context,
37
+ data: FormData,
38
+ validate: boolean,
39
+ field_name_prefix = this.field_names_prefix,
40
+ form_id = this.form_id
41
+ ) {
42
+ return new FormControlContext(
43
+ ctx,
44
+ data.raw_values,
45
+ data.messages,
46
+ field_name_prefix,
47
+ form_id,
48
+ validate
49
+ );
50
+ }
51
+
52
+ // this one is meant to be overwritten
53
+ async validateValues(
54
+ ctx: Context,
55
+ data: Record<string, FormDataValue>
56
+ ): Promise<{ valid: boolean; error: string }> {
57
+ return {
58
+ valid: true,
59
+ error: "",
60
+ };
61
+ }
62
+
63
+ async validate(
64
+ ctx: Context,
65
+ values: Record<string, FormDataValue>
66
+ ): Promise<{
67
+ valid: boolean;
68
+ field_errors: Partial<Record<keyof Fields, string>>;
69
+ form_messages: FormMessage[];
70
+ }> {
71
+ const field_errors = {} as Record<keyof Fields, string>;
72
+ let valid = true;
73
+ const form_messages = [] as FormMessage[];
74
+ await Promise.all(
75
+ Object.entries(values)
76
+ .filter(([fieldname]) => fieldname in this.fields)
77
+ .map(async ([key]: [keyof F, FormDataValue]) => {
78
+ const field = this.fields[key];
79
+ const { valid: fieldvalid, message: fieldmessage } =
80
+ await field.getValue(ctx, values);
81
+ if (!fieldvalid) {
82
+ valid = false;
83
+ field_errors[field.name] = fieldmessage;
84
+ }
85
+ })
86
+ );
87
+ const formValidationResult = await this.validateValues(ctx, values);
88
+ if (!formValidationResult.valid) {
89
+ form_messages.push({
90
+ type: "error",
91
+ text: formValidationResult.error,
92
+ });
93
+ valid = false;
94
+ }
95
+ return { valid, field_errors, form_messages };
96
+ }
97
+
98
+ public renderControls(fctx: FormControlContext): FlatTemplatable {
99
+ return tempstream/* HTML */ `${this.controls.map((control) =>
100
+ control.render(fctx)
101
+ )}`;
102
+ }
103
+
104
+ async renderError(
105
+ _: Context,
106
+ error: PageErrorMessage
107
+ ): Promise<FlatTemplatable> {
108
+ return error.message;
109
+ }
110
+
111
+ public renderMessages(
112
+ _: Context,
113
+ data: FormData<keyof Fields>
114
+ ): FlatTemplatable {
115
+ return tempstream/* HTML */ `<div class="form-messages">
116
+ ${data.messages.map(
117
+ (message) =>
118
+ `<div class="form-message form-message--${message.type}">${message.text}</div>`
119
+ )}
120
+ </div>`;
121
+ }
122
+
123
+ abstract extractRawValues(
124
+ ctx: Context
125
+ ): Promise<Record<string, FormDataValue>>;
126
+
127
+ async getParsedValues(
128
+ ctx: Context
129
+ ): Promise<ShapeToType<FieldsToShape<F>>> {
130
+ const raw_values = await this.extractRawValues(ctx);
131
+ const result: Record<string, unknown> = {};
132
+ const promises = Object.entries(this.fields).map(
133
+ async ([key, field]) => {
134
+ const { parsed } = await field.getValue(ctx, raw_values);
135
+ result[key] = parsed;
136
+ }
137
+ );
138
+ await Promise.all(promises);
139
+ return result as ShapeToType<FieldsToShape<F>>;
140
+ }
141
+
142
+ async getDatabaseValues(
143
+ ctx: Context
144
+ ): Promise<ShapeToType<FieldsToShape<F>>> {
145
+ const raw_values = await this.extractRawValues(ctx);
146
+ const result: Record<string, unknown> = {};
147
+ const promises = Object.entries(this.fields).map(
148
+ async ([key, field]) => {
149
+ const db_value = await field.getDatabaseValue(ctx, raw_values);
150
+ if (db_value !== undefined) {
151
+ result[key] = db_value;
152
+ }
153
+ }
154
+ );
155
+ await Promise.all(promises);
156
+ return result as ShapeToType<FieldsToShape<F>>;
157
+ }
158
+ }
@@ -0,0 +1,13 @@
1
+ import Router from "@koa/router";
2
+
3
+ import { Context } from "koa";
4
+
5
+ export abstract class Mountable {
6
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
7
+ init(): void {}
8
+ abstract mount(router: Router, path: string): void;
9
+
10
+ abstract canAccess(
11
+ ctx: Context
12
+ ): Promise<{ canAccess: boolean; message: string }>;
13
+ }
@@ -0,0 +1,46 @@
1
+ import Router from "@koa/router";
2
+ import { Context } from "koa";
3
+
4
+ import { FlatTemplatable, tempstream } from "tempstream";
5
+ import { AllQueryParams } from "..";
6
+ import { FormDataValue } from "../forms/form";
7
+ import {
8
+ Fields,
9
+ MountableWithFields,
10
+ PageErrorMessage,
11
+ } from "./mountable-with-fields";
12
+ import { PagePropsParser } from "./props-parser";
13
+
14
+ export abstract class Page<
15
+ F extends Fields = Record<string, never>
16
+ > extends MountableWithFields<F> {
17
+ propsParser: PagePropsParser<Record<keyof Fields, unknown>> =
18
+ new AllQueryParams();
19
+ controls = [];
20
+
21
+ mount(router: Router, path: string): void {
22
+ router.get(path, async (ctx) => {
23
+ ctx.body = await this.render(ctx);
24
+ });
25
+ }
26
+
27
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
28
+ async canAccess(_: Context) {
29
+ return { canAccess: true, message: "" };
30
+ }
31
+
32
+ async renderError(
33
+ _: Context,
34
+ error: PageErrorMessage
35
+ ): Promise<FlatTemplatable> {
36
+ return tempstream/* HTML */ `<div>${error.message}</div>`;
37
+ }
38
+
39
+ public abstract render(ctx: Context): Promise<FlatTemplatable>;
40
+
41
+ async extractRawValues(
42
+ ctx: Context
43
+ ): Promise<Record<string, FormDataValue>> {
44
+ return this.propsParser.decode(ctx) || {};
45
+ }
46
+ }
@@ -0,0 +1,38 @@
1
+ import { Context } from "koa";
2
+ import merge from "merge";
3
+ import { FormData, FormDataValue } from "..";
4
+ import { BasePageProps } from "./list";
5
+
6
+ export type EncodedProps = Record<string, unknown>;
7
+
8
+ // page props parser is only tasked with getting the raw props from *somewhere*
9
+ // and being able to put new values back to that somewhere. Parsing to numbers
10
+ // or any other exotic types is done by fields logic.
11
+
12
+ export abstract class PagePropsParser<PropsType> {
13
+ abstract decode(ctx: Context): Record<string, FormDataValue>;
14
+ abstract encode(props: Record<string, FormData>): EncodedProps;
15
+ abstract getHTMLInputName(prop_name: string): string;
16
+
17
+ overwriteProp(ctx: Context, new_props: Partial<PropsType>): EncodedProps {
18
+ const result = {};
19
+ merge.recursive(result, this.decode(ctx), new_props);
20
+ return result;
21
+ }
22
+ }
23
+
24
+ export class AllQueryParams<
25
+ PropsType extends BasePageProps
26
+ > extends PagePropsParser<PropsType> {
27
+ decode(ctx: Context): Record<string, FormDataValue> {
28
+ return ctx.query;
29
+ }
30
+
31
+ encode(props: PropsType): Record<string, unknown> {
32
+ return props;
33
+ }
34
+
35
+ getHTMLInputName(prop_name: string): string {
36
+ return prop_name;
37
+ }
38
+ }
@@ -0,0 +1,202 @@
1
+ import Router from "@koa/router";
2
+ import { predicates, hasShape, is } from "@sealcode/ts-predicates";
3
+ import deepmerge, { ArrayMergeOptions } from "deepmerge";
4
+ import { BaseContext } from "koa";
5
+ import { Templatable, tempstream } from "tempstream";
6
+ import { from_base64, to_base64 } from "../utils/base64";
7
+ import { ExtractTail } from "../utils/utils";
8
+ import { Mountable } from "./mountable";
9
+
10
+ export abstract class StatefulPage<
11
+ State extends Record<string, unknown>,
12
+ Actions extends Record<
13
+ string,
14
+ (
15
+ state: State,
16
+ inputs: Record<string, string>,
17
+ ...args: unknown[]
18
+ ) => State
19
+ >
20
+ > extends Mountable {
21
+ abstract actions: Actions;
22
+ abstract getInitialState(): State;
23
+ abstract render(
24
+ ctx: BaseContext,
25
+ state: State,
26
+ inputs: Record<string, string>
27
+ ): Templatable | Promise<Templatable>;
28
+
29
+ async canAccess() {
30
+ return <const>{ canAccess: true, message: "" };
31
+ }
32
+
33
+ constructor() {
34
+ super();
35
+ const original_render = this.render.bind(this);
36
+ this.render = async (
37
+ ctx: BaseContext,
38
+ state: State,
39
+ inputs: Record<string, string>
40
+ ) => {
41
+ return this.wrapInLayout(
42
+ ctx,
43
+ this.wrapInForm(
44
+ state,
45
+ await original_render(ctx, state, inputs)
46
+ )
47
+ );
48
+ };
49
+ }
50
+
51
+ abstract wrapInLayout(ctx: BaseContext, content: Templatable): Templatable;
52
+
53
+ wrapInForm(state: State, content: Templatable): Templatable {
54
+ return tempstream/* HTML */ `<form action="./" method="POST">
55
+ <input
56
+ name="state"
57
+ type="hidden"
58
+ value="${to_base64(JSON.stringify(state))}"
59
+ />
60
+ ${content}
61
+ </form>`;
62
+ }
63
+
64
+ makeActionButton<ActionName extends keyof Actions>(
65
+ state: State,
66
+ action_description:
67
+ | {
68
+ action: ActionName;
69
+ label?: string;
70
+ }
71
+ | ActionName,
72
+ ...args: ExtractTail<ExtractTail<Parameters<Actions[ActionName]>>>
73
+ ) {
74
+ let label, action: string;
75
+ if (is(action_description, predicates.object)) {
76
+ action = action_description.action.toString();
77
+ label = action_description.label || action;
78
+ } else {
79
+ action = action_description.toString();
80
+ label = action;
81
+ }
82
+ return /* HTML */ `
83
+ <input
84
+ type="submit"
85
+ value=${label}
86
+ formaction="./?action=${action}&args=${to_base64(
87
+ JSON.stringify(args)
88
+ )}"
89
+ />
90
+ `;
91
+ }
92
+
93
+ extractState(ctx: BaseContext):
94
+ | {
95
+ state: State;
96
+ inputs: Record<string, string>;
97
+ action: keyof Actions;
98
+ action_args: string;
99
+ }
100
+ | {
101
+ state: State;
102
+ inputs: Record<string, string>;
103
+ action: null;
104
+ action_args: null;
105
+ } {
106
+ if (
107
+ !hasShape(
108
+ {
109
+ action: predicates.maybe(predicates.string),
110
+ state: predicates.string,
111
+ args: predicates.maybe(predicates.string),
112
+ $: predicates.maybe(predicates.object),
113
+ },
114
+ ctx.$body
115
+ )
116
+ ) {
117
+ console.error("Wrong data: ", ctx.$body);
118
+ throw new Error("wrong formdata shape");
119
+ }
120
+ const inputs = Object.fromEntries(
121
+ Object.entries(ctx.$body).filter(
122
+ ([key]) => !["action", "state", "args", "$"].includes(key)
123
+ )
124
+ ) as Record<string, string>;
125
+ // the "$" key is parsed as dot notation and overrides the state
126
+
127
+ const original_state = JSON.parse(from_base64(ctx.$body.state));
128
+
129
+ const modified_state = deepmerge(original_state, ctx.$body.$ || {}, {
130
+ arrayMerge: (
131
+ target: any[],
132
+ source: any[],
133
+ options: ArrayMergeOptions
134
+ ) => {
135
+ // https://github.com/TehShrike/deepmerge#user-content-arraymerge-example-combine-arrays
136
+ const destination = target.slice();
137
+
138
+ source.forEach((item, index) => {
139
+ if (typeof destination[index] === "undefined") {
140
+ destination[index] =
141
+ options.cloneUnlessOtherwiseSpecified(
142
+ item,
143
+ options
144
+ );
145
+ } else if (options.isMergeableObject(item)) {
146
+ destination[index] = deepmerge(
147
+ target[index],
148
+ item,
149
+ options
150
+ );
151
+ } else if (target.indexOf(item) === -1) {
152
+ destination.push(item);
153
+ }
154
+ });
155
+ return destination;
156
+ },
157
+ }) as State;
158
+
159
+ if (ctx.$body.action && ctx.$body.args) {
160
+ return {
161
+ state: modified_state,
162
+ action: ctx.$body.action as string,
163
+ inputs,
164
+ action_args: ctx.$body.args,
165
+ };
166
+ } else {
167
+ return {
168
+ state: modified_state,
169
+ action: null,
170
+ action_args: null,
171
+ inputs,
172
+ };
173
+ }
174
+ }
175
+
176
+ mount(router: Router, path: string) {
177
+ router.get(path, (ctx) => {
178
+ ctx.body = this.render(ctx, this.getInitialState(), {});
179
+ });
180
+
181
+ router.post(path, (ctx) => {
182
+ const { action, state, inputs, action_args } =
183
+ this.extractState(ctx);
184
+ if (action) {
185
+ ctx.body = this.render(
186
+ ctx,
187
+ this.actions[action](
188
+ state,
189
+ inputs,
190
+ ...(JSON.parse(
191
+ from_base64(action_args as string)
192
+ ) as unknown[])
193
+ ),
194
+ inputs
195
+ );
196
+ } else {
197
+ ctx.body = this.render(ctx, state, inputs);
198
+ }
199
+ ctx.status = 433;
200
+ });
201
+ }
202
+ }
package/src/run.ts ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ export function attribute(raw: string | number): string {
2
+ if (typeof raw !== "string") {
3
+ raw = raw.toString();
4
+ }
5
+ return (raw || "")
6
+ .replaceAll("<", "&lt;")
7
+ .replace('"', "&quot;")
8
+ .replaceAll("'", "&#39;");
9
+ }