@saltcorn/server 1.1.1-rc.1 → 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,15 @@
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
+
5
14
  * select_by_view fieldview for Key fields: the user selects the value of a
6
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.
7
16
 
@@ -38,6 +47,7 @@
38
47
  - ForLoop step type for loops over arrays.
39
48
  - Varius UX improvements for editing workflows
40
49
  - Integrate copilot, if installed, in workflow editing
50
+ - Call non-workflow trigger actions.
41
51
 
42
52
  * sbadmin2 theme - Color update: dark side bar, darker primary blue
43
53
 
package/locales/en.json CHANGED
@@ -1545,5 +1545,11 @@
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
1546
  "Trigger %s deleted": "Trigger %s deleted",
1547
1547
  "Edit menu": "Edit menu",
1548
- "Minimum role to edit menu": "Minimum role to 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"
1549
1555
  }
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "1.1.1-rc.1",
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-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",
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",
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/search.js CHANGED
@@ -8,6 +8,8 @@ const Router = require("express-promise-router");
8
8
  const { span, h5, h4, nbsp, p, a, div } = require("@saltcorn/markup/tags");
9
9
 
10
10
  const { getState } = require("@saltcorn/data/db/state");
11
+ const db = require("@saltcorn/data/db");
12
+
11
13
  const { isAdmin, error_catcher } = require("./utils.js");
12
14
  const Form = require("@saltcorn/data/models/form");
13
15
  const Table = require("@saltcorn/data/models/table");
@@ -58,6 +60,13 @@ const searchConfigForm = (tables, views, req) => {
58
60
  sublabel: req.__("Use table description instead of name as header"),
59
61
  type: "Bool",
60
62
  });
63
+ fields.push({
64
+ name: "search_results_decoration",
65
+ label: req.__("Show results in"),
66
+ sublabel: req.__("Show results from each table in this type of element"),
67
+ input_type: "select",
68
+ options: ["Cards", "Tabs"],
69
+ });
61
70
  const blurb1 = req.__(
62
71
  `Choose views for <a href="/search">search results</a> for each table.<br/>Set to blank to omit table from global search.`
63
72
  );
@@ -95,6 +104,10 @@ router.get(
95
104
  "search_table_description",
96
105
  false
97
106
  );
