@saltcorn/server 1.1.1-beta.7 → 1.1.1-rc.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## 1.1.1 - In beta
4
4
 
5
+ * select_by_view fieldview for Key fields: the user selects the value of a
6
+ Key field based on an clicking in a row of rendered views (typically a Show view) of the joined table. Works for both Edit and Filter views.
7
+
8
+ * Click to edit (Show and List view patterns) is now implemented by rendering
9
+ the first available edit fieldview. This should be more robust and work with
10
+ more data types.
11
+
12
+ * Data in the admin's data edit grid is now loaded by page. This makes it
13
+ possible to work with much larger datasets.
14
+
5
15
  * You can now permit to non-admin (role ID > 1) users to edit or inspect tables, or
6
16
  edit views, pages or triggers. In the permissions tab of the Users and security
7
17
  settings, minimum roles can be set for these capabilities. The appropriate
@@ -35,12 +45,23 @@
35
45
  is changed.
36
46
 
37
47
  * Mobile builder:
38
- - PJAX view loading.
48
+ - PJAX view loading: Use pjax for all functions like on the web version.
49
+ - Share content to your app on mobile and PWA.
50
+ - Ensure at least one ReceiveMobileShareData trigger exists when the app is built or the PWA is installed.
51
+ - Shared content is accessible via the row variable.
52
+ - Android: No additional configuration is needed.
53
+ - PWA: Ensure a trusted HTTPS connection is used.
54
+ - iOS:
55
+ - A second provisioning profile is required, with the bundle ID of the main app followed by share-ext (e.g., com.saltcorn.share-ext).
56
+ - The iOS project needs a Share Extension target. To set this up, open Xcode and add a Share Extension target from a template (more documentation is is about to come).
57
+ - The build will stop when the Xcode integration is required, and a "Finish the Build" shows up.
39
58
 
40
59
  ### Fixes
41
60
 
61
+ * Increase plugin install reliability
42
62
  * fix workflows on SQLite
43
63
  * fix query string build on check_state_field (#2948). Author: St0rml
64
+ * multiple fixes for the Capacitor port
44
65
 
45
66
  ### Translations
46
67
 
@@ -71,6 +92,11 @@
71
92
 
72
93
  * Webhook action has more options: method, set reponse value, headers.
73
94
 
95
+ * Mobile builder:
96
+ - Ported from Cordova to Capacitor: Cordova's core functionalities and plugins are well-maintained, but for some time now, the trend for mobile application development goes new directions. Capacitor aims to be a drop-in replacement with a more modern approach and an active Community. Existing Cordova plugins do still work, and plugins from the Capacitor ecosystem are available as well. This should make the mobile app development more future-proof.
97
+ - Screen orientation change handling: A Saltcorn plugin can register a listener for screen orientation changes (Landscape / Portrait modes). For an example, take a look at the [metronic-theme](https://github.com/saltcorn/metronic-theme/blob/35b69ba7b4e94e2bcfe2f1c61508bc579c1d914f/index.js#L844). It registers a listener to adjust the mobile bottom navigation bar when the phone rotates.
98
+ - PJAX view loading: Changed the full reload to pjax for sortby and gopage (paging). The remainig set_state calls are still full reloads.
99
+
74
100
  ### Security
75
101
 
76
102
  - SameSite cookie settings
package/auth/admin.js CHANGED
@@ -422,6 +422,7 @@ const permissions_settings_form = async (req) =>
422
422
  "min_role_edit_views",
423
423
  "min_role_edit_pages",
424
424
  "min_role_edit_triggers",
425
+ "min_role_edit_menu",
425
426
  //hidden "exttables_min_role_read",
426
427
  ],
427
428
  action: "/useradmin/permissions",
package/locales/en.json CHANGED
@@ -1543,5 +1543,7 @@
1543
1543
  "Minimum role to inspect (see, without editing) tables": "Minimum role to inspect (see, without editing) tables",
1544
1544
  "Home pages": "Home pages",
1545
1545
  "The home page is the page that is served when the user visits the home location (/). This can be set for each user role.": "The home page is the page that is served when the user visits the home location (/). This can be set for each user role.",
1546
- "Trigger %s deleted": "Trigger %s deleted"
1546
+ "Trigger %s deleted": "Trigger %s deleted",
1547
+ "Edit menu": "Edit menu",
1548
+ "Minimum role to edit menu": "Minimum role to edit menu"
1547
1549
  }
