@saltcorn/server 1.1.2-beta.0 → 1.1.2-beta.10

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,7 +2,18 @@
2
2
 
3
3
  ## 1.1.2 - In beta
4
4
 
5
- * Upgrade a large number of dependencies (express, typescript, oclif, pg, webpack, typescript). Node.js 18+ is require for this release.
5
+ * Builder:
6
+ - Container background image by file field in Show views
7
+ - Container opacity setting
8
+ - Set custom class on links and actions
9
+ - Fix error toast when saving on Firefox
10
+ - Set action to be submit action - action run on enter keypress.
11
+
12
+ * Restore large backups: stream JSON files to database, use system unzip
13
+
14
+ * Handle multiple fields with same name in Edit.
15
+
16
+ * Upgrade a large number of dependencies (express, typescript, oclif, pg, webpack, typescript, axios, mjml, svelte). Node.js 18+ is require for this release.
6
17
 
7
18
  ## 1.1.1 - Released 2 February 2025
8
19
 
package/app.js CHANGED
@@ -32,7 +32,7 @@ const { getAllTenants } = require("@saltcorn/admin-models/models/tenant");
32
32
  const path = require("path");
33
33
  const helmet = require("helmet");
34
34
  const wrapper = require("./wrapper");
35
- const csrf = require("csurf");
35
+ const csrf = require("@dr.pogodin/csurf");
36
36
  const { I18n } = require("i18n");
37
37
  const { h1 } = require("@saltcorn/markup/tags");
38
38
  const is = require("contractis/is");
@@ -131,6 +131,7 @@ const getApp = async (opts = {}) => {
131
131
  "cross_domain_iframe",
132
132
  false
133
133
  );
134
+ app.set("query parser", "extended");
134
135
 
