@saltcorn/server 0.9.0-beta.6 → 0.9.0-beta.8

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/app.js CHANGED
@@ -128,17 +128,23 @@ const getApp = async (opts = {}) => {
128
128
  app.use(helmet());
129
129
  // TODO ch find a better solution
130
130
  app.use(cors());
131
+ const bodyLimit = getState().getConfig("body_limit");
131
132
  app.use(
132
133
  express.json({
133
- limit: "5mb",
134
+ limit: bodyLimit ? `${bodyLimit}kb` : "5mb",
134
135
  verify: (req, res, buf) => {
135
136
  req.rawBody = buf;
136
137
  },
137
138
  })
138
139
  );
139
- // extenetede url encoding in use
140
+ const urlencodedLimit = getState().getConfig("url_encoded_limit");
141
+ // extended url encoding in use
140
142
  app.use(
141
- express.urlencoded({ limit: "5mb", extended: true, parameterLimit: 50000 })
143
+ express.urlencoded({
144
+ limit: urlencodedLimit ? `${urlencodedLimit}kb` : "5mb",
145
+ extended: true,
146
+ parameterLimit: 50000,
147
+ })
142
148
  );
143
149
 
144
150
  // cookies
package/errors.js CHANGED
@@ -46,10 +46,10 @@ module.exports =
46
46
  ? text(err.message)
47
47
  : req.__("An error occurred")
48
48
  );
49
- } else
50
- res
51
- .status(code)
52
- .sendWrap(
49
+ } else {
50
+ const _res = res.status(code);
51
+ if (_res.sendWrap)
52
+ _res.sendWrap(
53
53
  req.__(headline),
54
54
  devmode ? pre(text(err.stack)) : h3(req.__(headline)),
55
55
  role === 1 && !devmode ? pre(text(err.message)) : "",
@@ -61,4 +61,15 @@ module.exports =
61
61
  )
62
62
  : ""
63
63
  );
64
+ else
65
+ _res.send(
66
+ `<h2>${
67
+ err.message
68
+ ? err.message
69
+ : req.__
70
+ ? req.__("An error occurred")
71
+ : "An error occurred"
72
+ }</h2>`
73
+ );
74
+ }
64
75
  };