package/markup/admin.js CHANGED
@@ -224,24 +224,29 @@ const send_infoarch_page = (args) => {
224
224
  const tenant_list =
225
225
  db.is_it_multi_tenant() &&
226
226
  db.getTenantSchema() === db.connectObj.default_schema;
227
+ const isUserAdmin = args.req?.user.role_id === 1;
227
228
  return send_settings_page({
228
229
  main_section: "Site structure",
229
230
  main_section_href: "/site-structure",
230
231
  sub_sections: [
231
232
  { text: "Menu", href: "/menu" },
232
- { text: "Search", href: "/search/config" },
233
- { text: "Library", href: "/library/list" },
234
- { text: "Languages", href: "/site-structure/localizer" },
235
- ...(tenant_list
233
+ ...(isUserAdmin
236
234
  ? [
237
- { text: "Tenants", href: "/tenant/list" },
238
- { text: "Multitenancy", href: "/tenant/settings" },
235
+ { text: "Search", href: "/search/config" },
236
+ { text: "Library", href: "/library/list" },
237
+ { text: "Languages", href: "/site-structure/localizer" },
238
+ ...(tenant_list
239
+ ? [
240
+ { text: "Tenants", href: "/tenant/list" },
241
+ { text: "Multitenancy", href: "/tenant/settings" },
242
+ ]
243
+ : []),
244
+ { text: "Pagegroups", href: "/page_group/settings" },
245
+ { text: "Tags", href: "/tag" },
246
+ { text: "Diagram", href: "/diagram" },
247
+ { text: "Registry editor", href: "/registry-editor" },
239
248
  ]
240
249
  : []),
241
- { text: "Pagegroups", href: "/page_group/settings" },
242
- { text: "Tags", href: "/tag" },
243
- { text: "Diagram", href: "/diagram" },
244
- { text: "Registry editor", href: "/registry-editor" },
245
250
  ],
246
251
  ...args,
247
252
  });
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "1.1.1-beta.7",
3
+ "version": "1.1.1-rc.1",
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": "1.1.1-beta.7",
11
- "@saltcorn/builder": "1.1.1-beta.7",
12
- "@saltcorn/data": "1.1.1-beta.7",
13
- "@saltcorn/admin-models": "1.1.1-beta.7",
14
- "@saltcorn/filemanager": "1.1.1-beta.7",
15
- "@saltcorn/markup": "1.1.1-beta.7",
16
- "@saltcorn/plugins-loader": "1.1.1-beta.7",
17
- "@saltcorn/sbadmin2": "1.1.1-beta.7",
10
+ "@saltcorn/base-plugin": "1.1.1-rc.1",
11
+ "@saltcorn/builder": "1.1.1-rc.1",
12
+ "@saltcorn/data": "1.1.1-rc.1",
13
+ "@saltcorn/admin-models": "1.1.1-rc.1",
14
+ "@saltcorn/filemanager": "1.1.1-rc.1",
15
+ "@saltcorn/markup": "1.1.1-rc.1",
16
+ "@saltcorn/plugins-loader": "1.1.1-rc.1",
17
+ "@saltcorn/sbadmin2": "1.1.1-rc.1",
18
18
  "@socket.io/cluster-adapter": "^0.2.1",
19
19
  "@socket.io/sticky": "^1.0.1",
20
20
  "adm-zip": "0.5.10",
@@ -965,6 +965,38 @@ function initialize_page() {
965
965
  var current =
966
966
  $(this).attr("data-inline-edit-current") ||
967
967
  $(this).children("span.current").html();
968
+ const resetHtml = this.outerHTML;
969
+
970
+ let fielddata = $(this).attr("data-inline-edit-fielddata");
971
+ if (fielddata) {
972
+ //fetch edit
973
+ $.ajax(`/field/edit-get-fieldview`, {
974
+ type: "POST",
975
+ headers: {
976
+ "CSRF-Token": _sc_globalCsrf,
977
+ },
978
+ contentType: "application/json",
979
+ data: decodeURIComponent(fielddata),
980
+ }).then((resp) => {
981
+ const opts = encodeURIComponent(
982
+ JSON.stringify({
983
+ resetHtml,
984
+ })
985
+ );
986
+ $(this).replaceWith(
987
+ `<form method="post" action="/field/save-click-edit" onsubmit="inline_ajax_submit_with_fielddata(event, '${opts}')"
988
+ <input type="hidden" name="_csrf" value="${_sc_globalCsrf}">
989
+ <input type="hidden" name="_fielddata" value="${fielddata}">
990
+ <div class="input-group">
991
+ ${resp}
992
+ <button type="submit" class="btn btn-sm btn-primary">OK</button>
993
+ <button onclick="cancel_inline_edit(event, '${opts}')" type="button" class="btn btn-sm btn-danger"><i class="fas fa-times"></i></button>
994
+ </div>
995
+ </form>`
996
+ );
997
+ });
998
+ return;
999
+ }
968
1000
  var key = $(this).attr("data-inline-edit-field") || "value";
969
1001
  var ajax = !!$(this).attr("data-inline-edit-ajax");
970
1002
  var type = $(this).attr("data-inline-edit-type");
@@ -984,7 +1016,6 @@ function initialize_page() {
984
1016
  current = current === "true";
985
1017
  }
986
1018
  var is_key = type?.startsWith("Key:");
987
- const resetHtml = this.outerHTML;
988
1019
  const opts = encodeURIComponent(
989
1020
  JSON.stringify({
990
1021
  url,
@@ -1267,6 +1298,37 @@ function inline_submit_success(e, form, opts) {
1267
1298
  } else location.reload();
1268
1299
  }
1269
1300
 
1301
+ function inline_ajax_submit_with_fielddata(e, opts1) {
1302
+ var opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
1303
+ e.preventDefault();
1304
+
1305
+ var form = $(e.target).closest("form");
1306
+ var form_data = form.serialize();
1307
+ var url = form.attr("action");
1308
+ if (opts.type === "Bool" && !form_data.includes(`${opts.key}=on`)) {
1309
+ form_data += `&${opts.key}=off`;
1310
+ }
1311
+ $.ajax(url, {
1312
+ type: "POST",
1313
+ headers: {
1314
+ "CSRF-Token": _sc_globalCsrf,
1315
+ },
1316
+ data: form_data,
1317
+ success: function (res) {
1318
+ var opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
1319
+ var form = $(e.target).closest("form");
1320
+ form.replaceWith(res);
1321
+ initialize_page();
1322
+ },
1323
+ error: function (e) {
1324
+ if (!checkNetworkError(e))
1325
+ ajax_done(
1326
+ e.responseJSON || { error: "Unknown error: " + e.responseText }
1327
+ );
1328
+ },
1329
+ });
1330
+ }
1331
+
1270
1332
  function inline_ajax_submit(e, opts1) {
1271
1333
  var opts = JSON.parse(decodeURIComponent(opts1 || "") || "{}");
1272
1334
  e.preventDefault();
@@ -2032,6 +2094,28 @@ function update_time_of_week(nm) {
2032
2094
  };
2033
2095
  }
2034
2096
 
2097
+ function select_by_view_click(element, event, required) {
2098
+ const isAlreadySelected = $(element).hasClass("selected");
2099
+ $(element)
2100
+ .closest(".select-by-view-container")
2101
+ .find(".select-by-view-option")
2102
+ .removeClass("selected");
2103
+ if (!required && isAlreadySelected) {
2104
+ $(element)
2105
+ .closest(".select-by-view-container")
2106
+ .find("input[type=hidden]")
2107
+ .val("")
2108
+ .trigger("change");
2109
+ } else {
2110
+ $(element).addClass("selected");
2111
+ $(element)
2112
+ .closest(".select-by-view-container")
2113
+ .find("input[type=hidden]")
2114
+ .val($(element).attr("data-id"))
2115
+ .trigger("change");
2116
+ }
2117
+ }
2118
+
2035
2119
  const observer = new IntersectionObserver(
2036
2120
  (entries, observer) => {
2037
2121
  entries.forEach((entry) => {
@@ -773,3 +773,42 @@ i[class*=" unicode-"] {
773
773
  [data-animate-initial-hide] {
774
774
  opacity: 0;
775
775
  }
776
+
777
+ div.select-by-view-container {
778
+ display: flex;
779
+ }
780
+ div.select-by-view-container.justify-start {
781
+ justify-content: flex-start;
782
+ }
783
+ div.select-by-view-container.justify-end {
784
+ justify-content: flex-end;
785
+ }
786
+ div.select-by-view-container.justify-center {
787
+ justify-content: center;
788
+ }
789
+ div.select-by-view-container.justify-between {
790
+ justify-content: space-between;
791
+ }
792
+ div.select-by-view-container.justify-around {
793
+ justify-content: space-around;
794
+ }
795
+ div.select-by-view-container.justify-evenly {
796
+ justify-content: space-evenly;
797
+ }
798
+
799
+ div.select-by-view-option.no-card {
800
+ border: 1px solid var(--bs-secondary, var(--tblr-secondary, blue));
801
+ }
802
+ div.select-by-view-option:hover {
803
+ border: 1px solid var(--bs-primary, var(--tblr-primary, blue));
804
+ }
805
+ div.select-by-view-option.selected {
806
+ border: 2px solid var(--bs-primary, var(--tblr-primary, blue));
807
+ }
808
+ tr span.add-tag {
809
+ opacity: 0;
810
+ }
811
+
812
+ tr:hover span.add-tag {
813
+ opacity: 1;
814
+ }
@@ -34,7 +34,10 @@ function updateQueryStringParameter(uri1, key, value) {
34
34
  uri = uris[0];
35
35
  }
36
36
 
37
- var re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
37
+ var re = new RegExp(
38
+ "([?&])" + escapeRegExp(key) + "=.*?(&|$)",
39
+ "i"
40
+ );
38
41
  var separator = uri.indexOf("?") !== -1 ? "&" : "?";
39
42
  if (uri.match(re)) {
40
43
  if (Array.isArray(value)) {
@@ -75,9 +78,12 @@ function removeQueryStringParameter(uri1, key, value) {
75
78
  }
76
79
  let re;
77
80
  if (value) {
78
- re = new RegExp("([?&])" + key + "=" + value + "?(&|$)", "gi");
81
+ re = new RegExp(
82
+ "([?&])" + escapeRegExp(key) + "=" + value + "?(&|$)",
83
+ "gi"
84
+ );
79
85
  } else {
80
- re = new RegExp("([?&])" + key + "=.*?(&|$)", "gi");
86
+ re = new RegExp("([?&])" + escapeRegExp(key) + "=.*?(&|$)", "gi");
81
87
  }
82
88
  if (uri.match(re)) {
83
89
  uri = uri.replace(re, "$1" + "$2");
@@ -88,6 +94,10 @@ function removeQueryStringParameter(uri1, key, value) {
88
94
  return uri + hash;
89
95
  }
90
96
 
97
+ function escapeRegExp(string) {
98
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
100
+
91
101
  function addQueryStringParameter(uri1, key, value) {
92
102
  let hash = "";
93
103
  let uri = uri1;
@@ -96,7 +106,10 @@ function addQueryStringParameter(uri1, key, value) {
96
106
  hash = "#" + uris[1];
97
107
  uri = uris[0];
98
108
  }
99
- var re = new RegExp("([?&])" + key + "=" + value + "?(&|$)", "gi");
109
+ var re = new RegExp(
110
+ "([?&])" + escapeRegExp(key) + "=" + value + "?(&|$)",
111
+ "gi"
112
+ );
100
113
  if (uri.match(re)) return uri1;
101
114
 
102
115
  var separator = uri.indexOf("?") !== -1 ? "&" : "?";
package/routes/actions.js CHANGED
@@ -731,6 +731,7 @@ const getWorkflowStepForm = async (
731
731
  },
732
732
  };
733
733
  if (cfgFld.input_type === "code") cfgFld.input_type = "textarea";
734
+ actionConfigFields.push(cfgFld)
734
735
  }
735
736
  } catch {}
736
737
  }
package/routes/admin.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  get_sys_info,
15
15
  tenant_letsencrypt_name,
16
16
  isAdminOrHasConfigMinRole,
17
+ checkEditPermission,
17
18
  } = require("./utils.js");
18
19
  const Table = require("@saltcorn/data/models/table");
19
20
  const Plugin = require("@saltcorn/data/models/plugin");
@@ -567,7 +568,11 @@ router.get(
567
568
  error_catcher(async (req, res) => {
568
569
  const snaps = await Snapshot.find(
569
570
  {},
570
- { orderBy: "created", orderDesc: true, fields: ["id", "created", "hash"] }
571
+ {
572
+ orderBy: "created",
573
+ orderDesc: true,
574
+ fields: ["id", "created", "hash", "name"],
575
+ }
571
576
  );
572
577
  const locale = getState().getConfig("default_locale", "en");
573
578
  send_admin_page({
@@ -595,7 +600,9 @@ router.get(
595
600
  snap.created,
596
601
  {},
597
602
  locale
598
- )} (${moment(snap.created).fromNow()})`
603
+ )} (${moment(snap.created).fromNow()})${
604
+ snap.name ? ` [${snap.name}]` : ""
605
+ }`
599
606
  )
600
607
  )
601
608
  )
@@ -627,20 +634,6 @@ router.get(
627
634
  })
628
635
  );
629
636
 
630
- const checkEditPermission = (type, user) => {
631
- if (user.role_id === 1) return true;
632
- switch (type) {
633
- case "view":
634
- return getState().getConfig("min_role_edit_views", 1) >= user.role_id;
635
- case "page":
636
- return getState().getConfig("min_role_edit_pages", 1) >= user.role_id;
637
- case "trigger":
638
- return getState().getConfig("min_role_edit_triggers", 1) >= user.role_id;
639
- default:
640
- return false;
641
- }
642
- };
643
-
644
637
  router.get(
645
638
  "/snapshot-restore/:type/:name",
646
639
  isAdminOrHasConfigMinRole([
@@ -652,7 +645,7 @@ router.get(
652
645
  const { type, name } = req.params;
653
646
  const snaps = await Snapshot.entity_history(type, name);
654
647
  const locale = getState().getConfig("default_locale", "en");
655
- const auth = checkEditPermission(type, req.user);
648
+ const auth = checkEditPermission(type + "s", req.user);
656
649
  if (!auth) {
657
650
  res.send("Not authorized");
658
651
  return;
@@ -664,11 +657,14 @@ router.get(
664
657
  {
665
658
  label: req.__("When"),
666
659
  key: (r) =>
667
- `${localeDateTime(r.created, {}, locale)} (${moment(
660
+ `${moment(
668
661
  r.created
669
- ).fromNow()})`,
662
+ ).fromNow()}<br><small>${localeDateTime(r.created, {}, locale)}</small>`,
663
+ },
664
+ {
665
+ label: req.__("Name"),
666
+ key: (r) => r.name || "",
670
667
  },
671
-
672
668
  {
673
669
  label: req.__("Restore"),
674
670
  key: (r) =>
@@ -694,7 +690,7 @@ router.post(
694
690
  ]),
695
691
  error_catcher(async (req, res) => {
696
692
  const { type, name, id } = req.params;
697
- const auth = checkEditPermission(type, req.user);
693
+ const auth = checkEditPermission(type + "s", req.user);
698
694
  if (!auth) {
699
695
  req.flash("error", "Not authorized");
700
696
  } else {
@@ -963,7 +959,8 @@ const snapshotForm = (req) =>
963
959
  label: req.__("Snapshot now"),
964
960
  id: "btnSnapNow",
965
961
  class: "btn btn-outline-secondary",
966
- onclick: "ajax_post('/admin/snapshot-now')",
962
+ onclick:
963
+ "ajax_post('/admin/snapshot-now/'+prompt('Name of snapshot (optional)'))",
967
964
  },
968
965
  ],
969
966
  fields: [
@@ -1075,11 +1072,18 @@ router.post(
1075
1072
  * Do Snapshot now
1076
1073
  */
1077
1074
  router.post(
1078
- "/snapshot-now",
1075
+ "/snapshot-now/:snapshotname?",
1079
1076
  isAdmin,
1080
1077
  error_catcher(async (req, res) => {
1078
+ const { snapshotname } = req.params;
1079
+ if (snapshotname == "null") {
1080
+ //user clicked cancel on prompt
1081
+ res.json({ success: true });
1082
+ return;
1083
+ }
1084
+
1081
1085
  try {
1082
- const taken = await Snapshot.take_if_changed();
1086
+ const taken = await Snapshot.take_if_changed(snapshotname);
1083
1087
  if (taken) req.flash("success", req.__("Snapshot successful"));
1084
1088
  else
1085
1089
  req.flash("success", req.__("No changes detected, snapshot skipped"));
@@ -1574,7 +1578,7 @@ const doInstall = async (req, res, version, deepClean, runPull) => {
1574
1578
  }
1575
1579
  const child = spawn(
1576
1580
  "npm",
1577
- ["install", "-g", `@saltcorn/cli@${version}`, "--unsafe"],
1581
+ ["install", "-g", `@saltcorn/cli@${version}`, "--omit=dev"],
1578
1582
  {
1579
1583
  stdio: ["ignore", "pipe", "pipe"],
1580
1584
  }
package/routes/api.js CHANGED
@@ -279,14 +279,58 @@ router.get(
279
279
  * @function
280
280
  * @memberof module:routes/api~apiRouter
281
281
  */
282
- // todo add paging
282
+
283
+ function validateNumberMin(value, min) {
284
+ if (typeof value !== "number") {
285
+ // return false; //throw new TypeError('Value is not a number');
286
+ value = strictParseInt(value);
287
+ }
288
+
289
+ if (!Number.isSafeInteger(value)) {
290
+ return false; //throw new RangeError('Value is outside the valid range for an integer');
291
+ }
292
+ if (value < min) return false;
293
+ return true;
294
+ }
295
+
283
296
  router.get(
284
297
  "/:tableName/",
285
298
  //passport.authenticate("api-bearer", { session: false }),
286
299
  error_catcher(async (req, res, next) => {
287
300
  let { tableName } = req.params;
288
- const { fields, versioncount, approximate, dereference, ...req_query } =
289
- req.query;
301
+ const {
302
+ fields,
303
+ versioncount,
304
+ limit,
305
+ offset,
306
+ sortBy,
307
+ sortDesc,
308
+ approximate,
309
+ dereference,
310
+ tabulator_pagination_format,
311
+ ...req_query0
312
+ } = req.query;
313
+
314
+ let req_query = req_query0;
315
+ let tabulator_size, tabulator_page, tabulator_sort, tabulator_dir;
316
+ if (tabulator_pagination_format) {
317
+ const { page, size, sort, ...rq } = req_query0;
318
+ req_query = rq;
319
+ tabulator_page = page;
320
+ tabulator_size = size;
321
+ tabulator_sort = sort?.[0]?.field;
322
+ tabulator_dir = sort?.[0]?.dir;
323
+ }
324
+ if (typeof limit !== "undefined")
325
+ if (isNaN(limit) || !validateNumberMin(limit, 1)) {
326
+ getState().log(3, `API get ${tableName} Invalid limit parameter`);
327
+ return res.status(400).send({ error: "Invalid limit parameter" });
328
+ }
329
+ if (typeof offset !== "undefined")
330
+ if (isNaN(offset) || !validateNumberMin(offset, 1)) {
331
+ getState().log(3, `API get ${tableName} Invalid offset parameter`);
332
+ return res.status(400).send({ error: "Invalid offset parameter" });
333
+ }
290
334
  const table = Table.findOne(
291
335
  strictParseInt(tableName)
292
336
  ? { id: strictParseInt(tableName) }
@@ -303,6 +347,8 @@ router.get(
303
347
  res.status(404).json({ error: req.__("Not found") });
304
348
  return;
305
349
  }
350
+ const orderByField =
351
+ (sortBy || tabulator_sort) && table.getField(sortBy || tabulator_sort);
306
352
 
307
353
  await passport.authenticate(
308
354
  ["api-bearer", "jwt"],
@@ -312,9 +358,17 @@ router.get(
312
358
  let rows;
313
359
  if (versioncount === "on") {
314
360
  const joinOpts = {
315
- orderBy: "id",
316
361
  forUser: req.user || user || { role_id: 100 },
317
362
  forPublic: !(req.user || user),
363
+ limit: tabulator_pagination_format
364
+ ? +tabulator_size
365
+ : limit && +limit,
366
+ offset: tabulator_pagination_format
367
+ ? +tabulator_size * (+tabulator_page - 1)
368
+ : offset && +offset,
369
+ orderDesc:
370
+ (sortDesc && sortDesc !== "false") || tabulator_dir == "desc",
371
+ orderBy: orderByField?.name || "id",
318
372
  aggregations: {
319
373
  _versions: {
320
374
  table: table.name + "__history",
@@ -352,11 +406,20 @@ router.get(
352
406
  rows = await table.getJoinedRows({
353
407
  where: qstate,
354
408
  joinFields,
409
+ limit: limit && +limit,
410
+ offset: offset && +offset,
411
+ orderDesc: sortDesc && sortDesc !== "false",
412
+ orderBy: orderByField?.name || undefined,
355
413
  forPublic: !(req.user || user),
356
414
  forUser: req.user || user,
357
415
  });
358
416
  }
359
- res.json({ success: rows.map(limitFields(fields)) });
417
+ if (tabulator_pagination_format) {
418
+ res.json({
419
+ last_page: Math.ceil((await table.countRows()) / +tabulator_size),
420
+ data: rows.map(limitFields(fields)),
421
+ });
422
+ } else res.json({ success: rows.map(limitFields(fields)) });
360
423
  } else {
361
424
  getState().log(3, `API get ${table.name} not authorized`);
362
425
  res.status(401).json({ error: req.__("Not authorized") });
@@ -13,7 +13,17 @@ const {
13
13
  } = require("@saltcorn/markup");
14
14
  const { get_base_url } = require("./utils.js");
15
15
  const { getState } = require("@saltcorn/data/db/state");
16
- const { h4, p, div, a, i, text, span, nbsp } = require("@saltcorn/markup/tags");
16
+ const {
17
+ h4,
18
+ p,
19
+ div,
20
+ a,
21
+ i,
22
+ text,
23
+ span,
24
+ nbsp,
25
+ button,
26
+ } = require("@saltcorn/markup/tags");
17
27
 
18
28
  /**
19
29
  * @param {string} col
@@ -63,9 +73,11 @@ const tablesList = async (
63
73
  getState().getConfig("min_role_edit_tables", 1) >= req.user.role_id;
64
74
  const tagBadges = (table) => {
65
75
  const myTags = tag_entries.filter((te) => te.table_id === table.id);
66
- return myTags
67
- .map((te) => tagBadge(tagsById[te.tag_id], "tables"))
68
- .join(nbsp);
76
+ const myTagIds = new Set(myTags.map((t) => t.tag_id));
77
+ return (
78
+ myTags.map((te) => tagBadge(tagsById[te.tag_id], "tables")).join(nbsp) +
79
+ mkAddBtn(tags, "tables", table.id, req, myTagIds)
80
+ );
69
81
  };
70
82
 
71
83
  return (
@@ -278,6 +290,38 @@ const tagsDropdown = (tags, altHeader) =>
278
290
  )
279
291
  );
280
292
 
293
+ const mkAddBtn = (tags, entityType, id, req, myTagIds) =>
294
+ div(
295
+ { class: "dropdown d-inline ms-1" },
296
+ span(
297
+ {
298
+ class: "badge bg-secondary add-tag",
299
+ "data-bs-toggle": "dropdown",
300
+ "aria-haspopup": "true",
301
+ "aria-expanded": "false",
302
+ "data-boundary": "viewport",
303
+ },
304
+ i({ class: "fas fa-plus fa-sm" })
305
+ ),
306
+ div(
307
+ {
308
+ class: "dropdown-menu dropdown-menu-end",
309
+ },
310
+
311
+ tags
312
+ .filter((t) => !myTagIds.has(t.id))
313
+ .map((t) =>
314
+ post_dropdown_item(
315
+ `/tag-entries/add-tag-entity/${encodeURIComponent(
316
+ t.name
317
+ )}/${entityType}/${id}`,
318
+ t.name,
319
+ req
320
+ )
321
+ )
322
+ )
323
+ );
324
+
281
325
  const viewsList = async (
282
326
  views,
283
327
  req,
@@ -300,9 +344,12 @@ const viewsList = async (
300
344
 
301
345
  const tagBadges = (view) => {
302
346
  const myTags = tag_entries.filter((te) => te.view_id === view.id);
303
- return myTags
304
- .map((te) => tagBadge(tagsById[te.tag_id], "views"))
305
- .join(nbsp);
347
+ const myTagIds = new Set(myTags.map((t) => t.tag_id));
348
+ const addBtn = mkAddBtn(tags, "views", view.id, req, myTagIds);
349
+ return (
350
+ myTags.map((te) => tagBadge(tagsById[te.tag_id], "views")).join(nbsp) +
351
+ addBtn
352
+ );
306
353
  };
307
354
 
308
355
  return (
@@ -504,9 +551,11 @@ const getPageList = async (
504
551
 
505
552
  const tagBadges = (page) => {
506
553
  const myTags = tag_entries.filter((te) => te.page_id === page.id);
507
- return myTags
508
- .map((te) => tagBadge(tagsById[te.tag_id], "pages"))
509
- .join(nbsp);
554
+ const myTagIds = new Set(myTags.map((t) => t.tag_id));
555
+ return (
556
+ myTags.map((te) => tagBadge(tagsById[te.tag_id], "pages")).join(nbsp) +
557
+ mkAddBtn(tags, "pages", page.id, req, myTagIds)
558
+ );
510
559
  };
511
560
  return mkTable(
512
561
  [
@@ -602,7 +651,7 @@ const getPageGroupList = (rows, roles, req) => {
602
651
  );
603
652
  };
604
653
 
605
- const trigger_dropdown = (trigger, req, on_done_redirect_str = "") =>
654
+ const trigger_dropdown = (trigger, req, on_done_redirect_str = "") =>
606
655
  settingsDropdown(`dropdownMenuButton${trigger.id}`, [
607
656
  a(
608
657
  {
@@ -643,8 +692,8 @@ const getTriggerList = async (
643
692
  const base_url = get_base_url(req);
644
693
  const tags = await Tag.find();
645
694
  const on_done_redirect_str = on_done_redirect
646
- ? `?on_done_redirect=${on_done_redirect}`
647
- : "";
695
+ ? `?on_done_redirect=${on_done_redirect}`
696
+ : "";
648
697
  const tag_entries = await TagEntry.find({
649
698
  not: { trigger_id: null },
650
699
  });
@@ -657,9 +706,11 @@ const getTriggerList = async (
657
706
 
658
707
  const tagBadges = (trigger) => {
659
708
  const myTags = tag_entries.filter((te) => te.trigger_id === trigger.id);
660
- return myTags
661
- .map((te) => tagBadge(tagsById[te.tag_id], "triggers"))
662
- .join(nbsp);
709
+ const myTagIds = new Set(myTags.map((t) => t.tag_id));
710
+ return (
711
+ myTags.map((te) => tagBadge(tagsById[te.tag_id], "triggers")).join(nbsp) +
712
+ mkAddBtn(tags, "triggers", trigger.id, req, myTagIds)
713
+ );
663
714
  };
664
715
  return mkTable(
665
716
  [
package/routes/fields.js CHANGED
@@ -37,8 +37,8 @@ const {
37
37
  } = require("@saltcorn/data/plugin-helper");
38
38
  const { wizardCardTitle } = require("../markup/forms.js");
39
39
  const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
40
- const { applyAsync } = require("@saltcorn/data/utils");
41
- const { text } = require("@saltcorn/markup/tags");
40
+ const { applyAsync, isWeb } = require("@saltcorn/data/utils");
41
+ const { text, div } = require("@saltcorn/markup/tags");
42
42
  const { mkFormContentNoLayout } = require("@saltcorn/markup/form");
43
43
 
44
44
  /**
@@ -1301,6 +1301,19 @@ router.post(
1301
1301
  // - disabled inputs do not dispactch click events
1302
1302
  const firefox = true;
1303
1303
  const fv = fieldviews[fieldview];
1304
+ field.fieldview === fieldview;
1305
+ field.fieldviewObj = fv;
1306
+ field.attributes = { ...configuration, ...field.attributes };
1307
+ if (field.type === "Key")
1308
+ await field.fill_fkey_options(
1309
+ false,
1310
+ {},
1311
+ {},
1312
+ undefined,
1313
+ undefined,
1314
+ undefined,
1315
+ req.user
1316
+ );
1304
1317
  if (!fv && field.type === "Key" && fieldview === "select")
1305
1318
  res.send(
1306
1319
  `<input ${
@@ -1422,3 +1435,101 @@ router.post(
1422
1435
  res.send(mkFormContentNoLayout(form));
1423
1436
  })
1424
1437
  );
1438
+
1439
+ router.post(
1440
+ "/edit-get-fieldview",
1441
+ error_catcher(async (req, res) => {
1442
+ const { field_name, table_name, pk, fieldview, configuration } = req.body;
1443
+ const table = Table.findOne({ name: table_name });
1444
+ const row = await table.getRow(
1445
+ { [table.pk_name]: pk },
1446
+ { forUser: req.user, forPublic: !req.user }
1447
+ );
1448
+ const field = table.getField(field_name);
1449
+ let fv;
1450
+ if (field.is_fkey) {
1451
+ await field.fill_fkey_options(
1452
+ false,
1453
+ undefined,
1454
+ undefined,
1455
+ undefined,
1456
+ undefined,
1457
+ row[field_name],
1458
+ req.user
1459
+ );
1460
+ fv = getState().keyFieldviews.select;
1461
+ } else if (fieldview === "subfield" && field.type?.name === "JSON") {
1462
+ fv = field.type.fieldviews.edit_subfield;
1463
+ } else {
1464
+ //TODO: json subfield is special
1465
+ const fieldviews = field.type.fieldviews;
1466
+ fv = Object.values(fieldviews).find((v) => v.isEdit);
1467
+ }
1468
+ res.send(
1469
+ fv.run(
1470
+ field_name,
1471
+ row[field_name],
1472
+ {
1473
+ ...field.attributes,
1474
+ ...configuration,
1475
+ },
1476
+ "",
1477
+ false,
1478
+ field
1479
+ )
1480
+ );
1481
+ })
1482
+ );
1483
+
1484
+ router.post(
1485
+ "/save-click-edit",
1486
+ error_catcher(async (req, res) => {
1487
+ const fielddata = JSON.parse(decodeURIComponent(req.body._fielddata));
1488
+ const { field_name, table_name, pk, fieldview, configuration, join_field } =
1489
+ fielddata;
1490
+ const table = Table.findOne({ name: table_name });
1491
+ const field = table.getField(field_name);
1492
+ let val = field.type?.read
1493
+ ? field.type?.read(req.body[field_name])
1494
+ : req.body[field_name];
1495
+ await table.updateRow({ [field_name]: val }, pk, req.user);
1496
+ let fv;
1497
+ if (field.is_fkey) {
1498
+ if (join_field) {
1499
+ const refTable = Table.findOne({ name: field.reftable_name });
1500
+ const refRow = await refTable.getRow({ [refTable.pk_name]: val });
1501
+ val = refRow[join_field];
1502
+ const targetField = refTable.getField(join_field);
1503
+ const fieldviews = targetField.type.fieldviews;
1504
+
1505
+ fv = fieldviews[fieldview];
1506
+ } else fv = { run: (v) => `${v}` };
1507
+ } else {
1508
+ const fieldviews = field.type.fieldviews;
1509
+
1510
+ fv = fieldviews[fieldview];
1511
+
1512
+ if (!fv) {
1513
+ const fv1 = Object.values(fieldviews).find(
1514
+ (v) => !v.isEdit && !v.isFilter
1515
+ );
1516
+ fv = fv1;
1517
+ }
1518
+ }
1519
+
1520
+ res.send(
1521
+ div(
1522
+ {
1523
+ "data-inline-edit-fielddata": req.body._fielddata,
1524
+ "data-inline-edit-ajax": "true",
1525
+ "data-inline-edit-dest-url": `/api/${table.name}/${pk}`,
1526
+ class: !isWeb(req) ? "mobile-data-inline-edit" : "",
1527
+ },
1528
+ fv.run(val, req, {
1529
+ ...field.attributes,
1530
+ ...configuration,
1531
+ })
1532
+ )
1533
+ );
1534
+ })
1535
+ );
package/routes/list.js CHANGED
@@ -402,25 +402,26 @@ router.get(
402
402
  })
403
403
  })
404
404
  window.tabulator_table = new Tabulator("#jsGrid", {
405
- ajaxURL:"/api/${encodeURIComponent(table.name)}${
406
- table.versioned ? "?versioncount=on" : ""
405
+ ajaxURL:"/api/${encodeURIComponent(
406
+ table.name
407
+ )}?tabulator_pagination_format=true${
408
+ table.versioned ? "&versioncount=on" : ""
407
409
  }",
408
410
  layout:"fitColumns",
409
411
  columns,
410
412
  height:"100%",
411
413
  pagination:true,
412
- paginationSize:20,
414
+ paginationMode:"remote",
415
+ paginationSize:10,
413
416
  clipboard:true,
414
417
  persistence:true,
415
418
  persistenceID:"table_tab_${table.name}",
416
419
  movableColumns: true,
420
+ ajaxContentType:"json",
421
+ sortMode:"remote",
417
422
  initialSort:[
418
423
  {column:"id", dir:"asc"},
419
- ],
420
- ajaxResponse:function(url, params, response){
421
-
422
- return response.success; //return the tableData property of a response json object
423
- },
424
+ ],
424
425
  });
425
426
  window.tabulator_table.on("cellEdited", function(cell){
426
427
  const row = cell.getRow().getData()
package/routes/menu.js CHANGED
@@ -9,7 +9,7 @@ const Router = require("express-promise-router");
9
9
 
10
10
  //const Field = require("@saltcorn/data/models/field");
11
11
  const Form = require("@saltcorn/data/models/form");
12
- const { isAdmin, error_catcher } = require("./utils.js");
12
+ const { isAdmin, error_catcher, isAdminOrHasConfigMinRole } = require("./utils.js");
13
13
  const { getState } = require("@saltcorn/data/db/state");
14
14
  //const File = require("@saltcorn/data/models/file");
15
15
  const User = require("@saltcorn/data/models/user");
@@ -490,7 +490,7 @@ const menuTojQME = (menu_items) =>
490
490
  */
491
491
  router.get(
492
492
  "/",
493
- isAdmin,
493
+ isAdminOrHasConfigMinRole("min_role_edit_menu"),
494
494
  error_catcher(async (req, res) => {
495
495
  const form = await menuForm(req);
496
496
  const state = getState();
@@ -566,7 +566,7 @@ const jQMEtoMenu = (menu_items) =>
566
566
  */
567
567
  router.post(
568
568
  "/",
569
- isAdmin,
569
+ isAdminOrHasConfigMinRole("min_role_edit_menu"),
570
570
  error_catcher(async (req, res) => {
571
571
  const new_menu = req.body;
572
572
  const menu_items = jQMEtoMenu(new_menu);
@@ -12,7 +12,13 @@ const Tag = require("@saltcorn/data/models/tag");
12
12
  const TagEntry = require("@saltcorn/data/models/tag_entry");
13
13
  const Router = require("express-promise-router");
14
14
 
15
- const { isAdmin, error_catcher, csrfField } = require("./utils");
15
+ const {
16
+ isAdmin,
17
+ error_catcher,
18
+ csrfField,
19
+ isAdminOrHasConfigMinRole,
20
+ checkEditPermission,
21
+ } = require("./utils");
16
22
 
17
23
  const Table = require("@saltcorn/data/models/table");
18
24
  const View = require("@saltcorn/data/models/view");
@@ -170,6 +176,42 @@ router.post(
170
176
  })
171
177
  );
172
178
 
179
+ router.post(
180
+ "/add-tag-entity/:tagname/:entitytype/:entityid",
181
+ isAdminOrHasConfigMinRole([
182
+ "min_role_edit_tables",
183
+ "min_role_edit_views",
184
+ "min_role_edit_pages",
185
+ "min_role_edit_triggers",
186
+ ]),
187
+ error_catcher(async (req, res) => {
188
+ const { tagname, entitytype, entityid } = req.params;
189
+ const tag = await Tag.findOne({ name: tagname });
190
+
191
+ const fieldName = idField(entitytype);
192
+ const auth = checkEditPermission(entitytype, req.user);
193
+ if (!auth) req.flash("error", "Not authorized");
194
+ else await tag.addEntry({ [fieldName]: +entityid });
195
+ switch (entitytype) {
196
+ case "views":
197
+ res.redirect(`/viewedit`);
198
+ break;
199
+ case "pages":
200
+ res.redirect(`/pageedit`);
201
+ break;
202
+ case "tables":
203
+ res.redirect(`/table`);
204
+ break;
205
+ case "triggers":
206
+ res.redirect(`/actions`);
207
+ break;
208
+
209
+ default:
210
+ break;
211
+ }
212
+ })
213
+ );
214
+
173
215
  // add one object to multiple tags
174
216
  router.post(
175
217
  "/add/multiple_tags/:entry_type/:object_id",
package/routes/tags.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const { a, text, i, div } = require("@saltcorn/markup/tags");
2
2
 
3
3
  const Tag = require("@saltcorn/data/models/tag");
4
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
4
5
  const Router = require("express-promise-router");
5
6
  const Form = require("@saltcorn/data/models/form");
6
7
  const User = require("@saltcorn/data/models/user");
package/routes/utils.js CHANGED
@@ -611,6 +611,20 @@ const getRandomPage = (pageGroup, req) => {
611
611
  return Page.findOne({ id: sessionMember.page_id });
612
612
  };
613
613
 
614
+ const checkEditPermission = (type, user) => {
615
+ if (user.role_id === 1) return true;
616
+ switch (type) {
617
+ case "views":
618
+ return getState().getConfig("min_role_edit_views", 1) >= user.role_id;
619
+ case "pages":
620
+ return getState().getConfig("min_role_edit_pages", 1) >= user.role_id;
621
+ case "triggers":
622
+ return getState().getConfig("min_role_edit_triggers", 1) >= user.role_id;
623
+ default:
624
+ return false;
625
+ }
626
+ };
627
+
614
628
  module.exports = {
615
629
  sqlsanitize,
616
630
  csrfField,
@@ -634,4 +648,5 @@ module.exports = {
634
648
  getEligiblePage,
635
649
  getRandomPage,
636
650
  tenant_letsencrypt_name,
651
+ checkEditPermission,
637
652
  };
package/serve.js CHANGED
@@ -99,20 +99,24 @@ const ensurePluginsFolder = async () => {
99
99
  const staticDeps = ["@saltcorn/markup", "@saltcorn/data", "jest"];
100
100
  const allPluginFolders = new Set();
101
101
  await eachTenant(async () => {
102
- const allPlugins = (await Plugin.find()).filter(
103
- (p) => !["base", "sbadmin2"].includes(p.name)
104
- );
105
- for (const plugin of allPlugins) {
106
- const tokens =
107
- plugin.source === "npm"
108
- ? plugin.location.split("/")
109
- : plugin.name.split("/");
110
- const pluginDir = path.join(
111
- rootFolder,
112
- plugin.source === "git" ? "git_plugins" : "plugins_folder",
113
- ...tokens
102
+ try {
103
+ const allPlugins = (await Plugin.find()).filter(
104
+ (p) => !["base", "sbadmin2"].includes(p.name)
114
105
  );
115
- allPluginFolders.add(pluginDir);
106
+ for (const plugin of allPlugins) {
107
+ const tokens =
108
+ plugin.source === "npm"
109
+ ? plugin.location.split("/")
110
+ : plugin.name.split("/");
111
+ const pluginDir = path.join(
112
+ rootFolder,
113
+ plugin.source === "git" ? "git_plugins" : "plugins_folder",
114
+ ...tokens
115
+ );
116
+ allPluginFolders.add(pluginDir);
117
+ }
118
+ } catch {
119
+ //ignore
116
120
  }
117
121
  });
118
122
  for (const folder of allPluginFolders) {
package/tests/api.test.js CHANGED
@@ -107,6 +107,19 @@ describe("API read", () => {
107
107
  )
108
108
  );
109
109
  });
110
+ it("should get books limit", async () => {
111
+ const app = await getApp({ disableCsrf: true });
112
+ await request(app)
113
+ .get("/api/books/?limit=1&offset=1&sortBy=pages")
114
+ .expect(
115
+ succeedJsonWith(
116
+ (rows) =>
117
+ rows.length == 1 &&
118
+ rows[0].author === "Herman Melville" &&
119
+ rows[0].pages === 967
120
+ )
121
+ );
122
+ });
110
123
  it("should handle fkey args ", async () => {
111
124
  const loginCookie = await getAdminLoginCookie();
112
125
  const app = await getApp({ disableCsrf: true });
@@ -54,6 +54,26 @@ test("updateQueryStringParameter", () => {
54
54
  "AK"
55
55
  )
56
56
  ).toBe("/foo?publisher.publisher->name=AK");
57
+ expect(
58
+ updateQueryStringParameter(
59
+ "/foo?factor.Factors->focus_area.Focus%20area->short_name=Leadership",
60
+ "factor.Factors->focus_area.Focus%20area->short_name",
61
+ "Marketing"
62
+ )
63
+ ).toBe("/foo?factor.Factors->focus_area.Focus%20area->short_name=Marketing");
64
+ expect(
65
+ updateQueryStringParameter(
66
+ "/foo?factor.Factors%20(Solaris)->focus_area.Focus%20area%20(Solaris)->short_name=Leadership",
67
+ "factor.Factors%20(Solaris)->focus_area.Focus%20area%20(Solaris)->short_name",
68
+ "Marketing"
69
+ )
70
+ ).toBe(
71
+ "/foo?factor.Factors%20(Solaris)->focus_area.Focus%20area%20(Solaris)->short_name=Marketing"
72
+ );
73
+ expect(removeQueryStringParameter("/foo?name=Bar&factor.Factors%20(Solaris)->focus_area.Focus%20area%20(Solaris)->short_name=Leadership", "factor.Factors%20(Solaris)->focus_area.Focus%20area%20(Solaris)->short_name")).toBe(
74
+ "/foo?name=Bar"
75
+ );
76
+
57
77
  expect(updateQueryStringParameter("/foo", "_or_field", ["baz", "bar"])).toBe(
58
78
  "/foo?_or_field=baz&_or_field=bar"
59
79
  );
package/wrapper.js CHANGED
@@ -96,6 +96,7 @@ const get_menu = (req) => {
96
96
  const canEditViews = state.getConfig("min_role_edit_views", 1) >= role;
97
97
  const canEditPages = state.getConfig("min_role_edit_pages", 1) >= role;
98
98
  const canEditTriggers = state.getConfig("min_role_edit_triggers", 1) >= role;
99
+ const canEditMenu = state.getConfig("min_role_edit_menu", 1) >= role;
99
100
  const isAdmin = role === 1;
100
101
  const hasAdmin =
101
102
  isAdmin ||
@@ -103,6 +104,7 @@ const get_menu = (req) => {
103
104
  canInspectTables ||
104
105
  canEditPages ||
105
106
  canEditViews ||
107
+ canEditMenu ||
106
108
  canEditTriggers;
107
109
  /*
108
110
  * Admin Menu items
@@ -128,13 +130,41 @@ const get_menu = (req) => {
128
130
  icon: "far fa-file",
129
131
  label: req.__("Pages"),
130
132
  });
131
- if (canEditTriggers && !isAdmin)
133
+ if (canEditTriggers && !canEditMenu && !isAdmin)
132
134
  adminItems.push({
133
135
  link: "/actions",
134
136
  altlinks: ["/events", "/eventlog", "/crashlog"],
135
137
  icon: "fas fa-calendar-check",
136
138
  label: req.__("Triggers"),
137
139
  });
140
+ if (canEditMenu && !isAdmin) {
141
+ const subitems = [
142
+ {
143
+ link: "/menu",
144
+ altlinks: [
145
+ "/site-structure",
146
+ "/search/config",
147
+ "/library/list",
148
+ "/tenant/list",
149
+ ],
150
+ icon: "fas fa-compass",
151
+ label: req.__("Menu"),
152
+ },
153
+ ];
154
+ if (canEditTriggers)
155
+ subitems.push({
156
+ link: "/actions",
157
+ altlinks: ["/events", "/eventlog", "/crashlog"],
158
+ icon: "fas fa-calendar-check",
159
+ label: req.__("Triggers"),
160
+ });
161
+ adminItems.push({
162
+ label: req.__("Settings"),
163
+ icon: "fas fa-wrench",
164
+ subitems,
165
+ });
166
+ }
167
+
138
168
  if (isAdmin)
139
169
  adminItems.push({
140
170
  label: req.__("Settings"),