@saltcorn/server 0.9.4-beta.8 → 0.9.4-beta.9

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/locales/en.json CHANGED
@@ -1354,5 +1354,11 @@
1354
1354
  "Some view patterns accept interpolations. Ex: <code>{{ name }}</code> or <code>{{ row ? `Edit ${row.name}` : `New person` }}</code>": "Some view patterns accept interpolations. Ex: <code>{{ name }}</code> or <code>{{ row ? `Edit ${row.name}` : `New person` }}</code>",
1355
1355
  "For search engines. Some view patterns accept interpolations.": "For search engines. Some view patterns accept interpolations.",
1356
1356
  "Files cache TTL (minutes)": "Files cache TTL (minutes)",
1357
- "Cache-control max-age for files.": "Cache-control max-age for files."
1358
- }
1357
+ "Cache-control max-age for files.": "Cache-control max-age for files.",
1358
+ "Popup min width": "Popup min width",
1359
+ "Add %s to tag %s": "Add %s to tag %s",
1360
+ "Add entries to tag %s": "Add entries to tag %s",
1361
+ "Tag not found": "Tag not found",
1362
+ "Unable to save: No page or no layout": "Unable to save: No page or no layout",
1363
+ "Unable to save: No view": "Unable to save: No view"
1364
+ }
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "0.9.4-beta.8",
3
+ "version": "0.9.4-beta.9",
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
9
  "@aws-sdk/client-s3": "^3.451.0",
10
- "@saltcorn/base-plugin": "0.9.4-beta.8",
11
- "@saltcorn/builder": "0.9.4-beta.8",
12
- "@saltcorn/data": "0.9.4-beta.8",
13
- "@saltcorn/admin-models": "0.9.4-beta.8",
14
- "@saltcorn/filemanager": "0.9.4-beta.8",
15
- "@saltcorn/markup": "0.9.4-beta.8",
16
- "@saltcorn/sbadmin2": "0.9.4-beta.8",
10
+ "@saltcorn/base-plugin": "0.9.4-beta.9",
11
+ "@saltcorn/builder": "0.9.4-beta.9",
12
+ "@saltcorn/data": "0.9.4-beta.9",
13
+ "@saltcorn/admin-models": "0.9.4-beta.9",
14
+ "@saltcorn/filemanager": "0.9.4-beta.9",
15
+ "@saltcorn/markup": "0.9.4-beta.9",
16
+ "@saltcorn/sbadmin2": "0.9.4-beta.9",
17
17
  "@socket.io/cluster-adapter": "^0.2.1",
18
18
  "@socket.io/sticky": "^1.0.1",
19
19
  "adm-zip": "0.5.10",
