@saltcorn/server 0.8.0 → 0.8.1-beta.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.
- package/auth/admin.js +34 -0
- package/locales/en.json +11 -1
- package/locales/fr.json +14 -2
- package/package.json +8 -8
- package/public/saltcorn-common.js +128 -68
- package/public/saltcorn.js +68 -20
- package/routes/admin.js +6 -1
- package/routes/api.js +2 -9
- package/routes/fields.js +44 -0
- package/routes/tenant.js +2 -1
- package/routes/view.js +14 -2
- package/routes/viewedit.js +35 -0
- package/s3storage.js +7 -4
- package/tests/fields.test.js +23 -0
- package/wrapper.js +2 -0
package/auth/admin.js
CHANGED
|
@@ -162,6 +162,11 @@ const user_dropdown = (user, req, can_reset) =>
|
|
|
162
162
|
},
|
|
163
163
|
'<i class="fas fa-edit"></i> ' + req.__("Edit")
|
|
164
164
|
),
|
|
165
|
+
post_dropdown_item(
|
|
166
|
+
`/useradmin/become-user/${user.id}`,
|
|
167
|
+
'<i class="fas fa-ghost"></i> ' + req.__("Become user"),
|
|
168
|
+
req
|
|
169
|
+
),
|
|
165
170
|
post_dropdown_item(
|
|
166
171
|
`/useradmin/set-random-password/${user.id}`,
|
|
167
172
|
'<i class="fas fa-random"></i> ' + req.__("Set random password"),
|
|
@@ -1074,6 +1079,35 @@ router.post(
|
|
|
1074
1079
|
})
|
|
1075
1080
|
);
|
|
1076
1081
|
|
|
1082
|
+
/**
|
|
1083
|
+
* Become user
|
|
1084
|
+
* @name post/become-user/:id
|
|
1085
|
+
* @function
|
|
1086
|
+
* @memberof module:auth/admin~auth/adminRouter
|
|
1087
|
+
*/
|
|
1088
|
+
router.post(
|
|
1089
|
+
"/become-user/:id",
|
|
1090
|
+
isAdmin,
|
|
1091
|
+
error_catcher(async (req, res) => {
|
|
1092
|
+
const { id } = req.params;
|
|
1093
|
+
const u = await User.findOne({ id });
|
|
1094
|
+
if (u) {
|
|
1095
|
+
u.relogin(req);
|
|
1096
|
+
req.flash(
|
|
1097
|
+
"success",
|
|
1098
|
+
req.__(
|
|
1099
|
+
`Your are now logged in as %s. Logout and login again to assume your usual identity`,
|
|
1100
|
+
u.email
|
|
1101
|
+
)
|
|
1102
|
+
);
|
|
1103
|
+
res.redirect(`/`);
|
|
1104
|
+
} else {
|
|
1105
|
+
req.flash("error", req.__(`User not found`));
|
|
1106
|
+
res.redirect(`/useradmin`);
|
|
1107
|
+
}
|
|
1108
|
+
})
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1077
1111
|
/**
|
|
1078
1112
|
* @name post/disable/:id
|
|
1079
1113
|
* @function
|
package/locales/en.json
CHANGED
|
@@ -1057,5 +1057,15 @@
|
|
|
1057
1057
|
"Specifies a default filter for what file types the user can pick from the file input dialog box. Example is `.doc, text/csv,audio/*,video/*,image/*`": "Specifies a default filter for what file types the user can pick from the file input dialog box. Example is `.doc, text/csv,audio/*,video/*,image/*`",
|
|
1058
1058
|
"Destination page": "Destination page",
|
|
1059
1059
|
"Module Store endpoint": "Module Store endpoint",
|
|
1060
|
-
"Authentication settings updated": "Authentication settings updated"
|
|
1060
|
+
"Authentication settings updated": "Authentication settings updated",
|
|
1061
|
+
"Log client errors": "Log client errors",
|
|
1062
|
+
"Record all client errors in the crash log": "Record all client errors in the crash log",
|
|
1063
|
+
"Default File accept filter": "Default File accept filter",
|
|
1064
|
+
"File upload debug": "File upload debug",
|
|
1065
|
+
"Turn on to debug file upload in express-fileupload.": "Turn on to debug file upload in express-fileupload.",
|
|
1066
|
+
"Upload size limit (Kb)": "Upload size limit (Kb)",
|
|
1067
|
+
"Maximum upload file size in kilobytes": "Maximum upload file size in kilobytes",
|
|
1068
|
+
"File upload timeout": "File upload timeout",
|
|
1069
|
+
"Defines how long to wait for data before aborting file upload. Set to 0 if you want to turn off timeout checks. ": "Defines how long to wait for data before aborting file upload. Set to 0 if you want to turn off timeout checks. ",
|
|
1070
|
+
"Files settings": "Files settings"
|
|
1061
1071
|
}
|
package/locales/fr.json
CHANGED
|
@@ -274,5 +274,17 @@
|
|
|
274
274
|
"Field %s deleted": "Champ %s supprimé",
|
|
275
275
|
"Language: ": "Langage: ",
|
|
276
276
|
"Local": "Local",
|
|
277
|
-
"Language changed to %s": "Langage changé vers %s"
|
|
278
|
-
|
|
277
|
+
"Language changed to %s": "Langage changé vers %s",
|
|
278
|
+
"CSV upload": "CSV upload",
|
|
279
|
+
"Create page": "Create page",
|
|
280
|
+
"Action": "Action",
|
|
281
|
+
"Table or Channel": "Table or Channel",
|
|
282
|
+
"Add trigger": "Add trigger",
|
|
283
|
+
"Upload file(s)": "Upload file(s)",
|
|
284
|
+
"About application": "About application",
|
|
285
|
+
"Modules": "Modules",
|
|
286
|
+
"Users and security": "Users and security",
|
|
287
|
+
"Site structure": "Site structure",
|
|
288
|
+
"Events": "Events",
|
|
289
|
+
"Are you sure?": "Are you sure?"
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/server",
|
|
3
|
-
"version": "0.8.0",
|
|
3
|
+
"version": "0.8.1-beta.0",
|
|
4
4
|
"description": "Server app for Saltcorn, open-source no-code platform",
|
|
5
5
|
"homepage": "https://saltcorn.com",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@saltcorn/base-plugin": "0.8.0",
|
|
10
|
-
"@saltcorn/builder": "0.8.0",
|
|
11
|
-
"@saltcorn/data": "0.8.0",
|
|
12
|
-
"@saltcorn/admin-models": "0.8.0",
|
|
13
|
-
"@saltcorn/filemanager": "0.8.0",
|
|
14
|
-
"@saltcorn/markup": "0.8.0",
|
|
15
|
-
"@saltcorn/sbadmin2": "0.8.0",
|
|
9
|
+
"@saltcorn/base-plugin": "0.8.1-beta.0",
|
|
10
|
+
"@saltcorn/builder": "0.8.1-beta.0",
|
|
11
|
+
"@saltcorn/data": "0.8.1-beta.0",
|
|
12
|
+
"@saltcorn/admin-models": "0.8.1-beta.0",
|
|
13
|
+
"@saltcorn/filemanager": "0.8.1-beta.0",
|
|
14
|
+
"@saltcorn/markup": "0.8.1-beta.0",
|
|
15
|
+
"@saltcorn/sbadmin2": "0.8.1-beta.0",
|
|
16
16
|
"@socket.io/cluster-adapter": "^0.1.0",
|
|
17
17
|
"@socket.io/sticky": "^1.0.1",
|
|
18
18
|
"aws-sdk": "^2.1037.0",
|
|
@@ -36,11 +36,11 @@ function add_repeater(nm) {
|
|
|
36
36
|
newe.appendTo($("div.repeats-" + nm));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const _apply_showif_plugins = []
|
|
39
|
+
const _apply_showif_plugins = [];
|
|
40
40
|
|
|
41
|
-
const add_apply_showif_plugin = p => {
|
|
42
|
-
_apply_showif_plugins.push(p)
|
|
43
|
-
}
|
|
41
|
+
const add_apply_showif_plugin = (p) => {
|
|
42
|
+
_apply_showif_plugins.push(p);
|
|
43
|
+
};
|
|
44
44
|
function apply_showif() {
|
|
45
45
|
$("[data-show-if]").each(function (ix, element) {
|
|
46
46
|
var e = $(element);
|
|
@@ -92,7 +92,8 @@ function apply_showif() {
|
|
|
92
92
|
} else {
|
|
93
93
|
e.append(
|
|
94
94
|
$(
|
|
95
|
-
`<option ${
|
|
95
|
+
`<option ${
|
|
96
|
+
`${current}` === `${o.value}` ? "selected" : ""
|
|
96
97
|
} value="${o.value}">${o.label}</option>`
|
|
97
98
|
)
|
|
98
99
|
);
|
|
@@ -117,76 +118,127 @@ function apply_showif() {
|
|
|
117
118
|
e.attr("data-selected", ec.target.value);
|
|
118
119
|
});
|
|
119
120
|
|
|
120
|
-
const currentOptionsSet = e.prop(
|
|
121
|
+
const currentOptionsSet = e.prop("data-fetch-options-current-set");
|
|
121
122
|
if (currentOptionsSet === qs) return;
|
|
122
123
|
|
|
123
124
|
const activate = (success, qs) => {
|
|
124
125
|
e.empty();
|
|
125
|
-
e.prop(
|
|
126
|
+
e.prop("data-fetch-options-current-set", qs);
|
|
126
127
|
if (!dynwhere.required) e.append($(`<option></option>`));
|
|
127
128
|
let currentDataOption = undefined;
|
|
128
|
-
const dataOptions = []
|
|
129
|
+
const dataOptions = [];
|
|
129
130
|
success.forEach((r) => {
|
|
130
131
|
const label = dynwhere.label_formula
|
|
131
132
|
? new Function(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
: r[dynwhere.summary_field]
|
|
136
|
-
const value = r[dynwhere.refname]
|
|
137
|
-
const selected = `${current}` === `${r[dynwhere.refname]}
|
|
133
|
+
`{${Object.keys(r).join(",")}}`,
|
|
134
|
+
"return " + dynwhere.label_formula
|
|
135
|
+
)(r)
|
|
136
|
+
: r[dynwhere.summary_field];
|
|
137
|
+
const value = r[dynwhere.refname];
|
|
138
|
+
const selected = `${current}` === `${r[dynwhere.refname]}`;
|
|
138
139
|
dataOptions.push({ text: label, value });
|
|
139
140
|
if (selected) currentDataOption = value;
|
|
140
|
-
const html = `<option ${
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
);
|
|
141
|
+
const html = `<option ${
|
|
142
|
+
selected ? "selected" : ""
|
|
143
|
+
} value="${value}">${label}</option>`;
|
|
144
|
+
e.append($(html));
|
|
145
145
|
});
|
|
146
|
-
element.dispatchEvent(new Event(
|
|
146
|
+
element.dispatchEvent(new Event("RefreshSelectOptions"));
|
|
147
147
|
if (e.hasClass("selectized") && $().selectize) {
|
|
148
148
|
e.selectize()[0].selectize.clearOptions();
|
|
149
149
|
e.selectize()[0].selectize.addOption(dataOptions);
|
|
150
150
|
if (typeof currentDataOption !== "undefined")
|
|
151
151
|
e.selectize()[0].selectize.setValue(currentDataOption);
|
|
152
|
-
|
|
153
152
|
}
|
|
154
|
-
}
|
|
153
|
+
};
|
|
155
154
|
|
|
156
|
-
const cache = e.prop(
|
|
155
|
+
const cache = e.prop("data-fetch-options-cache") || {};
|
|
157
156
|
if (cache[qs]) {
|
|
158
|
-
activate(cache[qs], qs)
|
|
157
|
+
activate(cache[qs], qs);
|
|
159
158
|
} else
|
|
160
159
|
$.ajax(`/api/${dynwhere.table}?${qs}`).then((resp) => {
|
|
161
160
|
if (resp.success) {
|
|
162
|
-
activate(resp.success, qs)
|
|
163
|
-
const cacheNow = e.prop(
|
|
164
|
-
e.prop(
|
|
161
|
+
activate(resp.success, qs);
|
|
162
|
+
const cacheNow = e.prop("data-fetch-options-cache") || {};
|
|
163
|
+
e.prop("data-fetch-options-cache", {
|
|
164
|
+
...cacheNow,
|
|
165
|
+
[qs]: resp.success,
|
|
166
|
+
});
|
|
165
167
|
}
|
|
166
168
|
});
|
|
167
169
|
});
|
|
168
170
|
|
|
169
171
|
$("[data-source-url]").each(function (ix, element) {
|
|
170
172
|
const e = $(element);
|
|
171
|
-
const
|
|
173
|
+
const rec0 = get_form_record(e);
|
|
174
|
+
|
|
175
|
+
const relevantFieldsStr = e.attr("data-relevant-fields");
|
|
176
|
+
let rec;
|
|
177
|
+
if (relevantFieldsStr) {
|
|
178
|
+
rec = {};
|
|
179
|
+
relevantFieldsStr.split(",").forEach((k) => {
|
|
180
|
+
rec[k] = rec0[k];
|
|
181
|
+
});
|
|
182
|
+
} else rec = rec0;
|
|
183
|
+
const recS = JSON.stringify(rec);
|
|
184
|
+
|
|
185
|
+
const shown = e.prop("data-source-url-current");
|
|
186
|
+
if (shown === recS) return;
|
|
187
|
+
|
|
188
|
+
const cache = e.prop("data-source-url-cache") || {};
|
|
189
|
+
|
|
190
|
+
const activate_onchange_coldef = () => {
|
|
191
|
+
e.closest(".form-namespace")
|
|
192
|
+
.find("input,select, textarea")
|
|
193
|
+
.on("change", (ec) => {
|
|
194
|
+
const $ec = $(ec.target);
|
|
195
|
+
const k = $ec.attr("name");
|
|
196
|
+
if (!k || k === "_columndef") return;
|
|
197
|
+
const v = ec.target.value;
|
|
198
|
+
const $def = e
|
|
199
|
+
.closest(".form-namespace")
|
|
200
|
+
.find("input[name=_columndef]");
|
|
201
|
+
const def = JSON.parse($def.val());
|
|
202
|
+
def[k] = v;
|
|
203
|
+
$def.val(JSON.stringify(def));
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (typeof cache[recS] !== "undefined") {
|
|
208
|
+
e.html(cache[recS]);
|
|
209
|
+
activate_onchange_coldef();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
172
212
|
ajax_post_json(e.attr("data-source-url"), rec, {
|
|
173
213
|
success: (data) => {
|
|
174
214
|
e.html(data);
|
|
215
|
+
const cacheNow = e.prop("data-source-url-cache") || {};
|
|
216
|
+
e.prop("data-source-url-cache", {
|
|
217
|
+
...cacheNow,
|
|
218
|
+
[recS]: data,
|
|
219
|
+
});
|
|
220
|
+
e.prop("data-source-url-current", recS);
|
|
221
|
+
activate_onchange_coldef();
|
|
175
222
|
},
|
|
176
223
|
error: (err) => {
|
|
177
224
|
console.error(err);
|
|
225
|
+
const cacheNow = e.prop("data-source-url-cache") || {};
|
|
226
|
+
e.prop("data-source-url-cache", {
|
|
227
|
+
...cacheNow,
|
|
228
|
+
[recS]: "",
|
|
229
|
+
});
|
|
178
230
|
e.html("");
|
|
179
231
|
},
|
|
180
232
|
});
|
|
181
233
|
});
|
|
182
|
-
_apply_showif_plugins.forEach(p => p())
|
|
234
|
+
_apply_showif_plugins.forEach((p) => p());
|
|
183
235
|
}
|
|
184
236
|
|
|
185
237
|
function splitTargetMatch(elemValue, target, keySpec) {
|
|
186
238
|
if (!elemValue) return false;
|
|
187
|
-
const [fld, keySpec1] = keySpec.split("|_")
|
|
188
|
-
const [sep, pos] = keySpec1.split("_")
|
|
189
|
-
const elemValueShort = elemValue.split(sep)[pos]
|
|
239
|
+
const [fld, keySpec1] = keySpec.split("|_");
|
|
240
|
+
const [sep, pos] = keySpec1.split("_");
|
|
241
|
+
const elemValueShort = elemValue.split(sep)[pos];
|
|
190
242
|
return elemValueShort === target;
|
|
191
243
|
}
|
|
192
244
|
|
|
@@ -195,7 +247,7 @@ function get_form_record(e, select_labels) {
|
|
|
195
247
|
e.closest(".form-namespace")
|
|
196
248
|
.find("input[name],select[name]")
|
|
197
249
|
.each(function () {
|
|
198
|
-
const name = $(this).attr("data-fieldname") || $(this).attr("name")
|
|
250
|
+
const name = $(this).attr("data-fieldname") || $(this).attr("name");
|
|
199
251
|
if (select_labels && $(this).prop("tagName").toLowerCase() === "select")
|
|
200
252
|
rec[name] = $(this).find("option:selected").text();
|
|
201
253
|
else if ($(this).prop("type") === "checkbox")
|
|
@@ -508,14 +560,16 @@ function notifyAlert(note, spin) {
|
|
|
508
560
|
}
|
|
509
561
|
|
|
510
562
|
$("#alerts-area")
|
|
511
|
-
.append(`<div class="alert alert-${type} alert-dismissible fade show ${
|
|
512
|
-
|
|
563
|
+
.append(`<div class="alert alert-${type} alert-dismissible fade show ${
|
|
564
|
+
spin ? "d-flex align-items-center" : ""
|
|
565
|
+
}" role="alert">
|
|
513
566
|
${txt}
|
|
514
|
-
${
|
|
515
|
-
|
|
516
|
-
|
|
567
|
+
${
|
|
568
|
+
spin
|
|
569
|
+
? `<div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>`
|
|
570
|
+
: `<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
|
|
517
571
|
</button>`
|
|
518
|
-
|
|
572
|
+
}
|
|
519
573
|
</div>`);
|
|
520
574
|
}
|
|
521
575
|
|
|
@@ -532,8 +586,9 @@ function common_done(res, isWeb = true) {
|
|
|
532
586
|
(isWeb ? location : parent.location).reload(); //TODO notify to cookie if reload or goto
|
|
533
587
|
}
|
|
534
588
|
if (res.download) {
|
|
535
|
-
const dataurl = `data:${
|
|
536
|
-
|
|
589
|
+
const dataurl = `data:${
|
|
590
|
+
res.download.mimetype || "application/octet-stream"
|
|
591
|
+
};base64,${res.download.blob}`;
|
|
537
592
|
fetch(dataurl)
|
|
538
593
|
.then((res) => res.blob())
|
|
539
594
|
.then((blob) => {
|
|
@@ -553,15 +608,19 @@ function common_done(res, isWeb = true) {
|
|
|
553
608
|
else if (res.goto) {
|
|
554
609
|
if (res.target === "_blank") window.open(res.goto, "_blank").focus();
|
|
555
610
|
else {
|
|
556
|
-
const prev = new URL(window.location.href)
|
|
557
|
-
const next = new URL(res.goto, prev.origin)
|
|
611
|
+
const prev = new URL(window.location.href);
|
|
612
|
+
const next = new URL(res.goto, prev.origin);
|
|
558
613
|
window.location.href = res.goto;
|
|
559
|
-
if (
|
|
560
|
-
|
|
614
|
+
if (
|
|
615
|
+
prev.origin === next.origin &&
|
|
616
|
+
prev.pathname === next.pathname &&
|
|
617
|
+
next.hash !== prev.hash
|
|
618
|
+
)
|
|
619
|
+
location.reload();
|
|
561
620
|
}
|
|
562
621
|
}
|
|
563
622
|
if (res.popup) {
|
|
564
|
-
ajax_modal(res.popup)
|
|
623
|
+
ajax_modal(res.popup);
|
|
565
624
|
}
|
|
566
625
|
}
|
|
567
626
|
|
|
@@ -572,7 +631,9 @@ const repeaterCopyValuesToForm = (form, editor, noTriggerChange) => {
|
|
|
572
631
|
const $e = form.find(`input[name="${k}_${ix}"]`);
|
|
573
632
|
if ($e.length) $e.val(v);
|
|
574
633
|
else {
|
|
575
|
-
const $ne = $(
|
|
634
|
+
const $ne = $(
|
|
635
|
+
`<input type="hidden" data-repeater-ix="${ix}" name="${k}_${ix}"></input>`
|
|
636
|
+
);
|
|
576
637
|
$ne.val(v);
|
|
577
638
|
form.append($ne);
|
|
578
639
|
}
|
|
@@ -700,9 +761,9 @@ function room_older(viewname, room_id, btn) {
|
|
|
700
761
|
function init_room(viewname, room_id) {
|
|
701
762
|
const socket = parent?.config?.server_path
|
|
702
763
|
? io(parent.config.server_path, {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
764
|
+
query: `jwt=${localStorage.getItem("auth_jwt")}`,
|
|
765
|
+
transports: ["websocket"],
|
|
766
|
+
})
|
|
706
767
|
: io({ transports: ["websocket"] });
|
|
707
768
|
|
|
708
769
|
socket.emit("join_room", [viewname, room_id]);
|
|
@@ -735,32 +796,31 @@ function cancel_form(form) {
|
|
|
735
796
|
}
|
|
736
797
|
|
|
737
798
|
function split_paste_handler(e) {
|
|
738
|
-
let clipboardData =
|
|
799
|
+
let clipboardData =
|
|
800
|
+
e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
|
|
739
801
|
|
|
740
|
-
const lines = clipboardData.getData(
|
|
802
|
+
const lines = clipboardData.getData("text").split(/\r\n/g);
|
|
741
803
|
|
|
742
804
|
// do normal thing if not multiline - do not interfere with ordinary copy paste
|
|
743
805
|
if (lines.length < 2) return;
|
|
744
806
|
e.preventDefault();
|
|
745
|
-
const form = $(e.target).closest(
|
|
807
|
+
const form = $(e.target).closest("form");
|
|
746
808
|
|
|
747
809
|
let matched = false;
|
|
748
810
|
|
|
749
|
-
form
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
$elem.val(lines.shift())
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
811
|
+
form
|
|
812
|
+
.find("input:not(:disabled):not([readonly]):not(:hidden)")
|
|
813
|
+
.each(function (ix, element) {
|
|
814
|
+
if (!matched && element === e.target) matched = true;
|
|
815
|
+
if (matched && lines.length > 0) {
|
|
816
|
+
const $elem = $(element);
|
|
817
|
+
if (ix === 0 && $elem.attr("type") !== "number") {
|
|
818
|
+
//const existing = $elem.val()
|
|
819
|
+
//const pasted =
|
|
820
|
+
$elem.val(lines.shift());
|
|
821
|
+
} else $elem.val(lines.shift());
|
|
822
|
+
}
|
|
823
|
+
});
|
|
764
824
|
}
|
|
765
825
|
|
|
766
826
|
function is_paging_param(key) {
|
package/public/saltcorn.js
CHANGED
|
@@ -202,6 +202,19 @@ function view_post(viewname, route, data, onDone) {
|
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
let logged_errors = [];
|
|
205
|
+
let error_catcher_enabled = false;
|
|
206
|
+
function enable_error_catcher() {
|
|
207
|
+
if (error_catcher_enabled) return;
|
|
208
|
+
document.addEventListener(
|
|
209
|
+
"DOMContentLoaded",
|
|
210
|
+
function () {
|
|
211
|
+
window.onerror = globalErrorCatcher;
|
|
212
|
+
},
|
|
213
|
+
false
|
|
214
|
+
);
|
|
215
|
+
error_catcher_enabled = true;
|
|
216
|
+
}
|
|
217
|
+
|
|
205
218
|
function globalErrorCatcher(message, source, lineno, colno, error) {
|
|
206
219
|
if (error && error.preventDefault) error.preventDefault();
|
|
207
220
|
if (logged_errors.includes(message)) return;
|
|
@@ -260,16 +273,22 @@ function ajax_modal(url, opts = {}) {
|
|
|
260
273
|
if (opts.submitReload === false) $("#scmodal").addClass("no-submit-reload");
|
|
261
274
|
else $("#scmodal").removeClass("no-submit-reload");
|
|
262
275
|
$.ajax(url, {
|
|
276
|
+
headers: {
|
|
277
|
+
SaltcornModalRequest: "true",
|
|
278
|
+
},
|
|
263
279
|
success: function (res, textStatus, request) {
|
|
264
280
|
var title = request.getResponseHeader("Page-Title");
|
|
281
|
+
var width = request.getResponseHeader("SaltcornModalWidth");
|
|
282
|
+
if (width) $(".modal-dialog").css("max-width", width);
|
|
283
|
+
else $(".modal-dialog").css("max-width", "");
|
|
265
284
|
if (title) $("#scmodal .modal-title").html(decodeURIComponent(title));
|
|
266
285
|
$("#scmodal .modal-body").html(res);
|
|
267
286
|
$("#scmodal").prop("data-modal-state", url);
|
|
268
287
|
new bootstrap.Modal($("#scmodal")).show();
|
|
269
288
|
initialize_page();
|
|
270
|
-
(opts.onOpen || function () {
|
|
289
|
+
(opts.onOpen || function () {})(res);
|
|
271
290
|
$("#scmodal").on("hidden.bs.modal", function (e) {
|
|
272
|
-
(opts.onClose || function () {
|
|
291
|
+
(opts.onClose || function () {})(res);
|
|
273
292
|
$("body").css("overflow", "");
|
|
274
293
|
});
|
|
275
294
|
},
|
|
@@ -278,7 +297,7 @@ function ajax_modal(url, opts = {}) {
|
|
|
278
297
|
|
|
279
298
|
function saveAndContinue(e, k) {
|
|
280
299
|
var form = $(e).closest("form");
|
|
281
|
-
const valres = form[0].reportValidity()
|
|
300
|
+
const valres = form[0].reportValidity();
|
|
282
301
|
if (!valres) return;
|
|
283
302
|
submitWithEmptyAction(form[0]);
|
|
284
303
|
var url = form.attr("action");
|
|
@@ -323,7 +342,7 @@ function applyViewConfig(e, url, k) {
|
|
|
323
342
|
"CSRF-Token": _sc_globalCsrf,
|
|
324
343
|
},
|
|
325
344
|
data: JSON.stringify(cfg),
|
|
326
|
-
error: function (request) {
|
|
345
|
+
error: function (request) {},
|
|
327
346
|
success: function (res) {
|
|
328
347
|
k && k(res);
|
|
329
348
|
!k && updateViewPreview();
|
|
@@ -344,10 +363,14 @@ function updateViewPreview() {
|
|
|
344
363
|
"CSRF-Token": _sc_globalCsrf,
|
|
345
364
|
},
|
|
346
365
|
|
|
347
|
-
error: function (request) {
|
|
366
|
+
error: function (request) {},
|
|
348
367
|
success: function (res) {
|
|
349
368
|
$preview.css({ opacity: 1.0 });
|
|
350
369
|
|
|
370
|
+
//disable functions preview migght try to call
|
|
371
|
+
set_state_field = () => {};
|
|
372
|
+
set_state_fields = () => {};
|
|
373
|
+
|
|
351
374
|
//disable elements in preview
|
|
352
375
|
$preview.html(res);
|
|
353
376
|
$preview.find("a").attr("href", "#");
|
|
@@ -357,11 +380,6 @@ function updateViewPreview() {
|
|
|
357
380
|
|
|
358
381
|
$preview.find("textarea").attr("disabled", true);
|
|
359
382
|
$preview.find("input").attr("readonly", true);
|
|
360
|
-
|
|
361
|
-
//disable functions preview migght try to call
|
|
362
|
-
set_state_field = () => { }
|
|
363
|
-
set_state_fields = () => { }
|
|
364
|
-
|
|
365
383
|
},
|
|
366
384
|
});
|
|
367
385
|
}
|
|
@@ -596,15 +614,13 @@ function build_mobile_app(button) {
|
|
|
596
614
|
localStorage.setItem("sidebarClosed", `${closed}`);
|
|
597
615
|
});
|
|
598
616
|
}
|
|
599
|
-
})()
|
|
600
|
-
|
|
601
|
-
|
|
617
|
+
})() +
|
|
602
618
|
/*
|
|
603
619
|
https://github.com/jeffdavidgreen/bootstrap-html5-history-tabs/blob/master/bootstrap-history-tabs.js
|
|
604
620
|
Copyright (c) 2015 Jeff Green
|
|
605
621
|
*/
|
|
606
622
|
|
|
607
|
-
|
|
623
|
+
(function ($) {
|
|
608
624
|
"use strict";
|
|
609
625
|
$.fn.historyTabs = function () {
|
|
610
626
|
var that = this;
|
|
@@ -619,21 +635,24 @@ function build_mobile_app(button) {
|
|
|
619
635
|
$(element).on("show.bs.tab", function () {
|
|
620
636
|
var stateObject = { url: $(this).attr("href") };
|
|
621
637
|
|
|
622
|
-
if (
|
|
638
|
+
if (
|
|
639
|
+
window.location.hash &&
|
|
640
|
+
stateObject.url !== window.location.hash
|
|
641
|
+
) {
|
|
623
642
|
window.history.pushState(
|
|
624
643
|
stateObject,
|
|
625
644
|
document.title,
|
|
626
645
|
window.location.pathname +
|
|
627
|
-
|
|
628
|
-
|
|
646
|
+
window.location.search +
|
|
647
|
+
$(this).attr("href")
|
|
629
648
|
);
|
|
630
649
|
} else {
|
|
631
650
|
window.history.replaceState(
|
|
632
651
|
stateObject,
|
|
633
652
|
document.title,
|
|
634
653
|
window.location.pathname +
|
|
635
|
-
|
|
636
|
-
|
|
654
|
+
window.location.search +
|
|
655
|
+
$(this).attr("href")
|
|
637
656
|
);
|
|
638
657
|
}
|
|
639
658
|
});
|
|
@@ -649,4 +668,33 @@ function build_mobile_app(button) {
|
|
|
649
668
|
|
|
650
669
|
// Copyright (c) 2011 Marcus Ekwall, http://writeless.se/
|
|
651
670
|
// https://github.com/mekwall/jquery-throttle
|
|
652
|
-
(function (a) {
|
|
671
|
+
(function (a) {
|
|
672
|
+
var b = a.jQuery || a.me || (a.me = {}),
|
|
673
|
+
i = function (e, f, g, h, c, a) {
|
|
674
|
+
f || (f = 100);
|
|
675
|
+
var d = !1,
|
|
676
|
+
j = !1,
|
|
677
|
+
i = typeof g === "function",
|
|
678
|
+
l = function (a, b) {
|
|
679
|
+
d = setTimeout(function () {
|
|
680
|
+
d = !1;
|
|
681
|
+
if (h || c) e.apply(a, b), c && (j = +new Date());
|
|
682
|
+
i && g.apply(a, b);
|
|
683
|
+
}, f);
|
|
684
|
+
},
|
|
685
|
+
k = function () {
|
|
686
|
+
if (!d || a) {
|
|
687
|
+
if (!d && !h && (!c || +new Date() - j > f))
|
|
688
|
+
e.apply(this, arguments), c && (j = +new Date());
|
|
689
|
+
(a || !c) && clearTimeout(d);
|
|
690
|
+
l(this, arguments);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
if (b.guid) k.guid = e.guid = e.guid || b.guid++;
|
|
694
|
+
return k;
|
|
695
|
+
};
|
|
696
|
+
b.throttle = i;
|
|
697
|
+
b.debounce = function (a, b, g, h, c) {
|
|
698
|
+
return i(a, b, g, h, c, !0);
|
|
699
|
+
};
|
|
700
|
+
})(this);
|
package/routes/admin.js
CHANGED
|
@@ -1822,7 +1822,12 @@ router.post(
|
|
|
1822
1822
|
const dev_form = async (req) => {
|
|
1823
1823
|
return await config_fields_form({
|
|
1824
1824
|
req,
|
|
1825
|
-
field_names: [
|
|
1825
|
+
field_names: [
|
|
1826
|
+
"development_mode",
|
|
1827
|
+
"log_sql",
|
|
1828
|
+
"log_client_errors",
|
|
1829
|
+
"log_level",
|
|
1830
|
+
],
|
|
1826
1831
|
action: "/admin/dev",
|
|
1827
1832
|
});
|
|
1828
1833
|
};
|
package/routes/api.js
CHANGED
|
@@ -381,10 +381,7 @@ router.post(
|
|
|
381
381
|
res.status(400).json({ error: errors.join(", ") });
|
|
382
382
|
return;
|
|
383
383
|
}
|
|
384
|
-
const ins_res = await table.tryInsertRow(
|
|
385
|
-
row,
|
|
386
|
-
req.user ? +req.user.id : undefined
|
|
387
|
-
);
|
|
384
|
+
const ins_res = await table.tryInsertRow(row, req.user);
|
|
388
385
|
if (ins_res.error) res.status(400).json(ins_res);
|
|
389
386
|
else res.json(ins_res);
|
|
390
387
|
} else {
|
|
@@ -439,11 +436,7 @@ router.post(
|
|
|
439
436
|
res.status(400).json({ error: errors.join(", ") });
|
|
440
437
|
return;
|
|
441
438
|
}
|
|
442
|
-
const ins_res = await table.tryUpdateRow(
|
|
443
|
-
row,
|
|
444
|
-
id,
|
|
445
|
-
req.user ? +req.user.id : undefined
|
|
446
|
-
);
|
|
439
|
+
const ins_res = await table.tryUpdateRow(row, id, req.user);
|
|
447
440
|
|
|
448
441
|
if (ins_res.error) res.status(400).json(ins_res);
|
|
449
442
|
else res.json(ins_res);
|
package/routes/fields.js
CHANGED
|
@@ -28,11 +28,13 @@ const expressionBlurb = require("../markup/expression_blurb");
|
|
|
28
28
|
const {
|
|
29
29
|
readState,
|
|
30
30
|
add_free_variables_to_joinfields,
|
|
31
|
+
calcfldViewConfig,
|
|
31
32
|
} = require("@saltcorn/data/plugin-helper");
|
|
32
33
|
const { wizardCardTitle } = require("../markup/forms.js");
|
|
33
34
|
const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
|
|
34
35
|
const { applyAsync } = require("@saltcorn/data/utils");
|
|
35
36
|
const { text } = require("@saltcorn/markup/tags");
|
|
37
|
+
const { mkFormContentNoLayout } = require("@saltcorn/markup/form");
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* @type {object}
|
|
@@ -903,3 +905,45 @@ router.post(
|
|
|
903
905
|
res.send("");
|
|
904
906
|
})
|
|
905
907
|
);
|
|
908
|
+
|
|
909
|
+
router.post(
|
|
910
|
+
"/fieldviewcfgform/:tableName",
|
|
911
|
+
isAdmin,
|
|
912
|
+
error_catcher(async (req, res) => {
|
|
913
|
+
const { tableName } = req.params;
|
|
914
|
+
const {
|
|
915
|
+
field_name,
|
|
916
|
+
fieldview,
|
|
917
|
+
type,
|
|
918
|
+
join_field,
|
|
919
|
+
join_fieldview,
|
|
920
|
+
_columndef,
|
|
921
|
+
} = req.body;
|
|
922
|
+
const table = await Table.findOne({ name: tableName });
|
|
923
|
+
const fieldName = type == "Field" ? field_name : join_field;
|
|
924
|
+
const fv_name = type == "Field" ? fieldview : join_fieldview;
|
|
925
|
+
if (!fieldName) {
|
|
926
|
+
res.send("");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const field = await table.getField(fieldName);
|
|
931
|
+
|
|
932
|
+
const fieldViewConfigForms = await calcfldViewConfig([field], false, 0);
|
|
933
|
+
const formFields = fieldViewConfigForms[field.name][fv_name];
|
|
934
|
+
if (!formFields) {
|
|
935
|
+
res.send("");
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
formFields.forEach((ff) => {
|
|
939
|
+
ff.class = ff.class ? `${ff.class} item-menu` : "item-menu";
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const form = new Form({
|
|
943
|
+
formStyle: "vert",
|
|
944
|
+
fields: formFields,
|
|
945
|
+
});
|
|
946
|
+
if (_columndef) form.values = JSON.parse(_columndef);
|
|
947
|
+
res.send(mkFormContentNoLayout(form));
|
|
948
|
+
})
|
|
949
|
+
);
|
package/routes/tenant.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
renderForm,
|
|
22
22
|
link,
|
|
23
23
|
post_delete_btn,
|
|
24
|
+
localeDateTime,
|
|
24
25
|
mkTable,
|
|
25
26
|
} = require("@saltcorn/markup");
|
|
26
27
|
const {
|
|
@@ -384,7 +385,7 @@ router.get(
|
|
|
384
385
|
},
|
|
385
386
|
{
|
|
386
387
|
label: req.__("Created"),
|
|
387
|
-
key: (r) =>
|
|
388
|
+
key: (r) => localeDateTime(r.created),
|
|
388
389
|
},
|
|
389
390
|
{
|
|
390
391
|
label: req.__("Information"),
|
package/routes/view.js
CHANGED
|
@@ -9,7 +9,7 @@ const Router = require("express-promise-router");
|
|
|
9
9
|
const View = require("@saltcorn/data/models/view");
|
|
10
10
|
const Table = require("@saltcorn/data/models/table");
|
|
11
11
|
|
|
12
|
-
const { text } = require("@saltcorn/markup/tags");
|
|
12
|
+
const { text, style } = require("@saltcorn/markup/tags");
|
|
13
13
|
const {
|
|
14
14
|
isAdmin,
|
|
15
15
|
error_catcher,
|
|
@@ -62,8 +62,20 @@ router.get(
|
|
|
62
62
|
res.redirect("/");
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
+
const isModal = req.headers?.saltcornmodalrequest;
|
|
66
|
+
|
|
65
67
|
const contents = await view.run_possibly_on_page(query, req, res);
|
|
66
|
-
const title =
|
|
68
|
+
const title =
|
|
69
|
+
isModal && view.attributes?.popup_title
|
|
70
|
+
? view.attributes?.popup_title
|
|
71
|
+
: scan_for_page_title(contents, view.name);
|
|
72
|
+
if (isModal && view.attributes?.popup_width)
|
|
73
|
+
res.set(
|
|
74
|
+
"SaltcornModalWidth",
|
|
75
|
+
`${view.attributes?.popup_width}${
|
|
76
|
+
view.attributes?.popup_width_units || "px"
|
|
77
|
+
}`
|
|
78
|
+
);
|
|
67
79
|
res.sendWrap(
|
|
68
80
|
title,
|
|
69
81
|
add_edit_bar({
|
package/routes/viewedit.js
CHANGED
|
@@ -134,6 +134,7 @@ const viewForm = async (req, tableOptions, roles, pages, values) => {
|
|
|
134
134
|
action: addOnDoneRedirect("/viewedit/save", req),
|
|
135
135
|
submitLabel: req.__("Configure") + " »",
|
|
136
136
|
blurb: req.__("First, please give some basic information about the view."),
|
|
137
|
+
tabs: { tabsStyle: "Accordion" },
|
|
137
138
|
fields: [
|
|
138
139
|
new Field({
|
|
139
140
|
label: req.__("View name"),
|
|
@@ -191,6 +192,7 @@ const viewForm = async (req, tableOptions, roles, pages, values) => {
|
|
|
191
192
|
"Requests to render this view directly will instead show the chosen page, if any. The chosen page should embed this view. Use this to decorate the view with additional elements."
|
|
192
193
|
),
|
|
193
194
|
input_type: "select",
|
|
195
|
+
tab: "View settings",
|
|
194
196
|
options: [
|
|
195
197
|
{ value: "", label: "" },
|
|
196
198
|
...pages.map((p) => ({ value: p.name, label: p.name })),
|
|
@@ -201,6 +203,7 @@ const viewForm = async (req, tableOptions, roles, pages, values) => {
|
|
|
201
203
|
label: req.__("Slug"),
|
|
202
204
|
sublabel: req.__("Field that can be used for a prettier URL structure"),
|
|
203
205
|
type: "String",
|
|
206
|
+
tab: "View settings",
|
|
204
207
|
attributes: {
|
|
205
208
|
calcOptions: [
|
|
206
209
|
"table_name",
|
|
@@ -209,6 +212,33 @@ const viewForm = async (req, tableOptions, roles, pages, values) => {
|
|
|
209
212
|
},
|
|
210
213
|
showIf: { viewtemplate: hasTable },
|
|
211
214
|
}),
|
|
215
|
+
new Field({
|
|
216
|
+
name: "popup_title",
|
|
217
|
+
label: req.__("Title"),
|
|
218
|
+
type: "String",
|
|
219
|
+
parent_field: "attributes",
|
|
220
|
+
tab: "Popup settings",
|
|
221
|
+
}),
|
|
222
|
+
{
|
|
223
|
+
name: "popup_width",
|
|
224
|
+
label: req.__("Column width"),
|
|
225
|
+
type: "Integer",
|
|
226
|
+
tab: "Popup settings",
|
|
227
|
+
parent_field: "attributes",
|
|
228
|
+
attributes: { asideNext: true },
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "popup_width_units",
|
|
232
|
+
label: req.__("Units"),
|
|
233
|
+
type: "String",
|
|
234
|
+
tab: "Popup settings",
|
|
235
|
+
fieldview: "radio_group",
|
|
236
|
+
parent_field: "attributes",
|
|
237
|
+
attributes: {
|
|
238
|
+
inline: true,
|
|
239
|
+
options: ["px", "%", "vw", "em", "rem"],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
212
242
|
...(isEdit
|
|
213
243
|
? [
|
|
214
244
|
new Field({
|
|
@@ -396,6 +426,7 @@ router.post(
|
|
|
396
426
|
const vt = getState().viewtemplates[v.viewtemplate];
|
|
397
427
|
if (vt.initial_config) v.configuration = await vt.initial_config(v);
|
|
398
428
|
else v.configuration = {};
|
|
429
|
+
//console.log(v);
|
|
399
430
|
await View.create(v);
|
|
400
431
|
}
|
|
401
432
|
res.redirect(
|
|
@@ -503,6 +534,9 @@ router.get(
|
|
|
503
534
|
res.redirect("/viewedit");
|
|
504
535
|
return;
|
|
505
536
|
}
|
|
537
|
+
(view.configuration?.columns || []).forEach((c) => {
|
|
538
|
+
c._columndef = JSON.stringify(c);
|
|
539
|
+
});
|
|
506
540
|
const configFlow = await view.get_config_flow(req);
|
|
507
541
|
const hasConfig =
|
|
508
542
|
view.configuration && Object.keys(view.configuration).length > 0;
|
|
@@ -644,6 +678,7 @@ router.post(
|
|
|
644
678
|
|
|
645
679
|
if (viewname && req.body) {
|
|
646
680
|
const view = await View.findOne({ name: viewname });
|
|
681
|
+
req.staticFieldViewConfig = true;
|
|
647
682
|
const configFlow = await view.get_config_flow(req);
|
|
648
683
|
const step = await configFlow.singleStepForm(req.body, req);
|
|
649
684
|
if (step?.renderForm) {
|
package/s3storage.js
CHANGED
|
@@ -46,7 +46,8 @@ module.exports = {
|
|
|
46
46
|
s3upload(req, res, next);
|
|
47
47
|
} else {
|
|
48
48
|
// Use regular file upload https://www.npmjs.com/package/express-fileupload
|
|
49
|
-
const fileSizeLimit =
|
|
49
|
+
const fileSizeLimit =
|
|
50
|
+
1024 * +getState().getConfig("file_upload_limit", 0);
|
|
50
51
|
fileUpload({
|
|
51
52
|
useTempFiles: true,
|
|
52
53
|
createParentPath: true,
|
|
@@ -58,9 +59,11 @@ module.exports = {
|
|
|
58
59
|
defCharset: "utf8",
|
|
59
60
|
defParamCharset: "utf8",
|
|
60
61
|
// 0 - means no upload limit check
|
|
61
|
-
limits:
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
limits: fileSizeLimit
|
|
63
|
+
? {
|
|
64
|
+
fileSize: fileSizeLimit,
|
|
65
|
+
}
|
|
66
|
+
: {},
|
|
64
67
|
abortOnLimit: fileSizeLimit !== 0,
|
|
65
68
|
// 0 - means no upload limit check
|
|
66
69
|
uploadTimeout: getState().getConfig("file_upload_timeout", 0),
|
package/tests/fields.test.js
CHANGED
|
@@ -367,3 +367,26 @@ describe("Field Endpoints", () => {
|
|
|
367
367
|
.expect((r) => +r.body > 1);
|
|
368
368
|
});
|
|
369
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("Fieldview config", () => {
|
|
372
|
+
//itShouldRedirectUnauthToLogin("/field/2");
|
|
373
|
+
it("should return fieldview options", async () => {
|
|
374
|
+
const loginCookie = await getAdminLoginCookie();
|
|
375
|
+
|
|
376
|
+
const app = await getApp({ disableCsrf: true });
|
|
377
|
+
|
|
378
|
+
await request(app)
|
|
379
|
+
.post("/field/fieldviewcfgform/books")
|
|
380
|
+
.set("Cookie", loginCookie)
|
|
381
|
+
.send({
|
|
382
|
+
type: "Field",
|
|
383
|
+
field_name: "pages",
|
|
384
|
+
fieldview: "progress_bar",
|
|
385
|
+
})
|
|
386
|
+
.expect(
|
|
387
|
+
toInclude(
|
|
388
|
+
`<div class="form-group"><div><label for="inputmax">max</label></div><div><input type="number" class="form-control item-menu" data-fieldname="max" name="max" id="inputmax" step="1" required></div></div><div class="form-group"><div><label for="inputbar_color">Bar color</label></div><div><input type="color" class="form-control item-menu" data-fieldname="bar_color" name="bar_color" id="inputbar_color"></div></div><div class="form-group"><div><label for="inputbg_color">Background color</label></div><div><input type="color" class="form-control item-menu" data-fieldname="bg_color" name="bg_color" id="inputbg_color"></div></div><div class="form-group"><div><label for="inputpx_height">Height in px</label></div><div><input type="number" class="form-control item-menu" data-fieldname="px_height" name="px_height" id="inputpx_height" step="1"></div></div>`
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
});
|
package/wrapper.js
CHANGED
|
@@ -203,6 +203,8 @@ const get_headers = (req, version_tag, description, extras = []) => {
|
|
|
203
203
|
from_cfg.push({ style: state.getConfig("page_custom_css", "") });
|
|
204
204
|
if (state.getConfig("page_custom_html", ""))
|
|
205
205
|
from_cfg.push({ headerTag: state.getConfig("page_custom_html", "") });
|
|
206
|
+
if (state.getConfig("log_client_errors", false))
|
|
207
|
+
from_cfg.push({ scriptBody: `enable_error_catcher()` });
|
|
206
208
|
const state_headers = [];
|
|
207
209
|
for (const hs of Object.values(state.headers)) {
|
|
208
210
|
state_headers.push(...hs);
|