@saltcorn/server 0.9.6-beta.2 → 0.9.6-beta.20

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 (49) hide show
  1. package/app.js +6 -1
  2. package/auth/admin.js +55 -53
  3. package/auth/routes.js +28 -10
  4. package/auth/testhelp.js +86 -0
  5. package/help/Field label.tmd +11 -0
  6. package/help/Field types.tmd +39 -0
  7. package/help/Ownership field.tmd +76 -0
  8. package/help/Ownership formula.tmd +75 -0
  9. package/help/Table roles.tmd +20 -0
  10. package/help/User groups.tmd +35 -0
  11. package/load_plugins.js +33 -5
  12. package/locales/en.json +29 -1
  13. package/locales/it.json +3 -2
  14. package/markup/admin.js +1 -0
  15. package/markup/forms.js +5 -1
  16. package/package.json +9 -9
  17. package/public/log_viewer_utils.js +32 -0
  18. package/public/mermaid.min.js +705 -306
  19. package/public/saltcorn-builder.css +23 -0
  20. package/public/saltcorn-common.js +248 -80
  21. package/public/saltcorn.css +80 -0
  22. package/public/saltcorn.js +86 -2
  23. package/restart_watcher.js +1 -0
  24. package/routes/actions.js +27 -0
  25. package/routes/admin.js +175 -64
  26. package/routes/api.js +6 -0
  27. package/routes/common_lists.js +42 -32
  28. package/routes/fields.js +70 -42
  29. package/routes/homepage.js +2 -0
  30. package/routes/index.js +2 -0
  31. package/routes/menu.js +69 -4
  32. package/routes/notifications.js +90 -10
  33. package/routes/pageedit.js +18 -13
  34. package/routes/plugins.js +11 -2
  35. package/routes/registry.js +289 -0
  36. package/routes/search.js +10 -4
  37. package/routes/tables.js +51 -27
  38. package/routes/tenant.js +4 -15
  39. package/routes/utils.js +25 -8
  40. package/routes/view.js +1 -1
  41. package/routes/viewedit.js +11 -7
  42. package/serve.js +27 -5
  43. package/tests/edit.test.js +426 -0
  44. package/tests/fields.test.js +21 -0
  45. package/tests/filter.test.js +68 -0
  46. package/tests/page.test.js +2 -2
  47. package/tests/plugins.test.js +2 -0
  48. package/tests/sync.test.js +59 -0
  49. package/wrapper.js +4 -1