@@ -376,6 +376,7 @@ function ajax_modal(url, opts = {}) {
376
376
  success: function (res, textStatus, request) {
377
377
  var title = request.getResponseHeader("Page-Title");
378
378
  var width = request.getResponseHeader("SaltcornModalWidth");
379
+ var minwidth = request.getResponseHeader("SaltcornModalMinWidth");
379
380
  var saveIndicate = !!request.getResponseHeader(
380
381
  "SaltcornModalSaveIndicator"
381
382
  );
@@ -386,6 +387,8 @@ function ajax_modal(url, opts = {}) {
386
387
  else $(".sc-modal-linkout").hide();
387
388
  if (width) $(".modal-dialog").css("max-width", width);
388
389
  else $(".modal-dialog").css("max-width", "");
390
+ if (minwidth) $(".modal-dialog").css("min-width", minwidth);
391
+ else $(".modal-dialog").css("min-width", "");
389
392
  if (title) $("#scmodal .modal-title").html(decodeURIComponent(title));
390
393
  $("#scmodal .modal-body").html(res);
391
394
  $("#scmodal").prop("data-modal-state", url);
package/routes/actions.js CHANGED
@@ -14,6 +14,8 @@ const {
14
14
  const { getState } = require("@saltcorn/data/db/state");
15
15
  const Trigger = require("@saltcorn/data/models/trigger");
16
16
  const { getTriggerList } = require("./common_lists");
17
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
18
+ const Tag = require("@saltcorn/data/models/tag");
17
19
 
18
20
  /**
19
21
  * @type {object}
@@ -77,7 +79,20 @@ router.get(
77
79
  "/",
78
80
  isAdmin,
79
81
  error_catcher(async (req, res) => {
80
- const triggers = await Trigger.findAllWithTableName();
82
+ let triggers = await Trigger.findAllWithTableName();
83
+ let filterOnTag;
84
+
85
+ if (req.query._tag) {
86
+ const tagEntries = await TagEntry.find({
87
+ tag_id: +req.query._tag,
88
+ not: { trigger_id: null },
89
+ });
90
+ const tagged_trigger_ids = new Set(
91
+ tagEntries.map((te) => te.trigger_id).filter(Boolean)
92
+ );
93
+ triggers = triggers.filter((t) => tagged_trigger_ids.has(t.id));
94
+ filterOnTag = await Tag.findOne({ id: +req.query._tag });
95
+ }
81
96
  const actions = await getActions();
82
97
  send_events_page({
83
98
  res,
@@ -89,7 +104,7 @@ router.get(
89
104
  type: "card",
90
105
  title: req.__("Triggers"),
91
106
  contents: div(
92
- getTriggerList(triggers, req),
107
+ await getTriggerList(triggers, req, { filterOnTag }),
93
108
  link("/actions/new", req.__("Add trigger"))
94
109
  ),
95
110
  },
@@ -1,5 +1,7 @@
1
1
  const User = require("@saltcorn/data/models/user");
2
2
  const Table = require("@saltcorn/data/models/table");
3
+ const Tag = require("@saltcorn/data/models/tag");
4
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
3
5
  const { editRoleForm } = require("../markup/forms.js");
4
6
  const {
5
7
  mkTable,
@@ -7,16 +9,16 @@ const {
7
9
  post_delete_btn,
8
10
  settingsDropdown,
9
11
  post_dropdown_item,
12
+ badge,
10
13
  } = require("@saltcorn/markup");
11
14
  const { get_base_url } = require("./utils.js");
12
- const { h4, p, div, a, i, text } = require("@saltcorn/markup/tags");
15
+ const { h4, p, div, a, i, text, span, nbsp } = require("@saltcorn/markup/tags");
13
16
 
14
17
  /**
15
18
  * @param {string} col
16
19
  * @param {string} lbl
17
20
  * @returns {string}
18
21
  */
19
- const badge = (col, lbl) => `<span class="badge bg-${col}">${lbl}</span>&nbsp;`;
20
22
 
21
23
  /**
22
24
  * Table badges to show in System Table list views
@@ -42,57 +44,90 @@ const valIfSet = (check, value) => (check ? value : "");
42
44
  const listClass = (tagId, showList) =>
43
45
  valIfSet(tagId, `collapse ${valIfSet(showList, "show")}`);
44
46
 
45
- const tablesList = async (tables, req, { tagId, domId, showList } = {}) => {
47
+ const tablesList = async (
48
+ tables,
49
+ req,
50
+ { tagId, domId, showList, filterOnTag } = {}
51
+ ) => {
46
52
  const roles = await User.get_roles();
47
53
  const getRole = (rid) => roles.find((r) => r.id === rid)?.role || "?";
48
- return tables.length > 0
49
- ? mkTable(
50
- [
51
- {
52
- label: req.__("Name"),
53
- key: (r) => link(`/table/${r.id || r.name}`, text(r.name)),
54
- },
55
- {
56
- label: "",
57
- key: (r) => tableBadges(r, req),
58
- },
59
- {
60
- label: req.__("Access Read/Write"),
61
- key: (t) =>
62
- t.external
63
- ? `${getRole(t.min_role_read)} (read only)`
64
- : `${getRole(t.min_role_read)}/${getRole(t.min_role_write)}`,
65
- },
66
- !tagId
67
- ? {
68
- label: req.__("Delete"),
69
- key: (r) =>
70
- r.name === "users" || r.external
71
- ? ""
72
- : post_delete_btn(`/table/delete/${r.id}`, req, r.name),
73
- }
74
- : {
75
- label: req.__("Remove From Tag"),
76
- key: (r) =>
77
- post_delete_btn(
78
- `/tag-entries/remove/tables/${r.id}/${tagId}`,
79
- req,
80
- `${r.name} from this tag`
81
- ),
54
+ const tags = await Tag.find();
55
+ const tag_entries = await TagEntry.find({
56
+ not: { table_id: null },
57
+ });
58
+ const tagsById = {};
59
+ tags.forEach((t) => (tagsById[t.id] = t));
60
+
61
+ const tagBadges = (table) => {
62
+ const myTags = tag_entries.filter((te) => te.table_id === table.id);
63
+ return myTags
64
+ .map((te) => tagBadge(tagsById[te.tag_id], "tables"))
65
+ .join(nbsp);
66
+ };
67
+
68
+ return (
69
+ mkTable(
70
+ [
71
+ {
72
+ label: req.__("Name"),
73
+ key: (r) => link(`/table/${r.id || r.name}`, text(r.name)),
74
+ },
75
+ ...(tagId
76
+ ? []
77
+ : [
78
+ {
79
+ label: tagsDropdown(
80
+ tags,
81
+ filterOnTag ? `Tag:${filterOnTag.name}` : undefined
82
+ ),
83
+ key: (r) => tagBadges(r),
82
84
  },
83
- ],
84
- tables,
85
+ ]),
85
86
  {
86
- hover: true,
87
- tableClass: listClass(tagId, showList),
88
- tableId: domId,
89
- }
90
- )
91
- : div(
92
- { class: listClass(tagId, showList), id: domId },
93
- h4(req.__("No tables defined")),
94
- p(req.__("Tables hold collections of similar data"))
95
- );
87
+ label: "",
88
+ key: (r) => tableBadges(r, req),
89
+ },
90
+
91
+ {
92
+ label: req.__("Access Read/Write"),
93
+ key: (t) =>
94
+ t.external
95
+ ? `${getRole(t.min_role_read)} (read only)`
96
+ : `${getRole(t.min_role_read)}/${getRole(t.min_role_write)}`,
97
+ },
98
+ !tagId
99
+ ? {
100
+ label: req.__("Delete"),
101
+ key: (r) =>
102
+ r.name === "users" || r.external
103
+ ? ""
104
+ : post_delete_btn(`/table/delete/${r.id}`, req, r.name),
105
+ }
106
+ : {
107
+ label: req.__("Remove From Tag"),
108
+ key: (r) =>
109
+ post_delete_btn(
110
+ `/tag-entries/remove/tables/${r.id}/${tagId}`,
111
+ req,
112
+ `${r.name} from this tag`
113
+ ),
114
+ },
115
+ ],
116
+ tables,
117
+ {
118
+ hover: true,
119
+ tableClass: listClass(tagId, showList),
120
+ tableId: domId,
121
+ }
122
+ ) +
123
+ (tables.length == 0 && !filterOnTag
124
+ ? div(
125
+ { class: listClass(tagId, showList), id: domId },
126
+ h4(req.__("No tables defined")),
127
+ p(req.__("Tables hold collections of similar data"))
128
+ )
129
+ : "")
130
+ );
96
131
  };
97
132
 
98
133
  /**
@@ -177,100 +212,176 @@ const setTableRefs = async (views) => {
177
212
  return views;
178
213
  };
179
214
 
215
+ const tagBadge = (tag, type) =>
216
+ a(
217
+ {
218
+ href: `/tag/${tag.id}?show_list=${type}`,
219
+ class: "badge bg-secondary",
220
+ },
221
+ tag.name
222
+ );
223
+
224
+ const tagsDropdown = (tags, altHeader) =>
225
+ div(
226
+ { class: "dropdown" },
227
+ div(
228
+ {
229
+ class: "link-style",
230
+ "data-boundary": "viewport",
231
+ type: "button",
232
+ id: "tagsselector",
233
+ "data-bs-toggle": "dropdown",
234
+ "aria-haspopup": "true",
235
+ "aria-expanded": "false",
236
+ },
237
+ altHeader || "Tags",
238
+ i({ class: "ms-1 fas fa-caret-down" })
239
+ ),
240
+ div(
241
+ {
242
+ class: "dropdown-menu",
243
+ "aria-labelledby": "tagsselector",
244
+ },
245
+ a(
246
+ {
247
+ class: "dropdown-item",
248
+ // TODO check url why view for page, what do we need for page group
249
+ href: `javascript:unset_state_field('_tag', this)`,
250
+ },
251
+ "All tags"
252
+ ),
253
+ tags.map((tag) =>
254
+ a(
255
+ {
256
+ class: "dropdown-item",
257
+ // TODO check url why view for page, what do we need for page group
258
+ href: `javascript:set_state_field('_tag', ${tag.id}, this)`,
259
+ },
260
+ tag.name
261
+ )
262
+ ),
263
+ a(
264
+ {
265
+ class: "dropdown-item",
266
+ // TODO check url why view for page, what do we need for page group
267
+ href: `tag`,
268
+ },
269
+ "Manage tags..."
270
+ )
271
+ )
272
+ );
273
+
180
274
  const viewsList = async (
181
275
  views,
182
276
  req,
183
- { tagId, domId, showList, on_done_redirect, notable } = {}
277
+ { tagId, domId, showList, on_done_redirect, notable, filterOnTag } = {}
184
278
  ) => {
185
279
  const roles = await User.get_roles();
186
280
  const on_done_redirect_str = on_done_redirect
187
281
  ? `?on_done_redirect=${on_done_redirect}`
188
282
  : "";
189
- return views.length > 0
190
- ? mkTable(
191
- [
192
- {
193
- label: req.__("Name"),
194
- key: (r) => link(`/view/${encodeURIComponent(r.name)}`, r.name),
195
- sortlink: !tagId
196
- ? `set_state_field('_sortby', 'name', this)`
197
- : undefined,
198
- },
199
- // description - currently I dont want to show description in view list
200
- // because description can be long
201
- /*
202
- {
203
- label: req.__("Description"),
204
- key: "description",
205
- // this is sorting by column
206
- sortlink: `javascript:set_state_field('_sortby', 'description')`,
207
- },
208
- */
209
- // template
210
- {
211
- label: req.__("Pattern"),
212
- key: "viewtemplate",
213
- sortlink: !tagId
214
- ? `set_state_field('_sortby', 'viewtemplate', this)`
215
- : undefined,
216
- },
217
- ...(notable
218
- ? []
219
- : [
220
- {
221
- label: req.__("Table"),
222
- key: (r) => link(`/table/${r.table}`, r.table),
223
- sortlink: !tagId
224
- ? `set_state_field('_sortby', 'table', this)`
225
- : undefined,
226
- },
227
- ]),
228
- {
229
- label: req.__("Role to access"),
230
- key: (row) =>
231
- row.id
232
- ? editViewRoleForm(row, roles, req, on_done_redirect_str)
233
- : "admin",
234
- },
235
- {
236
- label: "",
237
- key: (r) =>
238
- r.id && r.viewtemplateObj?.configuration_workflow
239
- ? link(
240
- `/viewedit/config/${encodeURIComponent(
241
- r.name
242
- )}${on_done_redirect_str}`,
243
- req.__("Configure")
244
- )
245
- : "",
246
- },
247
- !tagId
248
- ? {
249
- label: "",
250
- key: (r) => view_dropdown(r, req, on_done_redirect_str),
251
- }
252
- : {
253
- label: req.__("Remove From Tag"),
254
- key: (r) =>
255
- post_delete_btn(
256
- `/tag-entries/remove/views/${r.id}/${tagId}`,
257
- req,
258
- `${r.name} from this tag`
259
- ),
283
+ const tags = await Tag.find();
284
+ const tag_entries = await TagEntry.find({
285
+ not: { view_id: null },
286
+ });
287
+ const tagsById = {};
288
+ tags.forEach((t) => (tagsById[t.id] = t));
289
+
290
+ const tagBadges = (view) => {
291
+ const myTags = tag_entries.filter((te) => te.view_id === view.id);
292
+ return myTags
293
+ .map((te) => tagBadge(tagsById[te.tag_id], "views"))
294
+ .join(nbsp);
295
+ };
296
+
297
+ return (
298
+ mkTable(
299
+ [
300
+ {
301
+ label: req.__("Name"),
302
+ key: (r) => link(`/view/${encodeURIComponent(r.name)}`, r.name),
303
+ sortlink: !tagId
304
+ ? `set_state_field('_sortby', 'name', this)`
305
+ : undefined,
306
+ },
307
+ ...(tagId
308
+ ? []
309
+ : [
310
+ {
311
+ label: tagsDropdown(
312
+ tags,
313
+ filterOnTag ? `Tag:${filterOnTag.name}` : undefined
314
+ ),
315
+ key: (r) => tagBadges(r),
260
316
  },
261
- ],
262
- views,
317
+ ]),
263
318
  {
264
- hover: true,
265
- tableClass: listClass(tagId, showList),
266
- tableId: domId,
267
- }
268
- )
269
- : div(
270
- { class: listClass(tagId, showList), id: domId },
271
- h4(req.__("No views defined")),
272
- p(req.__("Views define how table rows are displayed to the user"))
273
- );
319
+ label: req.__("Pattern"),
320
+ key: "viewtemplate",
321
+ sortlink: !tagId
322
+ ? `set_state_field('_sortby', 'viewtemplate', this)`
323
+ : undefined,
324
+ },
325
+ ...(notable
326
+ ? []
327
+ : [
328
+ {
329
+ label: req.__("Table"),
330
+ key: (r) => link(`/table/${r.table}`, r.table),
331
+ sortlink: !tagId
332
+ ? `set_state_field('_sortby', 'table', this)`
333
+ : undefined,
334
+ },
335
+ ]),
336
+ {
337
+ label: req.__("Role to access"),
338
+ key: (row) =>
339
+ row.id
340
+ ? editViewRoleForm(row, roles, req, on_done_redirect_str)
341
+ : "admin",
342
+ },
343
+ {
344
+ label: "",
345
+ key: (r) =>
346
+ r.id && r.viewtemplateObj?.configuration_workflow
347
+ ? link(
348
+ `/viewedit/config/${encodeURIComponent(
349
+ r.name
350
+ )}${on_done_redirect_str}`,
351
+ req.__("Configure")
352
+ )
353
+ : "",
354
+ },
355
+ !tagId
356
+ ? {
357
+ label: "",
358
+ key: (r) => view_dropdown(r, req, on_done_redirect_str),
359
+ }
360
+ : {
361
+ label: req.__("Remove From Tag"),
362
+ key: (r) =>
363
+ post_delete_btn(
364
+ `/tag-entries/remove/views/${r.id}/${tagId}`,
365
+ req,
366
+ `${r.name} from this tag`
367
+ ),
368
+ },
369
+ ],
370
+ views,
371
+ {
372
+ hover: true,
373
+ tableClass: listClass(tagId, showList),
374
+ tableId: domId,
375
+ }
376
+ ) +
377
+ (views.length == 0 && !filterOnTag
378
+ ? div(
379
+ { class: listClass(tagId, showList), id: domId },
380
+ h4(req.__("No views defined")),
381
+ p(req.__("Views define how table rows are displayed to the user"))
382
+ )
383
+ : "")
384
+ );
274
385
  };
275
386
 
276
387
  const page_group_dropdown = (page_group, req) =>
@@ -371,13 +482,42 @@ const editPageRoleForm = (page, roles, req, isGroup) =>
371
482
  * @param {object} req
372
483
  * @returns {div}
373
484
  */
374
- const getPageList = (rows, roles, req, { tagId, domId, showList } = {}) => {
485
+ const getPageList = async (
486
+ rows,
487
+ roles,
488
+ req,
489
+ { tagId, domId, showList, filterOnTag } = {}
490
+ ) => {
491
+ const tags = await Tag.find();
492
+ const tag_entries = await TagEntry.find({
493
+ not: { page_id: null },
494
+ });
495
+ const tagsById = {};
496
+ tags.forEach((t) => (tagsById[t.id] = t));
497
+
498
+ const tagBadges = (page) => {
499
+ const myTags = tag_entries.filter((te) => te.page_id === page.id);
500
+ return myTags
501
+ .map((te) => tagBadge(tagsById[te.tag_id], "pages"))
502
+ .join(nbsp);
503
+ };
375
504
  return mkTable(
376
505
  [
377
506
  {
378
507
  label: req.__("Name"),
379
508
  key: (r) => link(`/page/${r.name}`, r.name),
380
509
  },
510
+ ...(tagId
511
+ ? []
512
+ : [
513
+ {
514
+ label: tagsDropdown(
515
+ tags,
516
+ filterOnTag ? `Tag:${filterOnTag.name}` : undefined
517
+ ),
518
+ key: (r) => tagBadges(r),
519
+ },
520
+ ]),
381
521
  {
382
522
  label: req.__("Role to access"),
383
523
  key: (row) => editPageRoleForm(row, roles, req),
@@ -442,11 +582,40 @@ const getPageGroupList = (rows, roles, req) => {
442
582
  );
443
583
  };
444
584
 
445
- const getTriggerList = (triggers, req, { tagId, domId, showList } = {}) => {
585
+ const getTriggerList = async (
586
+ triggers,
587
+ req,
588
+ { tagId, domId, showList, filterOnTag } = {}
589
+ ) => {
446
590
  const base_url = get_base_url(req);
591
+ const tags = await Tag.find();
592
+
593
+ const tag_entries = await TagEntry.find({
594
+ not: { trigger_id: null },
595
+ });
596
+ const tagsById = {};
597
+ tags.forEach((t) => (tagsById[t.id] = t));
598
+
599
+ const tagBadges = (trigger) => {
600
+ const myTags = tag_entries.filter((te) => te.trigger_id === trigger.id);
601
+ return myTags
602
+ .map((te) => tagBadge(tagsById[te.tag_id], "triggers"))
603
+ .join(nbsp);
604
+ };
447
605
  return mkTable(
448
606
  [
449
607
  { label: req.__("Name"), key: "name" },
608
+ ...(tagId
609
+ ? []
610
+ : [
611
+ {
612
+ label: tagsDropdown(
613
+ tags,
614
+ filterOnTag ? `Tag:${filterOnTag.name}` : undefined
615
+ ),
616
+ key: (r) => tagBadges(r),
617
+ },
618
+ ]),
450
619
  { label: req.__("Action"), key: "action" },
451
620
  {
452
621
  label: req.__("Table or Channel"),
@@ -21,6 +21,8 @@ const { getViews, traverseSync } = require("@saltcorn/data/models/layout");
21
21
  const { add_to_menu } = require("@saltcorn/admin-models/models/pack");
22
22
  const db = require("@saltcorn/data/db");
23
23
  const { getPageList, getPageGroupList } = require("./common_lists");
24
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
25
+ const Tag = require("@saltcorn/data/models/tag");
24
26
 
25
27
  const {
26
28
  isAdmin,
@@ -293,7 +295,18 @@ router.get(
293
295
  "/",
294
296
  isAdmin,
295
297
  error_catcher(async (req, res) => {
296
- const pages = await Page.find({}, { orderBy: "name", nocase: true });
298
+ const pageq = {};
299
+ let filterOnTag;
300
+
301
+ if (req.query._tag) {
302
+ const tagEntries = await TagEntry.find({
303
+ tag_id: +req.query._tag,
304
+ not: { page_id: null },
305
+ });
306
+ pageq.id = { in: tagEntries.map((te) => te.page_id).filter(Boolean) };
307
+ filterOnTag = await Tag.findOne({ id: +req.query._tag });
308
+ }
309
+ const pages = await Page.find(pageq, { orderBy: "name", nocase: true });
297
310
  const pageGroups = await PageGroup.find(
298
311
  {},
299
312
  { orderBy: "name", nocase: true }
@@ -311,7 +324,7 @@ router.get(
311
324
  title: req.__("Your pages"),
312
325
  class: "mt-0",
313
326
  contents: div(
314
- getPageList(pages, roles, req),
327
+ await getPageList(pages, roles, req, { filterOnTag }),
315
328
  a(
316
329
  {
317
330
  href: `/pageedit/new`,
@@ -677,9 +690,11 @@ router.post(
677
690
 
678
691
  if (id && req.body.layout) {
679
692
  await Page.update(+id, { layout: req.body.layout });
680
- res.json({ success: "ok" });
693
+ res.json({
694
+ success: "ok",
695
+ });
681
696
  } else {
682
- res.json({ error: "no page or no layout." });
697
+ res.json({ error: req.__("Unable to save: No page or no layout") });
683
698
  }
684
699
  })
685
700
  );