@saltcorn/server 0.9.4-beta.1 → 0.9.4-beta.11

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.
@@ -29,9 +29,17 @@ const groupPropsForm = async (req, isNew) => {
29
29
  ...(isNew
30
30
  ? {}
31
31
  : {
32
- onChange: `saveAndContinue(this, (res) => {
33
- history.replaceState(null, '', res.responseJSON.row.name);
34
- });`,
32
+ onChange: `
33
+ saveAndContinue(this, (res) => {
34
+ history.replaceState(null, '', res.responseJSON.row.name);
35
+ const arrowsVisible = $('#upDownArrowsId').length > 0;
36
+ if (
37
+ arrowsVisible && res.responseJSON.row.random_allocation ||
38
+ !arrowsVisible && !res.responseJSON.row.random_allocation
39
+ ) {
40
+ window.location.reload();
41
+ }
42
+ });`,
35
43
  }),
36
44
  noSubmitButton: !isNew,
37
45
  fields: [
@@ -68,65 +76,78 @@ const groupPropsForm = async (req, isNew) => {
68
76
  input_type: "select",
69
77
  options: roles.map((r) => ({ value: r.id, label: r.role })),
70
78
  },
79
+ {
80
+ name: "random_allocation",
81
+ label: req.__("Random allocation"),
82
+ type: "Bool",
83
+ sublabel: req.__(
84
+ "Serve a random page, ignoring the eligible formula. " +
85
+ "Within a session, reloads will always deliver the same page. " +
86
+ "This is a basic requirement for A/B testing."
87
+ ),
88
+ },
71
89
  ],
72
90
  });
73
91
  };
74
92
 