135
136
  const helmetOptions = {
136
137
  contentSecurityPolicy: {
@@ -160,7 +161,7 @@ const getApp = async (opts = {}) => {
160
161
  helmetOptions.contentSecurityPolicy = false;
161
162
 
162
163
  if (cross_domain_iframe) helmetOptions.xFrameOptions = false;
163
- // app.use(helmet(helmetOptions));
164
+ app.use(helmet(helmetOptions));
164
165
 
165
166
  // TODO ch find a better solution
166
167
  if (getState().getConfig("cors_enabled", true)) app.use(cors());
package/locales/en.json CHANGED
@@ -1552,5 +1552,6 @@
1552
1552
  "Show results from each table in this type of element": "Show results from each table in this type of element",
1553
1553
  "Search syntax help": "Search syntax help",
1554
1554
  "Search syntax": "Search syntax",
1555
- "Maximum role": "Maximum role"
1555
+ "Maximum role": "Maximum role",
1556
+ "Module dependencies": "Module dependencies"
1556
1557
  }
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "1.1.2-beta.0",
3
+ "version": "1.1.2-beta.10",
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.735.0",
10
- "@saltcorn/base-plugin": "1.1.2-beta.0",
11
- "@saltcorn/builder": "1.1.2-beta.0",
12
- "@saltcorn/data": "1.1.2-beta.0",
13
- "@saltcorn/admin-models": "1.1.2-beta.0",
14
- "@saltcorn/filemanager": "1.1.2-beta.0",
15
- "@saltcorn/markup": "1.1.2-beta.0",
16
- "@saltcorn/plugins-loader": "1.1.2-beta.0",
17
- "@saltcorn/sbadmin2": "1.1.2-beta.0",
10
+ "@dr.pogodin/csurf": "^1.14.1",
11
+ "@saltcorn/base-plugin": "1.1.2-beta.10",
12
+ "@saltcorn/builder": "1.1.2-beta.10",
13
+ "@saltcorn/data": "1.1.2-beta.10",
14
+ "@saltcorn/admin-models": "1.1.2-beta.10",
15
+ "@saltcorn/filemanager": "1.1.2-beta.10",
16
+ "@saltcorn/markup": "1.1.2-beta.10",
17
+ "@saltcorn/plugins-loader": "1.1.2-beta.10",
18
+ "@saltcorn/sbadmin2": "1.1.2-beta.10",
18
19
  "@socket.io/cluster-adapter": "^0.2.1",
19
20
  "@socket.io/sticky": "^1.0.1",
20
21
  "adm-zip": "0.5.16",
@@ -25,7 +26,6 @@
25
26
  "cookie-parser": "^1.4.7",
26
27
  "cookie-session": "^2.1.0",
27
28
  "cors": "2.8.5",
28
- "csurf": "^1.11.0",
29
29
  "csv-stringify": "^6.5.2",
30
30
  "dockerode": "~4.0.4",
31
31
  "express": "^5.0.1",
@@ -77,11 +77,12 @@
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "nodemon index.js",
80
- "test": "jest --runInBand",
80
+ "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js ./tests --runInBand",
81
81
  "tsc": "echo \"Error: no TypeScript support yet\"",
82
82
  "clean": "echo \"Error: no TypeScript support yet\""
83
83
  },
84
84
  "jest": {
85
+ "transform": {},
85
86
  "testEnvironment": "node",
86
87
  "testPathIgnorePatterns": [
87
88
  "/node_modules/",
@@ -103,15 +103,26 @@ function rep_del(e) {
103
103
  var myrep = $(e).closest(".form-repeat");
104
104
  var ix = myrep.index();
105
105
  var parent = myrep.parent();
106
- myrep.remove();
107
106
  parent.children().each(function (childix, element) {
108
107
  if (childix > ix) {
109
108
  reindex(element, childix, childix - 1);
110
109
  }
111
110
  });
111
+ myrep.remove();
112
112
  }
113
113
 
114
114
  function reindex(element, oldix, newix) {
115
+ $(element)
116
+ .find("input,textarea")
117
+ .each(function () {
118
+ $(this).attr("value", $(this).val());
119
+ });
120
+ $(element)
121
+ .find("select")
122
+ .each(function () {
123
+ $(this).find(":selected").attr("selected", "selected");
124
+ });
125
+
115
126
  $(element).html(
116
127
  $(element)
117
128
  .html()
@@ -909,6 +920,27 @@ function initialize_page() {
909
920
  if (e.keyCode === 13) e.target.blur();
910
921
  });
911
922
 
923
+ const validate_identifier_elem = (target) => {
924
+ const next = target.next();
925
+ if (next.hasClass("expr-error")) next.remove();
926
+ const val = target.val();
927
+ if (!val) return;
928
+ try {
929
+ Function(val, "return 1");
930
+ } catch (error) {
931
+ target.after(`<small class="text-danger font-monospace d-block expr-error">
932
+ Invalid identifier
933
+ </small>`);
934
+ }
935
+ };
936
+ $(".validate-identifier").attr("spellcheck", false);
937
+ $(".validate-expression").attr("spellcheck", false);
938
+
939
+ $(".validate-identifier").bind("input", function (e) {
940
+ const target = $(e.target);
941
+ validate_identifier_elem(target);
942
+ });
943
+
912
944
  const validate_expression_elem = (target) => {
913
945
  const next = target.next();
914
946
  if (next.hasClass("expr-error")) next.remove();
@@ -921,7 +953,10 @@ function initialize_page() {
921
953
  }
922
954
  if (!val) return;
923
955
  try {
924
- Function("return " + val);
956
+ const AsyncFunction = Object.getPrototypeOf(
957
+ async function () {}
958
+ ).constructor;
959
+ AsyncFunction("return " + val);
925
960
  } catch (error) {
926
961
  target.after(`<small class="text-danger font-monospace d-block expr-error">
927
962
  ${error.message}
@@ -932,6 +967,7 @@ function initialize_page() {
932
967
  const target = $(e.target);
933
968
  validate_expression_elem(target);
934
969
  });
970
+
935
971
  $(".validate-expression-conditional").each(function () {
936
972
  const theInput = $(this);
937
973
  theInput
@@ -1543,7 +1579,7 @@ function emptyAlerts() {
1543
1579
  $("#toasts-area").html("");
1544
1580
  }
1545
1581
 
1546
- function press_store_button(clicked, keepOld) {
1582
+ function press_store_button(clicked, keepOld, disable) {
1547
1583
  let btn = clicked;
1548
1584
  if ($(clicked).is("form")) btn = $(clicked).find("button[type=submit]");
1549
1585
  if (keepOld) {
@@ -1552,17 +1588,24 @@ function press_store_button(clicked, keepOld) {
1552
1588
  }
1553
1589
  const width = $(btn).width();
1554
1590
  $(btn).html('<i class="fas fa-spinner fa-spin"></i>').width(width);
1591
+ setTimeout(() => {
1592
+ $(btn).prop("disabled", true);
1593
+ }, 50);
1555
1594
  }
1556
1595
 
1557
1596
  function restore_old_button(btnId) {
1558
- const btn = $(`#${btnId}`);
1597
+ const btn = btnId instanceof jQuery ? btnId : $(`#${btnId}`);
1559
1598
  const oldText = $(btn).data("old-text");
1560
1599
  btn.html(oldText);
1561
- btn.css({ width: "" });
1600
+ btn.css({ width: "" }).prop("disabled", false);
1562
1601
  btn.removeData("old-text");
1563
1602
  }
1564
1603
 
1565
- async function common_done(res, viewnameOrElem, isWeb = true) {
1604
+ async function common_done(res, viewnameOrElem0, isWeb = true) {
1605
+ const viewnameOrElem =
1606
+ viewnameOrElem0 === "undefined"
1607
+ ? last_route_viewname
1608
+ : viewnameOrElem0 || last_route_viewname;
1566
1609
  const viewname =
1567
1610
  typeof viewnameOrElem === "string"
1568
1611
  ? viewnameOrElem
@@ -812,3 +812,11 @@ tr span.add-tag {
812
812
  tr:hover span.add-tag {
813
813
  opacity: 1;
814
814
  }
815
+
816
+ #saltcorn-file-manager .filelist tr {
817
+ cursor: pointer;
818
+ }
819
+
820
+ #saltcorn-file-manager .filelist tr.selected td {
821
+ background-color: var(--bs-secondary-bg-subtle, var(--tblr-secondary-bg-subtle, gray));;
822
+ }
@@ -34,10 +34,7 @@ function updateQueryStringParameter(uri1, key, value) {
34
34
  uri = uris[0];
35
35
  }
36
36
 
37
- var re = new RegExp(
38
- "([?&])" + escapeRegExp(key) + "=.*?(&|$)",
39
- "i"
40
- );
37
+ var re = new RegExp("([?&])" + escapeRegExp(key) + "=.*?(&|$)", "i");
41
38
  var separator = uri.indexOf("?") !== -1 ? "&" : "?";
42
39
  if (uri.match(re)) {
43
40
  if (Array.isArray(value)) {
@@ -267,6 +264,8 @@ function reset_spinners() {
267
264
  });
268
265
  }
269
266
 
267
+ let last_route_viewname;
268
+
270
269
  function view_post(viewnameOrElem, route, data, onDone, sendState) {
271
270
  const viewname =
272
271
  typeof viewnameOrElem === "string"
@@ -274,6 +273,7 @@ function view_post(viewnameOrElem, route, data, onDone, sendState) {
274
273
  : $(viewnameOrElem)
275
274
  .closest("[data-sc-embed-viewname]")
276
275
  .attr("data-sc-embed-viewname");
276
+ last_route_viewname = viewname;
277
277
  const query = sendState
278
278
  ? `?${new URL(get_current_state_url()).searchParams.toString()}`
279
279
  : "";
@@ -1322,6 +1322,34 @@ function check_delete_unsaved(tablename, script_tag) {
1322
1322
  }
1323
1323
  }
1324
1324
 
1325
+ function handle_identical_fields(event) {
1326
+ let form = null;
1327
+ if (event.currentTarget.tagName === "FORM") form = event.currentTarget;
1328
+ else form = $(event.currentTarget).closest("form")[0];
1329
+ if (!form) {
1330
+ console.warn("No form found");
1331
+ } else {
1332
+ const name = event.target.name;
1333
+ const newValue = event.target.value;
1334
+ const tagName = event.target.tagName;
1335
+ const isRadio = event.target.type === "radio";
1336
+ if (tagName === "SELECT" || isRadio) {
1337
+ form.querySelectorAll(`select[name="${name}"]`).forEach((select) => {
1338
+ $(select).val(newValue); //.trigger("change");
1339
+ });
1340
+ form
1341
+ .querySelectorAll(`input[type="radio"][name="${name}"]`)
1342
+ .forEach((input) => {
1343
+ input.checked = input.value === newValue;
1344
+ });
1345
+ } else if (tagName === "INPUT") {
1346
+ form.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
1347
+ input.value = newValue;
1348
+ });
1349
+ }
1350
+ }
1351
+ }
1352
+
1325
1353
  (() => {
1326
1354
  const e = document.querySelector("[data-sidebar-toggler]");
1327
1355
  let closed = localStorage.getItem("sidebarClosed") === "true";
package/routes/actions.js CHANGED
@@ -730,6 +730,7 @@ const getWorkflowStepForm = async (
730
730
  label: req.__("Step name"),
731
731
  type: "String",
732
732
  required: true,
733
+ class: "validate-identifier",
733
734
  sublabel: "An identifier by which this step can be referred to.",
734
735
  validator: jsIdentifierValidator,
735
736
  },
@@ -742,6 +743,7 @@ const getWorkflowStepForm = async (
742
743
  {
743
744
  name: "wf_only_if",
744
745
  label: req.__("Only if..."),
746
+ class: "validate-expression",
745
747
  sublabel:
746
748
  "Optional JavaScript expression based on the run context. If given, the chosen action will only be executed if evaluates to true",
747
749
  type: "String",
@@ -1716,6 +1718,7 @@ const getWorkflowStepUserForm = async (run, trigger, step, req) => {
1716
1718
  const form = new Form({
1717
1719
  action: `/actions/fill-workflow-form/${run.id}`,
1718
1720
  submitLabel: run.wait_info.output ? req.__("OK") : req.__("Submit"),
1721
+ onSubmit: "press_store_button(this)",
1719
1722
  blurb,
1720
1723
  formStyle: run.wait_info.output || req.xhr ? "vert" : undefined,
1721
1724
  fields: await run.userFormFields(step),
@@ -1788,8 +1791,8 @@ router.post(
1788
1791
  if (req.xhr) {
1789
1792
  const retDirs = await run.popReturnDirectives();
1790
1793
 
1791
- if (runres?.popup) retDirs.popup = runres.popup;
1792
- res.json({ success: "ok", ...retDirs });
1794
+ //if (runres?.popup) retDirs.popup = runres.popup;
1795
+ res.json({ success: "ok", ...runres, ...retDirs });
1793
1796
  } else {
1794
1797
  if (run.context.goto) res.redirect(run.context.goto);
1795
1798
  else res.redirect("/");
package/routes/admin.js CHANGED
@@ -657,9 +657,11 @@ router.get(
657
657
  {
658
658
  label: req.__("When"),
659
659
  key: (r) =>
660
- `${moment(
661
- r.created
662
- ).fromNow()}<br><small>${localeDateTime(r.created, {}, locale)}</small>`,
660
+ `${moment(r.created).fromNow()}<br><small>${localeDateTime(
661
+ r.created,
662
+ {},
663
+ locale
664
+ )}</small>`,
663
665
  },
664
666
  {
665
667
  label: req.__("Name"),
@@ -1193,10 +1195,10 @@ router.get(
1193
1195
  th({ valign: "top" }, req.__("Saltcorn version")),
1194
1196
  td(
1195
1197
  packagejson.version,
1196
- isRoot && can_update
1198
+ isRoot
1197
1199
  ? post_btn(
1198
1200
  "/admin/upgrade",
1199
- req.__("Upgrade"),
1201
+ req.__("Upgrade") + " (latest)",
1200
1202
  req.csrfToken(),
1201
1203
  {
1202
1204
  btnClass: "btn-primary btn-sm",
@@ -290,7 +290,7 @@ const tagsDropdown = (tags, altHeader) =>
290
290
  )
291
291
  );
292
292
 
293
- const mkAddBtn = (tags, entityType, id, req, myTagIds) =>
293
+ const mkAddBtn = (tags, entityType, id, req, myTagIds, on_done_redirect_str) =>
294
294
  div(
295
295
  { class: "dropdown d-inline ms-1" },
296
296
  span(
@@ -314,7 +314,7 @@ const mkAddBtn = (tags, entityType, id, req, myTagIds) =>
314
314
  post_dropdown_item(
315
315
  `/tag-entries/add-tag-entity/${encodeURIComponent(
316
316
  t.name
317
- )}/${entityType}/${id}`,
317
+ )}/${entityType}/${id}${on_done_redirect_str||""}`,
318
318
  t.name,
319
319
  req
320
320
  )
@@ -345,7 +345,7 @@ const viewsList = async (
345
345
  const tagBadges = (view) => {
346
346
  const myTags = tag_entries.filter((te) => te.view_id === view.id);
347
347
  const myTagIds = new Set(myTags.map((t) => t.tag_id));
348
- const addBtn = mkAddBtn(tags, "views", view.id, req, myTagIds);
348
+ const addBtn = mkAddBtn(tags, "views", view.id, req, myTagIds, on_done_redirect_str);
349
349
  return (
350
350
  myTags.map((te) => tagBadge(tagsById[te.tag_id], "views")).join(nbsp) +
351
351
  addBtn
@@ -709,7 +709,7 @@ const getTriggerList = async (
709
709
  const myTagIds = new Set(myTags.map((t) => t.tag_id));
710
710
  return (
711
711
  myTags.map((te) => tagBadge(tagsById[te.tag_id], "triggers")).join(nbsp) +
712
- mkAddBtn(tags, "triggers", trigger.id, req, myTagIds)
712
+ mkAddBtn(tags, "triggers", trigger.id, req, myTagIds, on_done_redirect_str)
713
713
  );
714
714
  };
715
715
  return mkTable(
package/routes/fields.js CHANGED
@@ -1378,6 +1378,7 @@ router.post(
1378
1378
  agg_outcome_type,
1379
1379
  agg_fieldview,
1380
1380
  agg_field,
1381
+ mode,
1381
1382
  _columndef,
1382
1383
  } = req.body || {};
1383
1384
  const table = Table.findOne({ name: tableName });
@@ -1389,7 +1390,9 @@ router.post(
1389
1390
  return;
1390
1391
  }
1391
1392
  const field = table.getField(agg_field);
1392
- const cfgfields = await applyAsync(fv.configFields, field || { table });
1393
+ const cfgfields = await applyAsync(fv.configFields, field || { table }, {
1394
+ mode,
1395
+ });
1393
1396
  res.json(cfgfields);
1394
1397
  return;
1395
1398
  }
@@ -1412,7 +1415,12 @@ router.post(
1412
1415
  res.send(req.query?.accept == "json" ? "[]" : "");
1413
1416
  return;
1414
1417
  }
1415
- const fieldViewConfigForms = await calcfldViewConfig([field], false, 0);
1418
+ const fieldViewConfigForms = await calcfldViewConfig(
1419
+ [field],
1420
+ false,
1421
+ 0,
1422
+ mode
1423
+ );
1416
1424
  const formFields = fieldViewConfigForms[field.name][fv_name];
1417
1425
  if (!formFields) {
1418
1426
  res.send(req.query?.accept == "json" ? "[]" : "");
@@ -262,7 +262,10 @@ const actionsTab = async (req, triggers) => {
262
262
  ? p(req.__("No triggers"))
263
263
  : mkTable(
264
264
  [
265
- { label: req.__("Name"), key: "name" },
265
+ {
266
+ label: req.__("Name"),
267
+ key: (tr) => a({ href: `actions/configure/${tr.id}` }, tr.name),
268
+ },
266
269
  { label: req.__("Action"), key: "action" },
267
270
  {
268
271
  label: req.__("Table or Channel"),
package/routes/plugins.js CHANGED
@@ -558,6 +558,7 @@ const plugin_store_html = (items, req) => {
558
558
  },
559
559
  {
560
560
  besides: items.map(store_item_html(req)),
561
+ gy:3,
561
562
  widths: items.map(() => 4),
562
563
  },
563
564
  ],
package/routes/tables.js CHANGED
@@ -1582,7 +1582,7 @@ const constraintForm = (req, table, fields, type) => {
1582
1582
  case "Formula":
1583
1583
  return new Form({
1584
1584
  action: `/table/add-constraint/${table.id}/${type}`,
1585
-
1585
+ onSubmit: "press_store_button(this)",
1586
1586
  fields: [
1587
1587
  {
1588
1588
  name: "formula",
@@ -1617,6 +1617,7 @@ const constraintForm = (req, table, fields, type) => {
1617
1617
  blurb: req.__(
1618
1618
  "Tick the boxes for the fields that should be jointly unique"
1619
1619
  ),
1620
+ onSubmit: "press_store_button(this)",
1620
1621
  fields: [
1621
1622
  ...fields.map((f) => ({
1622
1623
  name: f.name,
@@ -1641,7 +1642,7 @@ const constraintForm = (req, table, fields, type) => {
1641
1642
  blurb: req.__(
1642
1643
  "Choose the field to be indexed. This make searching the table faster."
1643
1644
  ),
1644
-
1645
+ onSubmit: "press_store_button(this)",
1645
1646
  fields: [
1646
1647
  {
1647
1648
  type: "String",
@@ -18,6 +18,7 @@ const {
18
18
  csrfField,
19
19
  isAdminOrHasConfigMinRole,
20
20
  checkEditPermission,
21
+ is_relative_url,
21
22
  } = require("./utils");
22
23
 
23
24
  const Table = require("@saltcorn/data/models/table");
@@ -192,18 +193,23 @@ router.post(
192
193
  const auth = checkEditPermission(entitytype, req.user);
193
194
  if (!auth) req.flash("error", "Not authorized");
194
195
  else await tag.addEntry({ [fieldName]: +entityid });
196
+ let redirectTarget =
197
+ req.query.on_done_redirect &&
198
+ is_relative_url("/" + req.query.on_done_redirect)
199
+ ? `/${req.query.on_done_redirect}`
200
+ : null;
195
201
  switch (entitytype) {
196
202
  case "views":
197
- res.redirect(`/viewedit`);
203
+ res.redirect(redirectTarget || `/viewedit`);
198
204
  break;
199
205
  case "pages":
200
- res.redirect(`/pageedit`);
206
+ res.redirect(redirectTarget || `/pageedit`);
201
207
  break;
202
208
  case "tables":
203
- res.redirect(`/table`);
209
+ res.redirect(redirectTarget || `/table`);
204
210
  break;
205
211
  case "triggers":
206
- res.redirect(`/actions`);
212
+ res.redirect(redirectTarget || `/actions`);
207
213
  break;
208
214
 
209
215
  default:
@@ -1134,3 +1134,41 @@ describe("legacy relations with relation path", () => {
1134
1134
  .expect(toInclude("Delete"));
1135
1135
  });
1136
1136
  });
1137
+
1138
+ describe("identical fields", () => {
1139
+ it("runs a post with an author array ", async () => {
1140
+ const app = await getApp({ disableCsrf: true });
1141
+ const loginCookie = await getAdminLoginCookie();
1142
+ await request(app)
1143
+ .post("/view/authoredit_identicals")
1144
+ .set("Cookie", loginCookie)
1145
+ .send("author=Charles&author=Charles")
1146
+ .expect(toRedirect("/view/authorlist"));
1147
+ const table = Table.findOne({ name: "books" });
1148
+ const rows = await table.getRows();
1149
+ expect(rows).toContainEqual({
1150
+ id: 3,
1151
+ author: "Charles",
1152
+ pages: 678,
1153
+ publisher: null,
1154
+ });
1155
+ });
1156
+
1157
+ it("runs a post with only one author", async () => {
1158
+ const app = await getApp({ disableCsrf: true });
1159
+ const loginCookie = await getAdminLoginCookie();
1160
+ await request(app)
1161
+ .post("/view/authoredit_identicals")
1162
+ .set("Cookie", loginCookie)
1163
+ .send("author=Fjodor")
1164
+ .expect(toRedirect("/view/authorlist"));
1165
+ const table = Table.findOne({ name: "books" });
1166
+ const rows = await table.getRows();
1167
+ expect(rows).toContainEqual({
1168
+ id: 4,
1169
+ author: "Fjodor",
1170
+ pages: 678,
1171
+ publisher: null,
1172
+ });
1173
+ });
1174
+ });