107
+ form.values.search_results_decoration = getState().getConfig(
108
+ "search_results_decoration",
109
+ "Cards"
110
+ );
98
111
  send_infoarch_page({
99
112
  res,
100
113
  req,
@@ -126,13 +139,20 @@ router.post(
126
139
  const result = form.validate(req.body);
127
140
 
128
141
  if (result.success) {
142
+ const dbversion = await db.getVersion(true);
129
143
  const search_table_description =
130
144
  !!result.success.search_table_description;
131
145
  await getState().setConfig(
132
146
  "search_table_description",
133
147
  search_table_description
134
148
  );
149
+ await getState().setConfig(
150
+ "search_results_decoration",
151
+ result.success.search_results_decoration || "Cards"
152
+ );
153
+ await getState().setConfig("search_use_websearch", +dbversion >= 11.0);
135
154
  delete result.success.search_table_description;
155
+ delete result.success.search_results_decoration;
136
156
  await getState().setConfig("globalSearch", result.success);
137
157
  if (!req.xhr) res.redirect("/search/config");
138
158
  else res.json({ success: "ok" });
@@ -196,9 +216,12 @@ const runSearch = async ({ q, _page, table }, req, res) => {
196
216
  "search_table_description",
197
217
  false
198
218
  );
219
+ const search_results_decoration = getState().getConfig(
220
+ "search_results_decoration",
221
+ "Cards"
222
+ );
199
223
  const current_page = parseInt(_page) || 1;
200
224
  const offset = (current_page - 1) * page_size;
201
- let resp = [];
202
225
  let tablesWithResults = [];
203
226
  let tablesConfigured = 0;
204
227
  for (const [tableName, viewName] of Object.entries(cfg)) {
@@ -206,7 +229,9 @@ const runSearch = async ({ q, _page, table }, req, res) => {
206
229
  !viewName ||
207
230
  viewName === "" ||
208
231
  viewName === "search_table_description" ||
209
- tableName === "search_table_description"
232
+ tableName === "search_table_description" ||
233
+ viewName === "search_results_decoration" ||
234
+ tableName === "search_results_decoration"
210
235
  )
211
236
  continue;
212
237
  tablesConfigured += 1;
@@ -238,10 +263,9 @@ const runSearch = async ({ q, _page, table }, req, res) => {
238
263
  }
239
264
 
240
265
  if (vresps.length > 0) {
241
- tablesWithResults.push({ tableName, label: sectionHeader });
242
- resp.push({
243
- type: "card",
244
- title: span({ id: tableName }, sectionHeader),
266
+ tablesWithResults.push({
267
+ tableName,
268
+ label: sectionHeader,
245
269
  contents: vresps.map((vr) => vr.html).join("<hr>") + paginate,
246
270
  });
247
271
  }
@@ -251,17 +275,36 @@ const runSearch = async ({ q, _page, table }, req, res) => {
251
275
  const form = searchForm();
252
276
  form.validate({ q });
253
277
 
278
+ const mkResultDisplay = () => {
279
+ switch (search_results_decoration) {
280
+ case "Tabs":
281
+ const tabContents = {};
282
+ tablesWithResults.forEach((tblRes) => {
283
+ tabContents[tblRes.label] = tblRes.contents;
284
+ });
285
+ return [{ type: "card", tabContents }];
286
+
287
+ default:
288
+ return tablesWithResults.map((tblRes) => ({
289
+ type: "card",
290
+ title: span({ id: tblRes.tableName }, tblRes.label),
291
+ contents: tblRes.contents,
292
+ }));
293
+ }
294
+ };
295
+
254
296
  // Prepare search result visualization
255
297
  const searchResult =
256
- resp.length === 0
298
+ tablesWithResults.length === 0
257
299
  ? [{ type: "card", contents: req.__("Not found") }]
258
- : resp;
300
+ : mkResultDisplay();
259
301
  res.sendWrap(req.__("Search all tables"), {
260
302
  above: [
261
303
  {
262
304
  type: "card",
263
305
  contents: div(
264
306
  renderForm(form, false),
307
+ syntax_help_link(req),
265
308
  typeof table !== "undefined" &&
266
309
  tablesConfigured > 1 &&
267
310
  div(
@@ -296,6 +339,19 @@ const runSearch = async ({ q, _page, table }, req, res) => {
296
339
  });
297
340
  };
298
341
 
342
+ const syntax_help_link = (req) => {
343
+ const use_websearch = getState().getConfig("search_use_websearch", false);
344
+ if (use_websearch)
345
+ return a(
346
+ {
347
+ href: "javascript:void(0);",
348
+ onclick: "ajax_modal('/search/syntax-help')",
349
+ },
350
+ req.__("Search syntax")
351
+ );
352
+ else return "";
353
+ };
354
+
299
355
  /**
300
356
  * Execute search or only show search form
301
357
  * @name get
@@ -327,9 +383,33 @@ router.get(
327
383
  }
328
384
 
329
385
  const form = searchForm();
330
- form.noSubmitButton = false;
331
- form.submitLabel = req.__("Search");
332
- res.sendWrap(req.__("Search all tables"), renderForm(form, false));
386
+ res.sendWrap(req.__("Search all tables"), {
387
+ type: "card",
388
+ contents: renderForm(form, false) + syntax_help_link(req),
389
+ });
333
390
  }
334
391
  })
335
392
  );
393
+
394
+ router.get(
395
+ "/syntax-help",
396
+ error_catcher(async (req, res) => {
397
+ res.sendWrap(
398
+ req.__("Search syntax help"),
399
+ div(
400
+ p(
401
+ `Individual words matched independently. Example <code>large cat</code>`
402
+ ),
403
+ p(
404
+ `Double quotes to match phrase as a single unit. Example <code>"large cat"</code> matches "the large cat sat..." but not "the large brown cat".`
405
+ ),
406
+ p(
407
+ `"or" to match either of two phrases. Example <code>cat or mouse</code>`
408
+ ),
409
+ p(
410
+ `"-" to exclude a word or phrase. Example <code>cat -mouse</code> does not match "cat and mouse"`
411
+ )
412
+ )
413
+ );
414
+ })
415
+ );
package/routes/tables.js CHANGED
@@ -1533,6 +1533,8 @@ router.get(
1533
1533
  key: (r) =>
1534
1534
  r.type === "Unique"
1535
1535
  ? r.configuration.fields.join(", ")
1536
+ : r.type === "Index" && r.configuration?.field === "_fts"
1537
+ ? "Full text search"
1536
1538
  : r.type === "Index"
1537
1539
  ? r.configuration.field
1538
1540
  : r.type === "Formula"
@@ -1629,11 +1631,23 @@ const constraintForm = (req, table, fields, type) => {
1629
1631
  ],
1630
1632
  });
1631
1633
  case "Index":
1634
+ const fieldopts = fields.map((f) => ({ label: f.label, name: f.name }));
1635
+ const hasIncludeFts = fields.filter((f) => f.attributes?.include_fts);
1636
+ if (!db.isSQLite && !hasIncludeFts.length)
1637
+ fieldopts.push({ label: "Full-text search", name: "_fts" });
1632
1638
  return new Form({
1633
1639
  action: `/table/add-constraint/${table.id}/${type}`,
1634
- blurb: req.__(
1635
- "Choose the field to be indexed. This make searching the table faster."
1636
- ),
1640
+ blurb:
1641
+ req.__(
1642
+ "Choose the field to be indexed. This make searching the table faster."
1643
+ ) +
1644
+ " " +
1645
+ (hasIncludeFts.length
1646
+ ? req.__(
1647
+ `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`,
1648
+ hasIncludeFts.map((f) => f.name).join(",")
1649
+ )
1650
+ : ""),
1637
1651
  fields: [
1638
1652
  {
1639
1653
  type: "String",
@@ -1641,7 +1655,7 @@ const constraintForm = (req, table, fields, type) => {
1641
1655
  label: "Field",
1642
1656
  required: true,
1643
1657
  attributes: {
1644
- options: fields.map((f) => ({ label: f.label, name: f.name })),
1658
+ options: fieldopts,
1645
1659
  },
1646
1660
  },
1647
1661
  ],
@@ -407,7 +407,7 @@ describe("visible_entries test", () => {
407
407
  .set("Cookie", staffCookie);
408
408
  expect(resp.statusCode).toBe(200);
409
409
  expect(
410
- resp.body.directories.find((file) => file.filename === "subsubfolder")
410
+ resp.body.directories.find((file) => file.filename === "_sc_test_subfolder_one/subsubfolder")
411
411
  ).toBeDefined();
412
412
  });
413
413