75
- const memberForm = async (action, req, groupName, pageValidator) => {
93
+ const memberForm = async (action, req, group, pageValidator) => {
76
94
  const pageOptions = (await Page.find()).map((p) => p.name);
77
- return new Form({
78
- action,
79
- fields: [
80
- {
81
- name: "page_name",
82
- label: req.__("Page"),
83
- sublabel: req.__("Page to be served"),
84
- type: "String",
85
- required: true,
86
- validator: pageValidator,
87
- attributes: {
88
- options: pageOptions,
89
- },
90
- },
91
- {
92
- name: "description",
93
- label: req.__("Description"),
94
- type: "String",
95
- sublabel: req.__("A description of the group member"),
95
+ const fields = [
96
+ {
97
+ name: "page_name",
98
+ label: req.__("Page"),
99
+ sublabel: req.__("Page to be served"),
100
+ type: "String",
101
+ required: true,
102
+ validator: pageValidator,
103
+ attributes: {
104
+ options: pageOptions,
96
105
  },
97
- {
98
- name: "eligible_formula",
99
- label: req.__("Eligible Formula"),
100
- sublabel:
101
- req.__("Formula to determine if this page should be served.") +
102
- br() +
103
- span(
104
- "Variables in scope: ",
105
- [
106
- "width",
107
- "height",
108
- "innerWidth",
109
- "innerHeight",
110
- "user",
111
- "locale",
112
- "device",
113
- ]
114
- .map((f) => code(f))
115
- .join(", ")
116
- ),
117
- help: {
118
- topic: "Eligible Formula",
119
- },
120
- type: "String",
121
- required: true,
122
- class: "validate-expression",
106
+ },
107
+ {
108
+ name: "description",
109
+ label: req.__("Description"),
110
+ type: "String",
111
+ sublabel: req.__("A description of the group member"),
112
+ },
113
+ ];
114
+ if (!group.random_allocation) {
115
+ fields.push({
116
+ name: "eligible_formula",
117
+ label: req.__("Eligible Formula"),
118
+ sublabel:
119
+ req.__("Formula to determine if this page should be served.") +
120
+ br() +
121
+ span(
122
+ "Variables in scope: ",
123
+ [
124
+ "width",
125
+ "height",
126
+ "innerWidth",
127
+ "innerHeight",
128
+ "user",
129
+ "locale",
130
+ "device",
131
+ ]
132
+ .map((f) => code(f))
133
+ .join(", ")
134
+ ),
135
+ help: {
136
+ topic: "Eligible Formula",
123
137
  },
124
- ],
138
+ type: "String",
139
+ required: true,
140
+ class: "validate-expression",
141
+ });
142
+ }
143
+ return new Form({
144
+ action,
145
+ fields,
125
146
  additionalButtons: [
126
147
  {
127
148
  label: req.__("Cancel"),
128
149
  class: "btn btn-primary",
129
- onclick: `cancelMemberEdit('${groupName}');`,
150
+ onclick: `cancelMemberEdit('${group.name}');`,
130
151
  },
131
152
  ],
132
153
  });
@@ -142,7 +163,7 @@ const editMemberForm = async (member, req) => {
142
163
  return await memberForm(
143
164
  `/page_groupedit/edit-member/${member.id}`,
144
165
  req,
145
- group.name,
166
+ group,
146
167
  validator
147
168
  );
148
169
  };
@@ -156,7 +177,7 @@ const addMemberForm = async (group, req) => {
156
177
  return await memberForm(
157
178
  `/page_groupedit/add-member/${group.name}`,
158
179
  req,
159
- group.name,
180
+ group,
160
181
  validator
161
182
  );
162
183
  };
@@ -224,7 +245,7 @@ const pageGroupMembers = async (pageGroup, req) => {
224
245
  if (members.length <= 1) return "";
225
246
  else
226
247
  return div(
227
- { class: "container" },
248
+ { class: "container", id: "upDownArrowsId" },
228
249
  div(
229
250
  { class: "row" },
230
251
  div(
@@ -266,38 +287,38 @@ const pageGroupMembers = async (pageGroup, req) => {
266
287
  )
267
288
  );
268
289
  };
269
-
270
- return mkTable(
271
- [
272
- {
273
- label: req.__("Page"),
274
- key: (r) =>
275
- link(`/page/${pageIdToName[r.page_id]}`, pageIdToName[r.page_id]),
276
- },
277
- {
278
- label: "",
279
- key: (r) => upDownBtns(r, req),
280
- },
281
- {
282
- label: req.__("Edit"),
283
- key: (member) =>
284
- link(`/page_groupedit/edit-member/${member.id}`, req.__("Edit")),
285
- },
286
- {
287
- label: req.__("Delete"),
288
- key: (member) =>
289
- post_delete_btn(
290
- `/page_groupedit/remove-member/${member.id}`,
291
- req,
292
- req.__("Member %s", member.sequence)
293
- ),
294
- },
295
- ],
296
- members,
290
+ const tblArr = [
291
+ {
292
+ label: req.__("Page"),
293
+ key: (r) =>
294
+ link(`/page/${pageIdToName[r.page_id]}`, pageIdToName[r.page_id]),
295
+ },
296
+ ];
297
+ if (!pageGroup.random_allocation) {
298
+ tblArr.push({
299
+ label: "",
300
+ key: (r) => upDownBtns(r, req),
301
+ });
302
+ }
303
+ tblArr.push(
297
304
  {
298
- hover: true,
305
+ label: req.__("Edit"),
306
+ key: (member) =>
307
+ link(`/page_groupedit/edit-member/${member.id}`, req.__("Edit")),
308
+ },
309
+ {
310
+ label: req.__("Delete"),
311
+ key: (member) =>
312
+ post_delete_btn(
313
+ `/page_groupedit/remove-member/${member.id}`,
314
+ req,
315
+ req.__("Member %s", member.sequence)
316
+ ),
299
317
  }
300
318
  );
319
+ return mkTable(tblArr, members, {
320
+ hover: true,
321
+ });
301
322
  };
302
323
 
303
324
  /**
@@ -21,6 +21,8 @@ const { getViews, traverseSync } = require("@saltcorn/data/models/layout");
21
21
  const { add_to_menu } = require("@saltcorn/admin-models/models/pack");
22
22
  const db = require("@saltcorn/data/db");
23
23
  const { getPageList, getPageGroupList } = require("./common_lists");
24
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
25
+ const Tag = require("@saltcorn/data/models/tag");
24
26
 
25
27
  const {
26
28
  isAdmin,
@@ -293,7 +295,18 @@ router.get(
293
295
  "/",
294
296
  isAdmin,
295
297
  error_catcher(async (req, res) => {
296
- const pages = await Page.find({}, { orderBy: "name", nocase: true });
298
+ const pageq = {};
299
+ let filterOnTag;
300
+
301
+ if (req.query._tag) {
302
+ const tagEntries = await TagEntry.find({
303
+ tag_id: +req.query._tag,
304
+ not: { page_id: null },
305
+ });
306
+ pageq.id = { in: tagEntries.map((te) => te.page_id).filter(Boolean) };
307
+ filterOnTag = await Tag.findOne({ id: +req.query._tag });
308
+ }
309
+ const pages = await Page.find(pageq, { orderBy: "name", nocase: true });
297
310
  const pageGroups = await PageGroup.find(
298
311
  {},
299
312
  { orderBy: "name", nocase: true }
@@ -311,7 +324,7 @@ router.get(
311
324
  title: req.__("Your pages"),
312
325
  class: "mt-0",
313
326
  contents: div(
314
- getPageList(pages, roles, req),
327
+ await getPageList(pages, roles, req, { filterOnTag }),
315
328
  a(
316
329
  {
317
330
  href: `/pageedit/new`,
@@ -370,7 +383,7 @@ const wrap = (contents, noCard, req, page) => ({
370
383
  crumbs: [
371
384
  { text: req.__("Pages"), href: "/pageedit" },
372
385
  page
373
- ? { href: `/page/${page.name}`, text: page.name }
386
+ ? { href: `/page/${encodeURIComponent(page.name)}`, text: page.name }
374
387
  : { text: req.__("New") },
375
388
  ],
376
389
  },
@@ -677,9 +690,11 @@ router.post(
677
690
 
678
691
  if (id && req.body.layout) {
679
692
  await Page.update(+id, { layout: req.body.layout });
680
- res.json({ success: "ok" });
693
+ res.json({
694
+ success: "ok",
695
+ });
681
696
  } else {
682
- res.json({ error: "no page or no layout." });
697
+ res.json({ error: req.__("Unable to save: No page or no layout") });
683
698
  }
684
699
  })
685
700
  );
package/routes/tables.js CHANGED
@@ -13,6 +13,7 @@ const View = require("@saltcorn/data/models/view");
13
13
  const User = require("@saltcorn/data/models/user");
14
14
  const Model = require("@saltcorn/data/models/model");
15
15
  const Trigger = require("@saltcorn/data/models/trigger");
16
+ const TagEntry = require("@saltcorn/data/models/tag_entry");
16
17
  const {
17
18
  mkTable,
18
19
  renderForm,
@@ -59,10 +60,12 @@ const { tablesList, viewsList } = require("./common_lists");
59
60
  const {
60
61
  InvalidConfiguration,
61
62
  removeAllWhiteSpace,
63
+ comparingCaseInsensitive,
62
64
  } = require("@saltcorn/data/utils");
63
65
  const { EOL } = require("os");
64
66
 
65
67
  const path = require("path");
68
+ const Tag = require("@saltcorn/data/models/tag");
66
69
  /**
67
70
  * @type {object}
68
71
  * @const
@@ -714,6 +717,7 @@ router.get(
714
717
  ...new Set(child_relations.map(({ table }) => table.name)),
715
718
  ];
716
719
  const triggers = table.id ? Trigger.find({ table_id: table.id }) : [];
720
+ triggers.sort(comparingCaseInsensitive("name"));
717
721
  let fieldCard;
718
722
  if (fields.length === 0) {
719
723
  fieldCard = [
@@ -785,7 +789,16 @@ router.get(
785
789
  triggers.length
786
790
  ? req.__("Table triggers: ") +
787
791
  triggers
788
- .map((t) => link(`/actions/configure/${t.id}`, t.name))
792
+ .map((t) =>
793
+ link(
794
+ `/actions/configure/${
795
+ t.id
796
+ }?on_done_redirect=${encodeURIComponent(
797
+ `table/${table.name}`
798
+ )}`,
799
+ t.name
800
+ )
801
+ )
789
802
  .join(", ") +
790
803
  "<br>"
791
804
  : "",
@@ -1211,13 +1224,23 @@ router.get(
1211
1224
  "/",
1212
1225
  isAdmin,
1213
1226
  error_catcher(async (req, res) => {
1214
- const rows = await Table.find_with_external(
1215
- {},
1216
- { orderBy: "name", nocase: true }
1217
- );
1227
+ const tblq = {};
1228
+ let filterOnTag;
1229
+ if (req.query._tag) {
1230
+ const tagEntries = await TagEntry.find({
1231
+ tag_id: +req.query._tag,
1232
+ not: { table_id: null },
1233
+ });
1234
+ tblq.id = { in: tagEntries.map((te) => te.table_id).filter(Boolean) };
1235
+ filterOnTag = await Tag.findOne({ id: +req.query._tag });
1236
+ }
1237
+ const rows = await Table.find_with_external(tblq, {
1238
+ orderBy: "name",
1239
+ nocase: true,
1240
+ });
1218
1241
  const roles = await User.get_roles();
1219
1242
  const getRole = (rid) => roles.find((r) => r.id === rid).role;
1220
- const mainCard = await tablesList(rows, req);
1243
+ const mainCard = await tablesList(rows, req, { filterOnTag });
1221
1244
  const createCard = div(
1222
1245
  a(
1223
1246
  { href: `/table/new`, class: "btn btn-primary mt-1 me-3" },
@@ -5,6 +5,7 @@ const {
5
5
  select,
6
6
  option,
7
7
  label,
8
+ text,
8
9
  } = require("@saltcorn/markup/tags");
9
10
 
10
11
  const Tag = require("@saltcorn/data/models/tag");
@@ -94,15 +95,21 @@ router.get(
94
95
  isAdmin,
95
96
  error_catcher(async (req, res) => {
96
97
  const { entry_type, tag_id } = req.params;
97
- res.sendWrap(req.__("Add %s to tag"), {
98
+ const tag = await Tag.findOne({ id: tag_id });
99
+
100
+ res.sendWrap(req.__("Add %s to tag %s", entry_type, tag.name), {
98
101
  above: [
99
102
  {
100
103
  type: "breadcrumbs",
101
- crumbs: [{ text: req.__(`Tag entry`) }],
104
+ crumbs: [
105
+ { text: req.__(`Tags`), href: "/tag" },
106
+ { text: tag.name, href: `/tag/${tag.id}` },
107
+ { text: req.__(`Add %s`, text(entry_type)) },
108
+ ],
102
109
  },
103
110
  {
104
111
  type: "card",
105
- title: req.__(`Add entries to tag`),
112
+ title: req.__(`Add entries to tag %s`, tag.name),
106
113
  contents: buildForm(
107
114
  entry_type,
108
115
  tag_id,
@@ -148,9 +155,10 @@ router.post(
148
155
  req.flash("error", req.__("Please select at least one item"));
149
156
  return res.redirect(`/tag-entries/add/${entry_type}/${tag_id}`);
150
157
  }
158
+ const ids_array = Array.isArray(ids) ? ids : [ids];
151
159
  const fieldName = idField(entry_type);
152
160
  const tag = await Tag.findOne({ id: tag_id });
153
- for (const id of ids) {
161
+ for (const id of ids_array) {
154
162
  await tag.addEntry({ [fieldName]: id });
155
163
  }
156
164
  res.redirect(`/tag/${tag_id}?show_list=${entry_type}`);
package/routes/tags.js CHANGED
@@ -1,9 +1,10 @@
1
- const { a, text } = require("@saltcorn/markup/tags");
1
+ const { a, text, i } = require("@saltcorn/markup/tags");
2
2
 
3
3
  const Tag = require("@saltcorn/data/models/tag");
4
4
  const Router = require("express-promise-router");
5
5
  const Form = require("@saltcorn/data/models/form");
6
6
  const User = require("@saltcorn/data/models/user");
7
+ const stream = require("stream");
7
8
 
8
9
  const { isAdmin, error_catcher, csrfField } = require("./utils");
9
10
  const { send_infoarch_page } = require("../markup/admin");
@@ -23,6 +24,10 @@ const {
23
24
  getTriggerList,
24
25
  } = require("./common_lists");
25
26
 
27
+ const db = require("@saltcorn/data/db");
28
+ const { getState } = require("@saltcorn/data/db/state");
29
+ const { create_pack_from_tag } = require("@saltcorn/admin-models/models/pack");
30
+
26
31
  const router = new Router();
27
32
  module.exports = router;
28
33
 
@@ -42,7 +47,7 @@ router.get(
42
47
  mkTable(
43
48
  [
44
49
  {
45
- label: req.__("Tagname"),
50
+ label: req.__("Tag name"),
46
51
  key: (r) =>
47
52
  link(`/tag/${r.id || r.name}?show_list=tables`, text(r.name)),
48
53
  },
@@ -57,7 +62,7 @@ router.get(
57
62
  a(
58
63
  {
59
64
  href: `/tag/new`,
60
- class: "btn btn-primary",
65
+ class: "btn btn-primary mt-3",
61
66
  },
62
67
  req.__("Create tag")
63
68
  ),
@@ -73,6 +78,13 @@ router.get(
73
78
  error_catcher(async (req, res) => {
74
79
  res.sendWrap(req.__(`New tag`), {
75
80
  above: [
81
+ {
82
+ type: "breadcrumbs",
83
+ crumbs: [
84
+ { text: req.__(`Tags`), href: "/tag" },
85
+ { text: req.__(`New`) },
86
+ ],
87
+ },
76
88
  {
77
89
  type: "card",
78
90
  title: req.__(`New tag`),
@@ -97,7 +109,26 @@ router.get(
97
109
  })
98
110
  );
99
111
 
100
- const headerWithCollapser = (title, cardId, showList) =>
112
+ router.get(
113
+ "/download-pack/:idorname",
114
+ isAdmin,
115
+ error_catcher(async (req, res) => {
116
+ const { idorname } = req.params;
117
+ const id = parseInt(idorname);
118
+ const tag = await Tag.findOne(id ? { id } : { name: idorname });
119
+ if (!tag) {
120
+ req.flash("error", req.__("Tag not found"));
121
+ return res.redirect(`/tag`);
122
+ }
123
+ const pack = await create_pack_from_tag(tag);
124
+ const readStream = new stream.PassThrough();
125
+ readStream.end(JSON.stringify(pack));
126
+ res.type("application/json");
127
+ res.attachment(`${tag.name}-pack.json`);
128
+ readStream.pipe(res);
129
+ })
130
+ );
131
+ const headerWithCollapser = (title, cardId, showList, count) =>
101
132
  a(
102
133
  {
103
134
  class: `card-header-left-collapse ${!showList ? "collapsed" : ""} ps-3`,
@@ -107,7 +138,8 @@ const headerWithCollapser = (title, cardId, showList) =>
107
138
  "aria-controls": cardId,
108
139
  role: "button",
109
140
  },
110
- title
141
+ title,
142
+ ` (${count})`
111
143
  );
112
144
 
113
145
  const isShowList = (showList, listType) => showList === listType;
@@ -139,14 +171,15 @@ router.get(
139
171
  above: [
140
172
  {
141
173
  type: "breadcrumbs",
142
- crumbs: [{ text: req.__(`Tag: %s`, tag.name) }],
174
+ crumbs: [{ text: req.__(`Tags`), href: "/tag" }, { text: tag.name }],
143
175
  },
144
176
  {
145
177
  type: "card",
146
178
  title: headerWithCollapser(
147
179
  req.__("Tables"),
148
180
  tablesDomId,
149
- isShowList(show_list, "tables")
181
+ isShowList(show_list, "tables"),
182
+ tables.length
150
183
  ),
151
184
  contents: [
152
185
  await tablesList(tables, req, {
@@ -168,7 +201,8 @@ router.get(
168
201
  title: headerWithCollapser(
169
202
  req.__("Views"),
170
203
  viewsDomId,
171
- isShowList(show_list, "views")
204
+ isShowList(show_list, "views"),
205
+ views.length
172
206
  ),
173
207
  contents: [
174
208
  await viewsList(views, req, {
@@ -190,10 +224,11 @@ router.get(
190
224
  title: headerWithCollapser(
191
225
  req.__("Pages"),
192
226
  pagesDomId,
193
- isShowList(show_list, "pages")
227
+ isShowList(show_list, "pages"),
228
+ pages.length
194
229
  ),
195
230
  contents: [
196
- getPageList(pages, roles, req, {
231
+ await getPageList(pages, roles, req, {
197
232
  tagId: tag.id,
198
233
  domId: pagesDomId,
199
234
  showList: isShowList(show_list, "pages"),
@@ -213,10 +248,11 @@ router.get(
213
248
  title: headerWithCollapser(
214
249
  req.__("Triggers"),
215
250
  triggersDomId,
216
- isShowList(show_list, "triggers")
251
+ isShowList(show_list, "triggers"),
252
+ triggers.length
217
253
  ),
218
254
  contents: [
219
- getTriggerList(triggers, req, {
255
+ await getTriggerList(triggers, req, {
220
256
  tagId: tag.id,
221
257
  domId: triggersDomId,
222
258
  showList: isShowList(show_list, "triggers"),
@@ -230,6 +266,19 @@ router.get(
230
266
  ),
231
267
  ],
232
268
  },
269
+ {
270
+ type: "card",
271
+ contents: [
272
+ a(
273
+ {
274
+ class: "btn btn-outline-primary",
275
+ href: `/tag/download-pack/${tag.id}`,
276
+ },
277
+ i({ class: "fas fa-download me-2" }),
278
+ "Download pack"
279
+ ),
280
+ ],
281
+ },
233
282
  ],
234
283
  });
235
284
  })
package/routes/utils.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  features,
14
14
  } = require("@saltcorn/data/db/state");
15
15
  const { get_base_url } = require("@saltcorn/data/models/config");
16
+ const { hash } = require("@saltcorn/data/utils");
16
17
  const { input, script, domReady } = require("@saltcorn/markup/tags");
17
18
  const session = require("express-session");
18
19
  const cookieSession = require("cookie-session");
@@ -21,6 +22,7 @@ const { validateHeaderName, validateHeaderValue } = require("http");
21
22
  const Crash = require("@saltcorn/data/models/crash");
22
23
  const File = require("@saltcorn/data/models/file");
23
24
  const User = require("@saltcorn/data/models/user");
25
+ const Page = require("@saltcorn/data/models/page");
24
26
  const si = require("systeminformation");
25
27
  const {
26
28
  config_fields_form,
@@ -30,6 +32,7 @@ const {
30
32
  } = require("../markup/admin.js");
31
33
  const path = require("path");
32
34
  const { UAParser } = require("ua-parser-js");
35
+ const crypto = require("crypto");
33
36
 
34
37
  const get_sys_info = async () => {
35
38
  const disks = await si.fsSize();
@@ -505,6 +508,21 @@ const getEligiblePage = async (pageGroup, req, res) => {
505
508
  }
506
509
  };
507
510
 
511
+ /**
512
+ * @param {PageGroup} pageGroup
513
+ * @param {any} req
514
+ * @returns the page, null or an error msg
515
+ */
516
+ const getRandomPage = (pageGroup, req) => {
517
+ if (pageGroup.members.length === 0)
518
+ return req.__("Pagegroup %s has no members", pageGroup.name);
519
+ const hash = crypto.createHash("sha1").update(req.sessionID).digest("hex");
520
+ const idx =
521
+ parseInt(hash.substring(hash.length - 4), 16) % pageGroup.members.length;
522
+ const sessionMember = pageGroup.members[idx];
523
+ return Page.findOne({ id: sessionMember.page_id });
524
+ };
525
+
508
526
  module.exports = {
509
527
  sqlsanitize,
510
528
  csrfField,
@@ -524,4 +542,5 @@ module.exports = {
524
542
  sendHtmlFile,
525
543
  setRole,
526
544
  getEligiblePage,
545
+ getRandomPage,
527
546
  };
package/routes/view.js CHANGED
@@ -71,10 +71,14 @@ router.get(
71
71
  const isModal = req.headers?.saltcornmodalrequest;
72
72
 
73
73
  const contents0 = await view.run_possibly_on_page(query, req, res);
74
- const title =
74
+ let title =
75
75
  isModal && view.attributes?.popup_title
76
76
  ? view.attributes?.popup_title
77
- : scan_for_page_title(contents0, view.name);
77
+ : view.attributes?.page_title ||
78
+ scan_for_page_title(contents0, view.name); //legacy
79
+ if ((title || "").includes("{{")) {
80
+ title = await view.interpolate_title_string(title, query);
81
+ }
78
82
  if (isModal && view.attributes?.popup_width)
79
83
  res.set(
80
84
  "SaltcornModalWidth",
@@ -82,10 +86,24 @@ router.get(
82
86
  view.attributes?.popup_width_units || "px"
83
87
  }`
84
88
  );
89
+ if (isModal && view.attributes?.popup_minwidth)
90
+ res.set(
91
+ "SaltcornModalMinWidth",
92
+ `${view.attributes?.popup_minwidth}${
93
+ view.attributes?.popup_minwidth_units || "px"
94
+ }`
95
+ );
85
96
  if (isModal && view.attributes?.popup_save_indicator)
86
97
  res.set("SaltcornModalSaveIndicator", `true`);
87
98
  if (isModal && view.attributes?.popup_link_out)
88
99
  res.set("SaltcornModalLinkOut", `true`);
100
+ if (view.attributes?.page_description) {
101
+ let description = view.attributes?.page_description;
102
+ if ((description || "").includes("{{")) {
103
+ description = await view.interpolate_title_string(description, query);
104
+ }
105
+ title = { title, description };
106
+ }
89
107
  const tock = new Date();
90
108
  const ms = tock.getTime() - tic.getTime();
91
109
  if (!isTest())