@@ -0,0 +1,9 @@
1
+ The action chosen will determine what happens when the trigger occurs.
2
+ After you have chosen an action, there will be further configuration of that action.
3
+
4
+ The available actions, some of which are only accessible when the trigger is
5
+ table-related, are:
6
+
7
+ {{# for (const [name, action] of Object.entries(scState.actions)) { }}
8
+ * `{{name}}`: {{action.description||""}}
9
+ {{# } }}
@@ -0,0 +1,159 @@
1
+ Here you can enter the code that needs to run when this trigger occurs.
2
+ The action can manipulate rows in the database, manipulate files, interact
3
+ with remote APIs, or issue directives for the user's display.
4
+
5
+ Your code can use await at the top level, and should do so whenever calling
6
+ database queries or other aynchronous code (see example below)
7
+
8
+ The variable `table` is the associated table (if any; note lowercase). If you want to access a different table,
9
+ use the `Table` variable (note uppercase) to access the Table class of tables (see
10
+ [documentation for Table class](https://saltcorn.github.io/saltcorn/classes/_saltcorn_data.models.Table-1.html))
11
+
12
+ Example:
13
+
14
+ ```
15
+ await table.insertRow({name: "Alex", age: 43})
16
+ const otherTable = Table.findOne({name: "Orders"})
17
+ await otherTable.deleteRows({id: order})
18
+ ```
19
+
20
+ In addition to `table` and `Table`, you can use other functions/variables:
21
+
22
+ #### `console`
23
+
24
+ Use this to print to the terminal.
25
+
26
+ Example: `console.log("Hello world")`
27
+
28
+ #### `Actions`
29
+
30
+ Use `Actions.{ACTION NAME}` to run an action.
31
+
32
+ Your available action names are: {{ Object.keys(scState.actions).join(", ") }}
33
+
34
+ Example:
35
+
36
+ ```
37
+ await Actions.set_user_language({language: "fr"})
38
+ ```
39
+
40
+ #### `sleep`
41
+
42
+ A small utility function to sleep for certain number of milliseconds. Use this with await
43
+
44
+ Example: `await sleep(1000)`
45
+
46
+ #### `require`
47
+
48
+ Use require to access NPM packages listed under your [Development settings](/admin/dev)
49
+
50
+ Example: `const _ = require("underscore")`
51
+
52
+ #### `fetch` and `fetchJSON`
53
+
54
+ Use these to make HTTP API calls. `fetch` is the standard JavaScript `fetch` (provided by
55
+ [node-fetch](https://www.npmjs.com/package/node-fetch#common-usage)). `fetchJSON` performs a fetch
56
+ and then reads its reponse to JSON
57
+
58
+ Example:
59
+
60
+ ```
61
+ const response = await fetch('https://api.github.com/users/github');
62
+ const data = await response.json();
63
+ ```
64
+
65
+ which is the same as
66
+
67
+ ```
68
+ const data = await fetchJSON('https://api.github.com/users/github');
69
+ ```
70
+
71
+ ## Return directives
72
+
73
+ Your code can with its return value give directives to the current page.
74
+ Valid return values are:
75
+
76
+ #### `notify`
77
+
78
+ Send a pop-up notification indicating success to the user
79
+
80
+ Example: `return { notify: "Order completed!" }`
81
+
82
+ #### `error`
83
+
84
+ Send a pop-up notification indicating error to the user.
85
+
86
+ Example: `return { error: "Invalid command!" }`
87
+
88
+ If this is triggered by an Edit view with the SubmitWithAjax,
89
+ halt navigation and stay on page. This can be used for complex validation logic,
90
+ When added as an Insert or Update trigger. If you delete the inserted row, You
91
+ may also need to clear the returned id in order to allow the user to continue editing.
92
+
93
+ Example:
94
+
95
+ ```
96
+ if(amount>cash_on_hand) {
97
+ await table.deleteRows({ id })
98
+ return {
99
+ error: "Invalid order!",
100
+ id: null
101
+ }
102
+ }
103
+ ```
104
+
105
+ #### `goto`
106
+
107
+ Navigate to a different URL:
108
+
109
+ Example: `return { goto: "https://saltcorn.com" }`
110
+
111
+ #### `reload_page`
112
+
113
+ Request a page reload with the existing URL.
114
+
115
+ Example: `return { reload_page: true }`
116
+
117
+ #### `popup`
118
+
119
+ Open a URL in a popup:
120
+
121
+ Example:
122
+
123
+ ```
124
+ return { popup: `/view/Orders?id=${parent}` }
125
+ ```
126
+
127
+ #### `download`
128
+
129
+ Download a file to the client browser.
130
+
131
+ Example:
132
+
133
+ ```
134
+ return { download: {
135
+ mimetype: "text/csv",
136
+ blob: filecontents
137
+ }
138
+ }
139
+ ```
140
+
141
+ #### `set_fields`
142
+
143
+ If triggered from an edit view, set fields dynamically in the form. The
144
+ value should be an object with keys that are field variable names.
145
+
146
+ Example:
147
+
148
+ ```
149
+ return { set_fields: {
150
+ zidentifier: `${name.toUpperCase()}-${id}`
151
+ }
152
+ }
153
+ ```
154
+
155
+ #### `eval_js`
156
+
157
+ Execute JavaScript in the browser.
158
+
159
+ Example: `return { eval_js: 'alert("Hello world")' }`
package/locales/en.json CHANGED
@@ -1261,5 +1261,10 @@
1261
1261
  "Module up-to-date": "Module up-to-date",
1262
1262
  "Module '%s' not found": "Module '%s' not found",
1263
1263
  "Module %s not found": "Module %s not found",
1264
- "Initially open": "Initially open"
1264
+ "Include Event Logs": "Include Event Logs",
1265
+ "Backup with event logs": "Backup with event logs",
1266
+ "Initially open": "Initially open",
1267
+ "Not a valid pack": "Not a valid pack",
1268
+ "Pack file": "Pack file",
1269
+ "Upload a pack file": "Upload a pack file"
1265
1270
  }
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "0.9.0-beta.6",
3
+ "version": "0.9.0-beta.8",
4
4
  "description": "Server app for Saltcorn, open-source no-code platform",
5
5
  "homepage": "https://saltcorn.com",
6
6
  "main": "index.js",
7
7
  "license": "MIT",
8
8
  "dependencies": {
9
- "@saltcorn/base-plugin": "0.9.0-beta.6",
10
- "@saltcorn/builder": "0.9.0-beta.6",
11
- "@saltcorn/data": "0.9.0-beta.6",
12
- "@saltcorn/admin-models": "0.9.0-beta.6",
13
- "@saltcorn/filemanager": "0.9.0-beta.6",
14
- "@saltcorn/markup": "0.9.0-beta.6",
15
- "@saltcorn/sbadmin2": "0.9.0-beta.6",
9
+ "@saltcorn/base-plugin": "0.9.0-beta.8",
10
+ "@saltcorn/builder": "0.9.0-beta.8",
11
+ "@saltcorn/data": "0.9.0-beta.8",
12
+ "@saltcorn/admin-models": "0.9.0-beta.8",
13
+ "@saltcorn/filemanager": "0.9.0-beta.8",
14
+ "@saltcorn/markup": "0.9.0-beta.8",
15
+ "@saltcorn/sbadmin2": "0.9.0-beta.8",
16
16
  "@socket.io/cluster-adapter": "^0.2.1",
17
17
  "@socket.io/sticky": "^1.0.1",
18
18
  "adm-zip": "0.5.10",
@@ -51,6 +51,7 @@ const nubBy = (prop, xs) => {
51
51
  });
52
52
  };
53
53
  function apply_showif() {
54
+ const isNode = typeof parent?.saltcorn?.data?.state === "undefined";
54
55
  $("[data-show-if]").each(function (ix, element) {
55
56
  var e = $(element);
56
57
  try {
@@ -272,10 +273,12 @@ function apply_showif() {
272
273
 
273
274
  if (typeof cache[recS] !== "undefined") {
274
275
  e.html(cache[recS]);
276
+ e.prop("data-source-url-current", recS);
275
277
  activate_onchange_coldef();
276
278
  return;
277
279
  }
278
- ajax_post_json(e.attr("data-source-url"), rec, {
280
+
281
+ const cb = {
279
282
  success: (data) => {
280
283
  e.html(data);
281
284
  const cacheNow = e.prop("data-source-url-cache") || {};
@@ -295,7 +298,11 @@ function apply_showif() {
295
298
  });
296
299
  e.html("");
297
300
  },
298
- });
301
+ };
302
+ if (isNode) ajax_post_json(e.attr("data-source-url"), rec, cb);
303
+ else {
304
+ local_post_json(e.attr("data-source-url"), rec, cb);
305
+ }
299
306
  });
300
307
  const locale =
301
308
  navigator.userLanguage ||
@@ -518,6 +525,11 @@ function initialize_page() {
518
525
  });
519
526
 
520
527
  $("form").change(apply_showif);
528
+ // also change if we select same
529
+ $("form select").on("blur", (e) => {
530
+ if (!e || !e.target) return;
531
+ $(e.target).closest("form").trigger("change");
532
+ });
521
533
  apply_showif();
522
534
  apply_showif();
523
535
  $("[data-inline-edit-dest-url]").each(function () {
@@ -678,10 +690,13 @@ function initialize_page() {
678
690
  setTimeout(() => {
679
691
  codes.forEach((el) => {
680
692
  //console.log($(el).attr("mode"), el);
693
+ if ($(el).hasClass("codemirror-enabled")) return;
694
+
681
695
  const cm = CodeMirror.fromTextArea(el, {
682
696
  lineNumbers: true,
683
697
  mode: $(el).attr("mode"),
684
698
  });
699
+ $(el).addClass("codemirror-enabled");
685
700
  cm.on(
686
701
  "change",
687
702
  $.debounce(() => {
@@ -356,6 +356,15 @@ function selectVersionError(res, btnId) {
356
356
  restore_old_button(btnId);
357
357
  }
358
358
 
359
+ function submitWithAjax(e) {
360
+ saveAndContinue(e, (res) => {
361
+ if (res && res.responseJSON && res.responseJSON.url_when_done)
362
+ window.location.href = res.responseJSON.url_when_done;
363
+ if (res && res.responseJSON && res.responseJSON.error)
364
+ notifyAlert({ type: "danger", text: res.responseJSON.error });
365
+ });
366
+ }
367
+
359
368
  function saveAndContinue(e, k) {
360
369
  var form = $(e).closest("form");
361
370
  const valres = form[0].reportValidity();
@@ -404,8 +413,8 @@ function saveAndContinue(e, k) {
404
413
  }
405
414
  ajax_indicate_error(e, request);
406
415
  },
407
- complete: function () {
408
- if (k) k();
416
+ complete: function (res) {
417
+ if (k) k(res);
409
418
  },
410
419
  });
411
420
 
package/routes/actions.js CHANGED
@@ -174,8 +174,9 @@ const triggerForm = async (req, trigger) => {
174
174
  attributes: {
175
175
  explainers: {
176
176
  Often: req.__("Every 5 minutes"),
177
- Never:
178
- req.__("Not scheduled but can be run as an action from a button click"),
177
+ Never: req.__(
178
+ "Not scheduled but can be run as an action from a button click"
179
+ ),
179
180
  },
180
181
  },
181
182
  },
@@ -201,6 +202,7 @@ const triggerForm = async (req, trigger) => {
201
202
  label: req.__("Action"),
202
203
  type: "String",
203
204
  required: true,
205
+ help: { topic: "Actions" },
204
206
  attributes: {
205
207
  calcOptions: ["when_trigger", action_options],
206
208
  },
@@ -402,7 +404,7 @@ router.get(
402
404
  form.values = trigger.configuration;
403
405
  const events = Trigger.when_options;
404
406
  const actions = Trigger.find({
405
- when_trigger: {or: ["API call", "Never"]},
407
+ when_trigger: { or: ["API call", "Never"] },
406
408
  });
407
409
  const tables = (await Table.find({})).map((t) => ({
408
410
  name: t.name,
package/routes/admin.js CHANGED
@@ -285,6 +285,9 @@ router.get(
285
285
  backupForm.values.auto_backup_expire_days = getState().getConfig(
286
286
  "auto_backup_expire_days"
287
287
  );
288
+ backupForm.values.backup_with_event_log = getState().getConfig(
289
+ "backup_with_event_log"
290
+ );
288
291
  //
289
292
  const aSnapshotForm = snapshotForm(req);
290
293
  aSnapshotForm.values.snapshots_enabled =
@@ -721,6 +724,15 @@ const autoBackupForm = (req) =>
721
724
  auto_backup_destination: "Local directory",
722
725
  },
723
726
  },
727
+ {
728
+ type: "Bool",
729
+ label: req.__("Include Event Logs"),
730
+ sublabel: req.__("Backup with event logs"),
731
+ name: "backup_with_event_log",
732
+ showIf: {
733
+ auto_backup_frequency: ["Daily", "Weekly"],
734
+ },
735
+ },
724
736
  ],
725
737
  });
726
738
 
package/routes/fields.js CHANGED
@@ -409,7 +409,7 @@ const fieldFlow = (req) =>
409
409
  instance_options[model.name].push(...instances.map((i) => i.name));
410
410
 
411
411
  const outputs = await applyAsync(
412
- model.templateObj.prediction_outputs || [],
412
+ model.templateObj?.prediction_outputs || [], // unit tests can have templateObj undefined
413
413
  { table, configuration: model.configuration }
414
414
  );
415
415
  output_options[model.name] = outputs.map((o) => o.name);
@@ -840,6 +840,11 @@ router.post(
840
840
  const table = Table.findOne({ name: tableName });
841
841
  const role = req.user && req.user.id ? req.user.role_id : 100;
842
842
 
843
+ getState().log(
844
+ 5,
845
+ `Route /fields/show-calculated/${tableName}/${fieldName}/${fieldview} user=${req.user?.id}`
846
+ );
847
+
843
848
  const fields = table.getFields();
844
849
  let row = { ...req.body };
845
850
  if (row && Object.keys(row).length > 0) readState(row, fields);
@@ -1018,6 +1023,13 @@ router.post(
1018
1023
  const { tableName, fieldName, fieldview } = req.params;
1019
1024
  const table = Table.findOne({ name: tableName });
1020
1025
  const fields = table.getFields();
1026
+ const state = getState();
1027
+
1028
+ state.log(
1029
+ 5,
1030
+ `Route /fields/preview/${tableName}/${fieldName}/${fieldview} user=${req.user?.id}`
1031
+ );
1032
+
1021
1033
  let field, row, value;
1022
1034
  if (fieldName.includes(".")) {
1023
1035
  const [refNm, targetNm] = fieldName.split(".");
@@ -1048,9 +1060,9 @@ router.post(
1048
1060
  }
1049
1061
  const fieldviews =
1050
1062
  field.type === "Key"
1051
- ? getState().keyFieldviews
1063
+ ? state.keyFieldviews
1052
1064
  : field.type === "File"
1053
- ? getState().fileviews
1065
+ ? state.fileviews
1054
1066
  : field.type.fieldviews;
1055
1067
  if (!field.type || !fieldviews) {
1056
1068
  res.send("");
package/routes/packs.js CHANGED
@@ -6,18 +6,19 @@
6
6
 
7
7
  const Router = require("express-promise-router");
8
8
  const { isAdmin, error_catcher } = require("./utils.js");
9
- const { mkTable, renderForm, link, post_btn } = require("@saltcorn/markup");
10
- const { getState } = require("@saltcorn/data/db/state");
9
+ const { renderForm } = require("@saltcorn/markup");
11
10
  const Table = require("@saltcorn/data/models/table");
12
11
  const Form = require("@saltcorn/data/models/form");
13
12
  const View = require("@saltcorn/data/models/view");
14
- const Field = require("@saltcorn/data/models/field");
15
13
  const Plugin = require("@saltcorn/data/models/plugin");
16
14
  const Page = require("@saltcorn/data/models/page");
15
+ const Tag = require("@saltcorn/data/models/tag");
16
+ const EventLog = require("@saltcorn/data/models/eventlog");
17
+ const Model = require("@saltcorn/data/models/model");
18
+ const ModelInstance = require("@saltcorn/data/models/model_instance");
17
19
  const load_plugins = require("../load_plugins");
18
20
 
19
21
  const { is_pack } = require("@saltcorn/data/contracts");
20
- const { contract, is } = require("contractis");
21
22
  const {
22
23
  table_pack,
23
24
  view_pack,
@@ -26,15 +27,20 @@ const {
26
27
  role_pack,
27
28
  library_pack,
28
29
  trigger_pack,
30
+ tag_pack,
31
+ model_pack,
32
+ model_instance_pack,
29
33
  install_pack,
30
34
  fetch_pack_by_name,
31
35
  can_install_pack,
32
36
  uninstall_pack,
37
+ event_log_pack,
33
38
  } = require("@saltcorn/admin-models/models/pack");
34
- const { h5, pre, code, p, text, text_attr } = require("@saltcorn/markup/tags");
39
+ const { pre, code, p, text, text_attr } = require("@saltcorn/markup/tags");
35
40
  const Library = require("@saltcorn/data/models/library");
36
41
  const Trigger = require("@saltcorn/data/models/trigger");
37
42
  const Role = require("@saltcorn/data/models/role");
43
+ const fs = require("fs");
38
44
 
39
45
  /**
40
46
  * @type {object}
@@ -98,6 +104,52 @@ router.get(
98
104
  name: `role.${l.role}`,
99
105
  type: "Bool",
100
106
  }));
107
+ const tags = await Tag.find({});
108
+ const tagFields = tags.map((t) => ({
109
+ label: `${t.name} tag`,
110
+ name: `tag.${t.name}`,
111
+ type: "Bool",
112
+ }));
113
+ const models = await Model.find({});
114
+ const modelFields = models.map((m) => {
115
+ const modelTbl = Table.findOne({ id: m.table_id });
116
+ return {
117
+ label: `${m.name} model, table: ${
118
+ modelTbl.name || req.__("Table not found")
119
+ }`,
120
+ name: `model.${m.name}.${modelTbl.name}`,
121
+ type: "Bool",
122
+ };
123
+ });
124
+ const modelInstances = await ModelInstance.find({});
125
+ const modelInstanceFields = (
126
+ await Promise.all(
127
+ modelInstances.map(async (instance) => {
128
+ const model = await Model.findOne({ id: instance.model_id });
129
+ if (!model) {
130
+ req.flash(
131
+ "warning",
132
+ req.__(`Model with '${instance.model_id}' not found`)
133
+ );
134
+ return null;
135
+ }
136
+ const mTable = await Table.findOne({ id: model.table_id });
137
+ if (!mTable) {
138
+ req.flash(
139
+ "warning",
140
+ req.__(`Table of model '${model.name}' not found`)
141
+ );
142
+ return null;
143
+ }
144
+ return {
145
+ label: `${instance.name} model instance, model: ${model.name}, table: ${mTable.name}`,
146
+ name: `model_instance.${instance.name}.${model.name}.${mTable.name}`,
147
+ type: "Bool",
148
+ };
149
+ })
150
+ )
151
+ ).filter((f) => f);
152
+
101
153
  const form = new Form({
102
154
  action: "/packs/create",
103
155
  fields: [
@@ -108,6 +160,14 @@ router.get(
108
160
  ...trigFields,
109
161
  ...roleFields,
110
162
  ...libFields,
163
+ ...tagFields,
164
+ ...modelFields,
165
+ ...modelInstanceFields,
166
+ {
167
+ name: "with_event_logs",
168
+ label: req.__("Include Event Logs"),
169
+ type: "Bool",
170
+ },
111
171
  ],
112
172
  });
113
173
  res.sendWrap(req.__(`Create Pack`), {
@@ -140,7 +200,7 @@ router.post(
140
200
  "/create",
141
201
  isAdmin,
142
202
  error_catcher(async (req, res) => {
143
- var pack = {
203
+ const pack = {
144
204
  tables: [],
145
205
  views: [],
146
206
  plugins: [],
@@ -148,9 +208,13 @@ router.post(
148
208
  roles: [],
149
209
  library: [],
150
210
  triggers: [],
211
+ tags: [],
212
+ models: [],
213
+ model_instances: [],
214
+ event_logs: [],
151
215
  };
152
216
  for (const k of Object.keys(req.body)) {
153
- const [type, name] = k.split(".");
217
+ const [type, name, ...rest] = k.split(".");
154
218
  switch (type) {
155
219
  case "table":
156
220
  pack.tables.push(await table_pack(name));
@@ -173,7 +237,32 @@ router.post(
173
237
  case "trigger":
174
238
  pack.triggers.push(await trigger_pack(name));
175
239
  break;
176
-
240
+ case "tag":
241
+ pack.tags.push(await tag_pack(name));
242
+ break;
243
+ case "model": {
244
+ const table = rest[0];
245
+ if (!table) throw new Error(`Table for model '${name}' not found`);
246
+ pack.models.push(await model_pack(name, table));
247
+ break;
248
+ }
249
+ case "model_instance": {
250
+ const model = rest[0];
251
+ if (!model)
252
+ throw new Error(`Model of Model Instance '${name}' not found`);
253
+ const table = rest[1];
254
+ if (!table) throw new Error(`Table of Model '${model}' not found`);
255
+ pack.model_instances.push(
256
+ await model_instance_pack(name, model, table)
257
+ );
258
+ break;
259
+ }
260
+ case "with_event_logs":
261
+ const logs = await EventLog.find({});
262
+ pack.event_logs = await Promise.all(
263
+ logs.map(async (l) => await event_log_pack(l))
264
+ );
265
+ break;
177
266
  default:
178
267
  break;
179
268
  }
@@ -217,11 +306,33 @@ const install_pack_form = (req) =>
217
306
  action: "/packs/install",
218
307
  submitLabel: req.__("Install"),
219
308
  fields: [
309
+ {
310
+ name: "source",
311
+ label: req.__("Source"),
312
+ type: "String",
313
+ attributes: {
314
+ options: [
315
+ { label: "from text", name: "from_text" },
316
+ { label: "from file", name: "from_file" },
317
+ ],
318
+ },
319
+ default: "from_text",
320
+ required: true,
321
+ },
220
322
  {
221
323
  name: "pack",
222
324
  label: req.__("Pack"),
223
325
  type: "String",
224
326
  fieldview: "textarea",
327
+ showIf: { source: "from_text" },
328
+ },
329
+ {
330
+ name: "pack_file",
331
+ label: req.__("Pack file"),
332
+ class: "form-control",
333
+ type: "File",
334
+ sublabel: req.__("Upload a pack file"),
335
+ showIf: { source: "from_file" },
225
336
  },
226
337
  ],
227
338
  });
@@ -267,8 +378,22 @@ router.post(
267
378
  isAdmin,
268
379
  error_catcher(async (req, res) => {
269
380
  var pack, error;
381
+ const source = req.body.source || "from_text";
270
382
  try {
271
- pack = JSON.parse(req.body.pack);
383
+ switch (source) {
384
+ case "from_text":
385
+ pack = JSON.parse(req.body.pack);
386
+ break;
387
+ case "from_file":
388
+ if (req.files?.pack_file?.tempFilePath)
389
+ pack = JSON.parse(
390
+ fs.readFileSync(req.files?.pack_file?.tempFilePath)
391
+ );
392
+ else throw new Error(req.__("No file uploaded"));
393
+ break;
394
+ default:
395
+ throw new Error(req.__("Invalid source"));
396
+ }
272
397
  } catch (e) {
273
398
  error = e.message;
274
399
  }
package/routes/sync.js CHANGED
@@ -312,7 +312,10 @@ router.get(
312
312
  const translatedIds = JSON.parse(
313
313
  await fs.readFile(path.join(syncDir, "translated-ids.json"))
314
314
  );
315
- res.json({ finished: true, translatedIds });
315
+ const uniqueConflicts = JSON.parse(
316
+ await fs.readFile(path.join(syncDir, "unique-conflicts.json"))
317
+ );
318
+ res.json({ finished: true, translatedIds, uniqueConflicts });
316
319
  } else if (entries.indexOf("error.json") >= 0) {
317
320
  const error = JSON.parse(
318
321
  await fs.readFile(path.join(syncDir, "error.json"))
@@ -632,6 +632,19 @@ router.post(
632
632
 
633
633
  const view = await View.findOne({ name });
634
634
  const configFlow = await view.get_config_flow(req);
635
+ configFlow.onStepSuccess = async (step, context) => {
636
+ let newcfg;
637
+ if (step.contextField)
638
+ newcfg = {
639
+ ...view.configuration,
640
+ [step.contextField]: {
641
+ ...view.configuration?.[step.contextField],
642
+ ...context,
643
+ },
644
+ };
645
+ else newcfg = { ...view.configuration, ...context };
646
+ await View.update({ configuration: newcfg }, view.id);
647
+ };
635
648
  const wfres = await configFlow.run(req.body, req);
636
649
 
637
650
  let table;
@@ -568,11 +568,11 @@ describe("tags", () => {
568
568
  .post("/tag")
569
569
  .set("Cookie", loginCookie)
570
570
  .send("name=MyNewTestTag")
571
- .expect(toRedirect("/tag/1?show_list=tables"));
571
+ .expect(toRedirect("/tag/2?show_list=tables"));
572
572
  });
573
573
 
574
574
  itShouldIncludeTextForAdmin("/tag", "MyNewTestTag");
575
- itShouldIncludeTextForAdmin("/tag/1", "MyNewTestTag");
575
+ itShouldIncludeTextForAdmin("/tag/2", "MyNewTestTag");
576
576
  itShouldIncludeTextForAdmin("/tag-entries/add/tables/1", "Add entries");
577
577
  itShouldIncludeTextForAdmin("/tag-entries/add/pages/1", "Add entries");
578
578
  itShouldIncludeTextForAdmin("/tag-entries/add/views/1", "Add entries");
@@ -13,6 +13,7 @@ const db = require("@saltcorn/data/db");
13
13
  const { sleep } = require("@saltcorn/data/tests/mocks");
14
14
 
15
15
  const Table = require("@saltcorn/data/models/table");
16
+ const TableConstraint = require("@saltcorn/data/models/table_constraints");
16
17
  const Field = require("@saltcorn/data/models/field");
17
18
  const User = require("@saltcorn/data/models/user");
18
19
 
@@ -349,8 +350,9 @@ describe("Upload changes", () => {
349
350
  .get(`/sync/upload_finished?dir_name=${encodeURIComponent(syncDir)}`)
350
351
  .set("Cookie", loginCookie);
351
352
  expect(resp.status).toBe(200);
352
- const { finished, translatedIds, error } = resp._body;
353
- if (finished) return translatedIds ? translatedIds : error;
353
+ const { finished, translatedIds, uniqueConflicts, error } = resp._body;
354
+ if (finished)
355
+ return translatedIds ? { translatedIds, uniqueConflicts } : error;
354
356
  await sleep(1000);
355
357
  }
356
358
  return null;
@@ -401,7 +403,7 @@ describe("Upload changes", () => {
401
403
  });
402
404
  expect(resp.status).toBe(200);
403
405
  const { syncDir } = resp._body;
404
- const translatedIds = await getResult(app, loginCookie, syncDir);
406
+ const { translatedIds } = await getResult(app, loginCookie, syncDir);
405
407
  await cleanSyncDir(app, loginCookie, syncDir);
406
408
  expect(translatedIds).toBeDefined();
407
409
  expect(translatedIds).toEqual({
@@ -414,6 +416,89 @@ describe("Upload changes", () => {
414
416
  });
415
417
  });
416
418
 
419
+ it("handles inserts with TableConstraint conflicts", async () => {
420
+ const books = Table.findOne({ name: "books" });
421
+ const oldCount = await books.countRows();
422
+ // unique constraint for author + pages
423
+ const constraint = await TableConstraint.create({
424
+ table: books,
425
+ type: "Unique",
426
+ configuration: {
427
+ fields: ["author", "pages"],
428
+ },
429
+ });
430
+
431
+ const app = await getApp({ disableCsrf: true });
432
+ const loginCookie = await getAdminLoginCookie();
433
+ const resp = await doUpload(app, loginCookie, new Date().valueOf(), {
434
+ books: {
435
+ inserts: [
436
+ {
437
+ author: "Herman Melville",
438
+ pages: 967,
439
+ publisher: 1,
440
+ },
441
+ {
442
+ author: "Leo Tolstoy",
443
+ pages: "728",
444
+ publisher: 2,
445
+ },
446
+ ],
447
+ },
448
+ });
449
+
450
+ expect(resp.status).toBe(200);
451
+ const { syncDir } = resp._body;
452
+ const { uniqueConflicts } = await getResult(app, loginCookie, syncDir);
453
+ await constraint.delete();
454
+ await cleanSyncDir(app, loginCookie, syncDir);
455
+ expect(uniqueConflicts).toBeDefined();
456
+ expect(uniqueConflicts).toEqual({
457
+ books: [
458
+ { id: 1, author: "Herman Melville", pages: 967, publisher: null },
459
+ { id: 2, author: "Leo Tolstoy", pages: 728, publisher: 1 },
460
+ ],
461
+ });
462
+ const newCount = await books.countRows();
463
+ expect(newCount).toBe(oldCount);
464
+ });
465
+
466
+ it("denies updates with TableConstraint conflicts", async () => {
467
+ const books = Table.findOne({ name: "books" });
468
+ const oldCount = await books.countRows();
469
+ // unique constraint for author + pages
470
+ const constraint = await TableConstraint.create({
471
+ table: books,
472
+ type: "Unique",
473
+ configuration: {
474
+ fields: ["author", "pages"],
475
+ },
476
+ });
477
+
478
+ const app = await getApp({ disableCsrf: true });
479
+ const loginCookie = await getAdminLoginCookie();
480
+ const resp = await doUpload(app, loginCookie, new Date().valueOf(), {
481
+ books: {
482
+ updates: [
483
+ {
484
+ id: 2,
485
+ author: "Herman Melville",
486
+ pages: 967,
487
+ },
488
+ ],
489
+ },
490
+ });
491
+ expect(resp.status).toBe(200);
492
+ const { syncDir } = resp._body;
493
+ const error = await getResult(app, loginCookie, syncDir);
494
+ await constraint.delete();
495
+ await cleanSyncDir(app, loginCookie, syncDir);
496
+ expect(error).toBeDefined();
497
+ expect(error).toEqual({
498
+ message: "Duplicate value for unique field: author_pages",
499
+ });
500
+ });
501
+
417
502
  it("update with translation", async () => {
418
503
  const app = await getApp({ disableCsrf: true });
419
504
  const loginCookie = await getAdminLoginCookie();
@@ -438,7 +523,7 @@ describe("Upload changes", () => {
438
523
  });
439
524
  expect(resp.status).toBe(200);
440
525
  const { syncDir } = resp._body;
441
- const translatedIds = await getResult(app, loginCookie, syncDir);
526
+ const { translatedIds } = await getResult(app, loginCookie, syncDir);
442
527
  await cleanSyncDir(app, loginCookie, syncDir);
443
528
  expect(translatedIds).toBeDefined();
444
529
  expect(translatedIds).toEqual({
@@ -476,7 +561,7 @@ describe("Upload changes", () => {
476
561
  });
477
562
  expect(resp.status).toBe(200);
478
563
  const { syncDir } = resp._body;
479
- const translatedIds = await getResult(app, loginCookie, syncDir);
564
+ const { translatedIds } = await getResult(app, loginCookie, syncDir);
480
565
  await cleanSyncDir(app, loginCookie, syncDir);
481
566
  expect(translatedIds).toBeDefined();
482
567
  const afterDelete = await books.getRows();
@@ -520,7 +605,7 @@ describe("Upload changes", () => {
520
605
  });
521
606
  expect(resp.status).toBe(200);
522
607
  const { syncDir } = resp._body;
523
- const translatedIds = await getResult(app, loginCookie, syncDir);
608
+ const { translatedIds } = await getResult(app, loginCookie, syncDir);
524
609
  await cleanSyncDir(app, loginCookie, syncDir);
525
610
  expect(translatedIds).toBeDefined();
526
611
  const afterDelete = await books.getRows();