@saltcorn/server 1.1.1-beta.8 → 1.1.1-rc.2

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,25 @@
2
2
 
3
3
  ## 1.1.1 - In beta
4
4
 
5
+ * Full-text search improvements:
6
+ - An index for full-text search can now be created. When creating an index in
7
+ the constraints setting for a table, you can select "Full-text search" in
8
+ the field selector. This will dramatically speed up search on large tables.
9
+ - Use websearch_to_tsquery if available. This is a more natural and modern syntax.
10
+ - Link to syntax examples in /search
11
+ - Use default locale's language for search localisation.
12
+ - Option to show results in tabs in search configuration.
13
+
14
+ * select_by_view fieldview for Key fields: the user selects the value of a
15
+ 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.
16
+
17
+ * Click to edit (Show and List view patterns) is now implemented by rendering
18
+ the first available edit fieldview. This should be more robust and work with
19
+ more data types.
20
+
21
+ * Data in the admin's data edit grid is now loaded by page. This makes it
22
+ possible to work with much larger datasets.
23
+
5
24
  * You can now permit to non-admin (role ID > 1) users to edit or inspect tables, or
6
25
  edit views, pages or triggers. In the permissions tab of the Users and security
7
26
  settings, minimum roles can be set for these capabilities. The appropriate
@@ -28,6 +47,7 @@
28
47
  - ForLoop step type for loops over arrays.
29
48
  - Varius UX improvements for editing workflows
30
49
  - Integrate copilot, if installed, in workflow editing
50
+ - Call non-workflow trigger actions.
31
51
 
32
52
  * sbadmin2 theme - Color update: dark side bar, darker primary blue
33
53
 
@@ -35,12 +55,23 @@
35
55
  is changed.
36
56
 
37
57
  * Mobile builder:
38
- - PJAX view loading.
58
+ - PJAX view loading: Use pjax for all functions like on the web version.
59
+ - Share content to your app on mobile and PWA.
60
+ - Ensure at least one ReceiveMobileShareData trigger exists when the app is built or the PWA is installed.
61
+ - Shared content is accessible via the row variable.
62
+ - Android: No additional configuration is needed.
63
+ - PWA: Ensure a trusted HTTPS connection is used.
64
+ - iOS:
65
+ - A second provisioning profile is required, with the bundle ID of the main app followed by share-ext (e.g., com.saltcorn.share-ext).
66
+ - 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).
67
+ - The build will stop when the Xcode integration is required, and a "Finish the Build" shows up.
39
68
 
40
69
  ### Fixes
41
70
 
71
+ * Increase plugin install reliability
42
72
  * fix workflows on SQLite
43
73
  * fix query string build on check_state_field (#2948). Author: St0rml
74
+ * multiple fixes for the Capacitor port
44
75
 
45
76
  ### Translations
46
77
 
@@ -71,6 +102,11 @@
71
102
 
72
103
  * Webhook action has more options: method, set reponse value, headers.
73
104
 
105
+ * Mobile builder:
106
+ - 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.
107
+ - 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.
108
+ - PJAX view loading: Changed the full reload to pjax for sortby and gopage (paging). The remainig set_state calls are still full reloads.
109
+
74
110
  ### Security
75
111
 
76
112
  - 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,13 @@
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",
1549
+ "Full-text search index is not available as the table contains Key fields (%s) with the \"Include in full-text search\" option enabled. Disable this before creating a Full-text search index": "Full-text search index is not available as the table contains Key fields (%s) with the \"Include in full-text search\" option enabled. Disable this before creating a Full-text search index",
1550
+ "Share Extension Provisioning Profile": "Share Extension Provisioning Profile",
1551
+ "Show results in": "Show results in",
1552
+ "Show results from each table in this type of element": "Show results from each table in this type of element",
1553
+ "Search syntax help": "Search syntax help",
1554
+ "Search syntax": "Search syntax"
1547
1555
  }
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.8",
3
+ "version": "1.1.1-rc.2",
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.8",
11
- "@saltcorn/builder": "1.1.1-beta.8",
12
- "@saltcorn/data": "1.1.1-beta.8",
13
- "@saltcorn/admin-models": "1.1.1-beta.8",
14
- "@saltcorn/filemanager": "1.1.1-beta.8",
15
- "@saltcorn/markup": "1.1.1-beta.8",
16
- "@saltcorn/plugins-loader": "1.1.1-beta.8",
17
- "@saltcorn/sbadmin2": "1.1.1-beta.8",
10
+ "@saltcorn/base-plugin": "1.1.1-rc.2",
11
+ "@saltcorn/builder": "1.1.1-rc.2",
12
+ "@saltcorn/data": "1.1.1-rc.2",
13
+ "@saltcorn/admin-models": "1.1.1-rc.2",
14
+ "@saltcorn/filemanager": "1.1.1-rc.2",
15
+ "@saltcorn/markup": "1.1.1-rc.2",
16
+ "@saltcorn/plugins-loader": "1.1.1-rc.2",
17
+ "@saltcorn/sbadmin2": "1.1.1-rc.2",
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,7 +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
+ actionConfigFields.push(cfgFld);
735
735
  }
736
736
  } catch {}
737
737
  }
@@ -745,6 +745,21 @@ const getWorkflowStepForm = async (
745
745
  wf_action_name: Trigger.find({ action: "Workflow" }).map((wf) => wf.name),
746
746
  },
747
747
  });
748
+ const nonWfTriggerNames = Trigger.find({})
749
+ .filter((tr) => tr.action !== "Workflow")
750
+ .map((wf) => wf.name);
751
+
752
+ actionConfigFields.push({
753
+ label: "Row expression",
754
+ name: "row_expr",
755
+ type: "String",
756
+ class: "validate-expression",
757
+ sublabel:
758
+ "Expression for the object to set the <code>row</code> value to inside the action. If blank, set to whole context",
759
+ showIf: {
760
+ wf_action_name: nonWfTriggerNames,
761
+ },
762
+ });
748
763
 
749
764
  const builtInActionExplainers = WorkflowStep.builtInActionExplainers({
750
765
  api_call: trigger.when_trigger == "API call",
@@ -752,6 +767,7 @@ const getWorkflowStepForm = async (
752
767
  const actionsNotRequiringRow = Trigger.action_options({
753
768
  notRequireRow: true,
754
769
  noMultiStep: true,
770
+ apiNeverTriggers: true,
755
771
  builtInLabel: "Workflow Actions",
756
772
  builtIns: Object.keys(builtInActionExplainers),
757
773
  forWorkflow: true,
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") });