@@ -0,0 +1,289 @@
1
+ const Router = require("express-promise-router");
2
+
3
+ const db = require("@saltcorn/data/db");
4
+ const { mkTable, link, post_btn, renderForm } = require("@saltcorn/markup");
5
+ const {
6
+ script,
7
+ domReady,
8
+ a,
9
+ div,
10
+ i,
11
+ text,
12
+ button,
13
+ input,
14
+ label,
15
+ form,
16
+ ul,
17
+ li,
18
+ details,
19
+ summary,
20
+ } = require("@saltcorn/markup/tags");
21
+ const Table = require("@saltcorn/data/models/table");
22
+ const { isAdmin, error_catcher } = require("./utils");
23
+ const { send_infoarch_page } = require("../markup/admin.js");
24
+ const View = require("@saltcorn/data/models/view");
25
+ const Page = require("@saltcorn/data/models/page");
26
+ const Form = require("@saltcorn/data/models/form");
27
+ const {
28
+ table_pack,
29
+ view_pack,
30
+ plugin_pack,
31
+ page_pack,
32
+ page_group_pack,
33
+ role_pack,
34
+ library_pack,
35
+ trigger_pack,
36
+ tag_pack,
37
+ model_pack,
38
+ model_instance_pack,
39
+ event_log_pack,
40
+ install_pack,
41
+ } = require("@saltcorn/admin-models/models/pack");
42
+ const Trigger = require("@saltcorn/data/models/trigger");
43
+ /**
44
+ * @type {object}
45
+ * @const
46
+ * @namespace listRouter
47
+ * @category server
48
+ * @subcategory routes
49
+ */
50
+ const router = new Router();
51
+
52
+ // export our router to be mounted by the parent application
53
+ module.exports = router;
54
+
55
+ async function asyncFilter(arr, cb) {
56
+ const filtered = [];
57
+
58
+ for (const element of arr) {
59
+ const needAdd = await cb(element);
60
+
61
+ if (needAdd) {
62
+ filtered.push(element);
63
+ }
64
+ }
65
+
66
+ return filtered;
67
+ }
68
+ router.get(
69
+ "/",
70
+ isAdmin,
71
+ error_catcher(async (req, res) => {
72
+ const { etype, ename, q } = req.query;
73
+ const qlink = q ? `&q=${encodeURIComponent(q)}` : "";
74
+ let edContents = "Choose an entity to edit";
75
+ const all_tables = await Table.find({}, { orderBy: "name", nocase: true });
76
+ const all_views = await View.find({}, { orderBy: "name", nocase: true });
77
+ const all_pages = await Page.find({}, { orderBy: "name", nocase: true });
78
+ const all_triggers = await Trigger.find(
79
+ {},
80
+ { orderBy: "name", nocase: true }
81
+ );
82
+ let tables, views, pages, triggers;
83
+ if (q) {
84
+ const qlower = q.toLowerCase();
85
+ const includesQ = (s) => s.toLowerCase().includes(qlower);
86
+
87
+ tables = await asyncFilter(all_tables, async (t) => {
88
+ const pack = await table_pack(t);
89
+ return includesQ(JSON.stringify(pack));
90
+ });
91
+ views = await asyncFilter(all_views, async (t) => {
92
+ const pack = await view_pack(t);
93
+ return includesQ(JSON.stringify(pack));
94
+ });
95
+ pages = await asyncFilter(all_pages, async (t) => {
96
+ const pack = await page_pack(t);
97
+ return includesQ(JSON.stringify(pack));
98
+ });
99
+ triggers = await asyncFilter(all_triggers, async (t) => {
100
+ const pack = await trigger_pack(t);
101
+ return includesQ(JSON.stringify(pack));
102
+ });
103
+ } else {
104
+ tables = all_tables;
105
+ views = all_views;
106
+ pages = all_pages;
107
+ triggers = all_triggers;
108
+ }
109
+ const li_link = (etype1, ename1) =>
110
+ li(
111
+ a(
112
+ {
113
+ href: `/registry-editor?etype=${etype1}&ename=${encodeURIComponent(
114
+ ename1
115
+ )}${qlink}`,
116
+ class: etype1 === etype && ename1 === ename ? "fw-bold" : undefined,
117
+ },
118
+ ename1
119
+ )
120
+ );
121
+ const mkForm = (jsonVal) =>
122
+ new Form({
123
+ labelCols: 0,
124
+ action: `/registry-editor?etype=${etype}&ename=${encodeURIComponent(
125
+ ename
126
+ )}${qlink}`,
127
+
128
+ values: { regval: JSON.stringify(jsonVal, null, 2) },
129
+ fields: [
130
+ {
131
+ name: "regval",
132
+ label: "",
133
+ input_type: "code",
134
+ attributes: { mode: "application/json" },
135
+ },
136
+ ],
137
+ });
138
+ switch (etype) {
139
+ case "table":
140
+ const tpack = await table_pack(
141
+ all_tables.find((t) => t.name === ename)
142
+ );
143
+ edContents = renderForm(mkForm(tpack), req.csrfToken());
144
+ break;
145
+ case "view":
146
+ const vpack = await view_pack(all_views.find((v) => v.name === ename));
147
+ edContents = renderForm(mkForm(vpack), req.csrfToken());
148
+ break;
149
+ case "page":
150
+ const ppack = await page_pack(all_pages.find((v) => v.name === ename));
151
+ edContents = renderForm(mkForm(ppack), req.csrfToken());
152
+ break;
153
+ case "trigger":
154
+ const trpack = await trigger_pack(
155
+ all_triggers.find((t) => t.name === ename)
156
+ );
157
+ edContents = renderForm(mkForm(trpack), req.csrfToken());
158
+ break;
159
+ }
160
+ send_infoarch_page({
161
+ res,
162
+ req,
163
+ active_sub: "Registry editor",
164
+ contents: {
165
+ widths: [3, 9],
166
+ besides: [
167
+ {
168
+ type: "card",
169
+ bodyClass: "p-1",
170
+ title: "Entities",
171
+ contents: div(
172
+ form(
173
+ { method: "GET", action: `/registry-editor` },
174
+ div(
175
+ { class: "input-group search-bar mb-2" },
176
+ etype &&
177
+ input({ type: "hidden", name: "etype", value: etype }),
178
+ ename &&
179
+ input({ type: "hidden", name: "ename", value: ename }),
180
+ input({
181
+ type: "search",
182
+ class: "form-control search-bar ps-2 hasbl",
183
+ placeholder: "Search",
184
+ name: "q",
185
+ value: q,
186
+ "aria-label": "Search",
187
+ "aria-describedby": "button-search-submit",
188
+ }),
189
+ button(
190
+ {
191
+ class: "btn btn-outline-secondary search-bar",
192
+ type: "submit",
193
+ },
194
+ i({ class: "fas fa-search" })
195
+ )
196
+ )
197
+ ),
198
+ // following https://iamkate.com/code/tree-views/
199
+ ul(
200
+ { class: "katetree ps-2" },
201
+ li(
202
+ details(
203
+ { open: q || etype === "table" },
204
+ summary("Tables"),
205
+ ul(
206
+ { class: "ps-3" },
207
+ tables.map((t) => li_link("table", t.name))
208
+ )
209
+ )
210
+ ),
211
+ li(
212
+ details(
213
+ { open: q || etype === "view" },
214
+ summary("Views"),
215
+ ul(
216
+ { class: "ps-3" },
217
+ views.map((v) => li_link("view", v.name))
218
+ )
219
+ )
220
+ ),
221
+ li(
222
+ details(
223
+ { open: q || etype === "page" }, //
224
+ summary("Pages"),
225
+ ul(
226
+ { class: "ps-3" },
227
+ pages.map((p) => li_link("page", p.name))
228
+ )
229
+ )
230
+ ),
231
+ li(
232
+ details(
233
+ { open: q || etype === "trigger" }, //
234
+ summary("Triggers"),
235
+ ul(
236
+ { class: "ps-3" },
237
+ triggers.map((t) => li_link("trigger", t.name))
238
+ )
239
+ )
240
+ )
241
+ )
242
+ ),
243
+ },
244
+ {
245
+ type: "card",
246
+ title:
247
+ ename && etype
248
+ ? `Registry editor: ${ename} ${etype}`
249
+ : "Registry editor",
250
+ contents: edContents,
251
+ },
252
+ ],
253
+ },
254
+ });
255
+ })
256
+ );
257
+
258
+ router.post(
259
+ "/",
260
+ isAdmin,
261
+ error_catcher(async (req, res) => {
262
+ const { etype, ename, q } = req.query;
263
+ const qlink = q ? `&q=${encodeURIComponent(q)}` : "";
264
+
265
+ const entVal = JSON.parse(req.body.regval);
266
+ let pack = { plugins: [], tables: [], views: [], pages: [], triggers: [] };
267
+
268
+ switch (etype) {
269
+ case "table":
270
+ pack.tables = [entVal];
271
+ break;
272
+ case "view":
273
+ pack.views = [entVal];
274
+ break;
275
+ case "page":
276
+ pack.pages = [entVal];
277
+ break;
278
+ case "trigger":
279
+ pack.triggers = [entVal];
280
+ break;
281
+ }
282
+ await install_pack(pack);
283
+ res.redirect(
284
+ `/registry-editor?etype=${etype}&ename=${encodeURIComponent(
285
+ ename
286
+ )}${qlink}`
287
+ );
288
+ })
289
+ );
package/routes/search.js CHANGED
@@ -202,7 +202,8 @@ const runSearch = async ({ q, _page, table }, req, res) => {
202
202
  let tablesWithResults = [];
203
203
  let tablesConfigured = 0;
204
204
  for (const [tableName, viewName] of Object.entries(cfg)) {
205
- if (!viewName || viewName === "") continue;
205
+ if (!viewName || viewName === "" || viewName === "search_table_description")
206
+ continue;
206
207
  tablesConfigured += 1;
207
208
  if (table && tableName !== table) continue;
208
209
  let sectionHeader = tableName;
@@ -232,7 +233,7 @@ const runSearch = async ({ q, _page, table }, req, res) => {
232
233
  }
233
234
 
234
235
  if (vresps.length > 0) {
235
- tablesWithResults.push(tableName);
236
+ tablesWithResults.push({ tableName, label: sectionHeader });
236
237
  resp.push({
237
238
  type: "card",
238
239
  title: span({ id: tableName }, sectionHeader),
@@ -273,8 +274,13 @@ const runSearch = async ({ q, _page, table }, req, res) => {
273
274
  req.__("Show only matches in table:"),
274
275
  " ",
275
276
  tablesWithResults
276
- .map((t) =>
277
- a({ href: `javascript:set_state_field('table', '${t}')` }, t)
277
+ .map(({ tableName, label }) =>
278
+ a(
279
+ {
280
+ href: `javascript:set_state_field('table', '${tableName}')`,
281
+ },
282
+ label
283
+ )
278
284
  )
279
285
  .join(" | ")
280
286
  )
package/routes/tables.js CHANGED
@@ -93,14 +93,45 @@ const tableForm = async (table, req) => {
93
93
  noSubmitButton: true,
94
94
  onChange: "saveAndContinue(this)",
95
95
  fields: [
96
+ {
97
+ label: req.__("Minimum role to read"),
98
+ sublabel: req.__(
99
+ "User must have this role or higher to read rows from the table, unless they are the owner"
100
+ ),
101
+ help: {
102
+ topic: "Table roles",
103
+ context: {},
104
+ },
105
+ name: "min_role_read",
106
+ input_type: "select",
107
+ options: roleOptions,
108
+ attributes: { asideNext: !table.external && !table.provider_name },
109
+ },
96
110
  ...(!table.external && !table.provider_name
97
111
  ? [
112
+ {
113
+ label: req.__("Minimum role to write"),
114
+ name: "min_role_write",
115
+ input_type: "select",
116
+ help: {
117
+ topic: "Table roles",
118
+ context: {},
119
+ },
120
+ sublabel: req.__(
121
+ "User must have this role or higher to edit or create new rows in the table, unless they are the owner"
122
+ ),
123
+ options: roleOptions,
124
+ },
98
125
  {
99
126
  label: req.__("Ownership field"),
100
127
  name: "ownership_field_id",
101
128
  sublabel: req.__(
102
129
  "The user referred to in this field will be the owner of the row"
103
130
  ),
131
+ help: {
132
+ topic: "Ownership field",
133
+ context: {},
134
+ },
104
135
  input_type: "select",
105
136
  options: [
106
137
  { value: "", label: req.__("None") },
@@ -114,6 +145,10 @@ const tableForm = async (table, req) => {
114
145
  validator: expressionValidator,
115
146
  type: "String",
116
147
  class: "validate-expression",
148
+ help: {
149
+ topic: "Ownership formula",
150
+ context: {},
151
+ },
117
152
  sublabel:
118
153
  req.__("User is treated as owner if true. In scope: ") +
119
154
  ["user", ...fields.map((f) => f.name)]
@@ -126,6 +161,10 @@ const tableForm = async (table, req) => {
126
161
  sublabel: req.__(
127
162
  "Add relations to this table in dropdown options for ownership field"
128
163
  ),
164
+ help: {
165
+ topic: "User groups",
166
+ context: {},
167
+ },
129
168
  name: "is_user_group",
130
169
  type: "Bool",
131
170
  },
@@ -141,28 +180,9 @@ const tableForm = async (table, req) => {
141
180
  ),
142
181
  //options: roleOptions,
143
182
  },
144
- {
145
- label: req.__("Minimum role to read"),
146
- sublabel: req.__(
147
- "User must have this role or higher to read rows from the table, unless they are the owner"
148
- ),
149
- name: "min_role_read",
150
- input_type: "select",
151
- options: roleOptions,
152
- attributes: { asideNext: !table.external && !table.provider_name },
153
- },
154
183
  ...(table.external || table.provider_name
155
184
  ? []
156
185
  : [
157
- {
158
- label: req.__("Minimum role to write"),
159
- name: "min_role_write",
160
- input_type: "select",
161
- sublabel: req.__(
162
- "User must have this role or higher to edit or create new rows in the table, unless they are the owner"
163
- ),
164
- options: roleOptions,
165
- },
166
186
  {
167
187
  label: req.__("Version history"),
168
188
  sublabel: req.__("Track table data changes over time"),
@@ -681,6 +701,10 @@ const attribBadges = (f) => {
681
701
  "on_delete_cascade",
682
702
  "on_delete",
683
703
  "unique_error_msg",
704
+ "ref",
705
+ "table",
706
+ "agg_field",
707
+ "agg_relation",
684
708
  ].includes(k)
685
709
  )
686
710
  return;
@@ -750,6 +774,14 @@ router.get(
750
774
  r.typename +
751
775
  span({ class: "badge bg-danger ms-1" }, "Unknown type"),
752
776
  },
777
+ ...(table.external
778
+ ? []
779
+ : [
780
+ {
781
+ label: req.__("Edit"),
782
+ key: (r) => link(`/field/${r.id}`, req.__("Edit")),
783
+ },
784
+ ]),
753
785
  {
754
786
  label: "",
755
787
  key: (r) => typeBadges(r, req),
@@ -759,14 +791,6 @@ router.get(
759
791
  key: (r) => attribBadges(r),
760
792
  },
761
793
  { label: req.__("Variable name"), key: (t) => code(t.name) },
762
- ...(table.external
763
- ? []
764
- : [
765
- {
766
- label: req.__("Edit"),
767
- key: (r) => link(`/field/${r.id}`, req.__("Edit")),
768
- },
769
- ]),
770
794
  ...(table.external || db.isSQLite
771
795
  ? []
772
796
  : [
package/routes/tenant.js CHANGED
@@ -44,7 +44,7 @@ const {
44
44
  const db = require("@saltcorn/data/db");
45
45
 
46
46
  const { loadAllPlugins, loadAndSaveNewPlugin } = require("../load_plugins");
47
- const { isAdmin, error_catcher } = require("./utils.js");
47
+ const { isAdmin, error_catcher, is_ip_address } = require("./utils.js");
48
48
  const User = require("@saltcorn/data/models/user");
49
49
  const File = require("@saltcorn/data/models/file");
50
50
  const {
@@ -117,22 +117,10 @@ const create_tenant_allowed = (req) => {
117
117
  return user_role <= required_role;
118
118
  };
119
119
 
120
- /**
121
- * Check that String is IPv4 address
122
- * @param {string} hostname
123
- * @returns {boolean|string[]}
124
- */
125
- // TBD not sure that false is correct return if type of is not string
126
- // TBD Add IPv6 support
127
- const is_ip_address = (hostname) => {
128
- if (typeof hostname !== "string") return false;
129
- return hostname.split(".").every((s) => +s >= 0 && +s <= 255);
130
- };
131
-
132
120
  const get_cfg_tenant_base_url = (req) =>
133
121
  remove_leading_chars(
134
122
  ".",
135
- getRootState().getConfig("tenant_baseurl", req.hostname)
123
+ getRootState().getConfig("tenant_baseurl", req.hostname) || req.hostname
136
124
  )
137
125
  .replace("http://", "")
138
126
  .replace("https://", "");
@@ -268,7 +256,8 @@ router.post(
268
256
  return;
269
257
  }
270
258
  // declare ui form
271
- const form = tenant_form(req);
259
+ const base_url = get_cfg_tenant_base_url(req);
260
+ const form = tenant_form(req, base_url);
272
261
  // validate ui form
273
262
  const valres = form.validate(req.body);
274
263
  if (valres.errors)
package/routes/utils.js CHANGED
@@ -14,7 +14,7 @@ const {
14
14
  } = require("@saltcorn/data/db/state");
15
15
  const { get_base_url } = require("@saltcorn/data/models/config");
16
16
  const { hash } = require("@saltcorn/data/utils");
17
- const { input, script, domReady } = require("@saltcorn/markup/tags");
17
+ const { input, script, domReady, a } = require("@saltcorn/markup/tags");
18
18
  const session = require("express-session");
19
19
  const cookieSession = require("cookie-session");
20
20
  const is = require("contractis/is");
@@ -77,11 +77,9 @@ function loggedIn(req, res, next) {
77
77
  * @returns {void}
78
78
  */
79
79
  function isAdmin(req, res, next) {
80
- if (
81
- req.user &&
82
- req.user.role_id === 1 &&
83
- req.user.tenant === db.getTenantSchema()
84
- ) {
80
+ const cur_tenant = db.getTenantSchema();
81
+ //console.log({ cur_tenant, user: req.user });
82
+ if (req.user && req.user.role_id === 1 && req.user.tenant === cur_tenant) {
85
83
  next();
86
84
  } else {
87
85
  req.flash("danger", req.__("Must be admin"));
@@ -157,6 +155,8 @@ const get_tenant_from_req = (req, hostPartsOffset) => {
157
155
  if (req.subdomains && req.subdomains.length == 0)
158
156
  return db.connectObj.default_schema;
159
157
  if (!req.subdomains && req.headers.host) {
158
+ if (is_ip_address(req.headers.host.split(":")[0]))
159
+ return db.connectObj.default_schema;
160
160
  const parts = req.headers.host.split(".");
161
161
  if (parts.length < (!hostPartsOffset ? 3 : 3 - hostPartsOffset))
162
162
  return db.connectObj.default_schema;
@@ -299,7 +299,7 @@ const getGitRevision = () => db.connectObj.git_commit;
299
299
  * Gets session store
300
300
  * @returns {session|cookieSession}
301
301
  */
302
- const getSessionStore = () => {
302
+ const getSessionStore = (pruneInterval) => {
303
303
  /*if (getState().getConfig("cookie_sessions", false)) {
304
304
  return cookieSession({
305
305
  keys: [db.connectObj.session_secret || is.str.generate()],
@@ -322,6 +322,7 @@ const getSessionStore = () => {
322
322
  schemaName: db.connectObj.default_schema,
323
323
  pool: db.pool,
324
324
  tableName: "_sc_session",
325
+ pruneSessionInterval: pruneInterval > 0 ? pruneInterval : false,
325
326
  }),
326
327
  secret: db.connectObj.session_secret || is.str.generate(),
327
328
  resave: false,
@@ -350,6 +351,18 @@ const is_relative_url = (url) => {
350
351
  return typeof url === "string" && !url.includes(":/") && !url.includes("//");
351
352
  };
352
353
 
354
+ /**
355
+ * Check that String is IPv4 address
356
+ * @param {string} hostname
357
+ * @returns {boolean|string[]}
358
+ */
359
+ // TBD not sure that false is correct return if type of is not string
360
+ // TBD Add IPv6 support
361
+ const is_ip_address = (hostname) => {
362
+ if (typeof hostname !== "string") return false;
363
+ return hostname.split(".").every((s) => +s >= 0 && +s <= 255);
364
+ };
365
+
353
366
  const admin_config_route = ({
354
367
  router,
355
368
  path,
@@ -398,7 +411,10 @@ const admin_config_route = ({
398
411
  if (restart_required)
399
412
  res.json({
400
413
  success: "ok",
401
- notify: req.__("Restart required for changes to take effect."),
414
+ notify:
415
+ req.__("Restart required for changes to take effect.") +
416
+ " " +
417
+ a({ href: "/admin/system" }, req.__("Restart here")),
402
418
  });
403
419
  else res.json({ success: "ok" });
404
420
  }
@@ -555,6 +571,7 @@ module.exports = {
555
571
  get_tenant_from_req,
556
572
  addOnDoneRedirect,
557
573
  is_relative_url,
574
+ is_ip_address,
558
575
  get_sys_info,
559
576
  admin_config_route,
560
577
  sendHtmlFile,
package/routes/view.js CHANGED
@@ -127,7 +127,7 @@ router.get(
127
127
  else {
128
128
  const qs = "";
129
129
  const contents =
130
- typeof contents0 === "string" && !req.xhr
130
+ typeof contents0 === "string"
131
131
  ? div(
132
132
  {
133
133
  class: "d-inline",
@@ -371,6 +371,7 @@ router.get(
371
371
  const form = await viewForm(req, tableOptions, roles, pages, viewrow);
372
372
  const inbound_connected = await viewrow.inbound_connected_objects();
373
373
  form.hidden("id");
374
+ form.onChange = `saveAndContinue(this)`;
374
375
  res.sendWrap(req.__(`Edit view`), {
375
376
  above: [
376
377
  {
@@ -383,6 +384,7 @@ router.get(
383
384
  {
384
385
  type: "card",
385
386
  class: "mt-0",
387
+ titleAjaxIndicator: true,
386
388
  title: req.__(
387
389
  `%s view - %s on %s`,
388
390
  viewname,
@@ -541,12 +543,14 @@ router.post(
541
543
  //console.log(v);
542
544
  await View.create(v);
543
545
  }
544
- res.redirect(
545
- addOnDoneRedirect(
546
- `/viewedit/config/${encodeURIComponent(v.name)}`,
547
- req
548
- )
549
- );
546
+ if (req.xhr) res.json({ success: "ok" });
547
+ else
548
+ res.redirect(
549
+ addOnDoneRedirect(
550
+ `/viewedit/config/${encodeURIComponent(v.name)}`,
551
+ req
552
+ )
553
+ );
550
554
  }
551
555
  } else {
552
556
  sendForm(form);
@@ -902,7 +906,7 @@ router.post(
902
906
  ? `/${req.query.on_done_redirect}`
903
907
  : "/viewedit";
904
908
  res.redirect(redirectTarget);
905
- } else res.json({ okay: true, responseText: message });
909
+ } else res.json({ success: "ok" });
906
910
  })
907
911
  );
908
912