@saltcorn/server 1.0.0-beta.9 → 1.0.0-rc.1

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/routes/admin.js CHANGED
@@ -309,13 +309,13 @@ router.get(
309
309
  backupForm.values.auto_backup_expire_days = getState().getConfig(
310
310
  "auto_backup_expire_days"
311
311
  );
312
- backupForm.values.backup_with_event_log = getState().getConfig(
312
+ aBackupFilePrefixForm.values.backup_with_event_log = getState().getConfig(
313
313
  "backup_with_event_log"
314
314
  );
315
- backupForm.values.backup_with_system_zip = getState().getConfig(
315
+ aBackupFilePrefixForm.values.backup_with_system_zip = getState().getConfig(
316
316
  "backup_with_system_zip"
317
317
  );
318
- backupForm.values.backup_system_zip_level = getState().getConfig(
318
+ aBackupFilePrefixForm.values.backup_system_zip_level = getState().getConfig(
319
319
  "backup_system_zip_level"
320
320
  );
321
321
  //
@@ -378,7 +378,9 @@ router.get(
378
378
  : { type: "blank", contents: "" },
379
379
  {
380
380
  type: "card",
381
- title: req.__("Snapshots"),
381
+ title:
382
+ req.__("Snapshots") +
383
+ `<a href="javascript:ajax_modal('/admin/help/Snapshots?')"><i class="fas fa-question-circle ms-1"></i></a>`,
382
384
  titleAjaxIndicator: true,
383
385
  contents: div(
384
386
  p(
@@ -673,7 +675,10 @@ router.get(
673
675
  const backup_file_prefix = getState().getConfig("backup_file_prefix");
674
676
  if (
675
677
  !isRoot ||
676
- !(filename.startsWith(backup_file_prefix) && filename.endsWith(".zip"))
678
+ !(
679
+ path.resolve(filename).startsWith(backup_file_prefix) &&
680
+ filename.endsWith(".zip")
681
+ )
677
682
  ) {
678
683
  res.redirect("/admin/backup");
679
684
  return;
@@ -708,6 +713,33 @@ const backupFilePrefixForm = (req) =>
708
713
  sublabel: req.__("Include table history in backup"),
709
714
  default: true,
710
715
  },
716
+ {
717
+ type: "Bool",
718
+ label: req.__("Include Event Logs"),
719
+ sublabel: req.__("Backup with event logs"),
720
+ name: "backup_with_event_log",
721
+ },
722
+ {
723
+ type: "Bool",
724
+ label: req.__("Use system zip"),
725
+ sublabel: req.__(
726
+ "Recommended. Executable <code>zip</code> must be installed"
727
+ ),
728
+ name: "backup_with_system_zip",
729
+ },
730
+ {
731
+ type: "Integer",
732
+ label: req.__("Zip compression level"),
733
+ sublabel: req.__("1=Fast, larger file, 9=Slow, smaller files"),
734
+ name: "backup_system_zip_level",
735
+ attributes: {
736
+ min: 1,
737
+ max: 9,
738
+ },
739
+ showIf: {
740
+ backup_with_system_zip: true,
741
+ },
742
+ },
711
743
  ],
712
744
  });
713
745
 
@@ -835,40 +867,6 @@ const autoBackupForm = (req) => {
835
867
  },
836
868
  ]
837
869
  : []),
838
- {
839
- type: "Bool",
840
- label: req.__("Include Event Logs"),
841
- sublabel: req.__("Backup with event logs"),
842
- name: "backup_with_event_log",
843
- showIf: {
844
- auto_backup_frequency: ["Daily", "Weekly"],
845
- },
846
- },
847
- {
848
- type: "Bool",
849
- label: req.__("Use system zip"),
850
- sublabel: req.__(
851
- "Recommended. Executable <code>zip</code> must be installed"
852
- ),
853
- name: "backup_with_system_zip",
854
- showIf: {
855
- auto_backup_frequency: ["Daily", "Weekly"],
856
- },
857
- },
858
- {
859
- type: "Integer",
860
- label: req.__("Zip compression level"),
861
- sublabel: req.__("1=Fast, larger file, 9=Slow, smaller files"),
862
- name: "backup_system_zip_level",
863
- attributes: {
864
- min: 1,
865
- max: 9,
866
- },
867
- showIf: {
868
- auto_backup_frequency: ["Daily", "Weekly"],
869
- backup_with_system_zip: true,
870
- },
871
- },
872
870
  ],
873
871
  });
874
872
  };
@@ -1270,6 +1268,50 @@ const pullCordovaBuilder = (req, res) => {
1270
1268
  });
1271
1269
  };
1272
1270
 
1271
+ const tryInstallSdNotify = (req, res) => {
1272
+ const child = spawn("npm", ["install", "-g", "sd-notify"], {
1273
+ stdio: ["ignore", "pipe", "pipe"],
1274
+ });
1275
+ return new Promise((resolve, reject) => {
1276
+ child.stdout.on("data", (data) => {
1277
+ res.write(data);
1278
+ });
1279
+ child.stderr?.on("data", (data) => {
1280
+ res.write(data);
1281
+ });
1282
+ child.on("exit", function (code, signal) {
1283
+ resolve(code);
1284
+ });
1285
+ child.on("error", (msg) => {
1286
+ const message = msg.message ? msg.message : msg.code;
1287
+ res.write(req.__("Error: ") + message + "\n");
1288
+ resolve(msg.code);
1289
+ });
1290
+ });
1291
+ };
1292
+
1293
+ const pruneDocker = (req, res) => {
1294
+ const child = spawn("docker", ["image", "prune", "-f"], {
1295
+ stdio: ["ignore", "pipe", "pipe"],
1296
+ });
1297
+ return new Promise((resolve, reject) => {
1298
+ child.stdout.on("data", (data) => {
1299
+ res.write(data);
1300
+ });
1301
+ child.stderr?.on("data", (data) => {
1302
+ res.write(data);
1303
+ });
1304
+ child.on("exit", function (code, signal) {
1305
+ resolve(code);
1306
+ });
1307
+ child.on("error", (msg) => {
1308
+ const message = msg.message ? msg.message : msg.code;
1309
+ res.write(req.__("Error: ") + message + "\n");
1310
+ resolve(msg.code);
1311
+ });
1312
+ });
1313
+ };
1314
+
1273
1315
  /*
1274
1316
  * fetch available saltcorn versions and show a dialog to select one
1275
1317
  */
@@ -1285,6 +1327,7 @@ router.get(
1285
1327
  throw new Error(req.__("Unable to fetch versions"));
1286
1328
  const versions = Object.keys(pkgInfo.versions);
1287
1329
  if (versions.length === 0) throw new Error(req.__("No versions found"));
1330
+ const tags = pkgInfo["dist-tags"] || {};
1288
1331
  res.set("Page-Title", req.__("%s versions", "Saltcorn"));
1289
1332
  let selected = packagejson.version;
1290
1333
  res.send(
@@ -1294,6 +1337,7 @@ router.get(
1294
1337
  method: "post",
1295
1338
  },
1296
1339
  input({ type: "hidden", name: "_csrf", value: req.csrfToken() }),
1340
+ // version select
1297
1341
  div(
1298
1342
  { class: "form-group" },
1299
1343
  label(
@@ -1319,6 +1363,54 @@ router.get(
1319
1363
  )
1320
1364
  )
1321
1365
  ),
1366
+ // tag select
1367
+ div(
1368
+ { class: "form-group" },
1369
+ label(
1370
+ {
1371
+ for: "tag_select",
1372
+ class: "form-label fw-bold",
1373
+ },
1374
+ req.__("Tags")
1375
+ ),
1376
+ select(
1377
+ {
1378
+ id: "tag_select",
1379
+ class: "form-control form-select",
1380
+ },
1381
+ option({
1382
+ id: "empty_opt",
1383
+ value: "",
1384
+ label: req.__("Select tag"),
1385
+ selected: true,
1386
+ }),
1387
+ Object.keys(tags).map((tag) =>
1388
+ option({
1389
+ id: `${tag}_opt`,
1390
+ value: tags[tag],
1391
+ label: `${tag} (${tags[tag]})`,
1392
+ })
1393
+ )
1394
+ )
1395
+ ),
1396
+ // deep clean checkbox
1397
+ div(
1398
+ { class: "form-group" },
1399
+ input({
1400
+ id: "deep_clean",
1401
+ class: "form-check-input",
1402
+ type: "checkbox",
1403
+ name: "deep_clean",
1404
+ checked: false,
1405
+ }),
1406
+ label(
1407
+ {
1408
+ for: "deep_clean",
1409
+ class: "form-label ms-2",
1410
+ },
1411
+ req.__("clean node_modules")
1412
+ )
1413
+ ),
1322
1414
  div(
1323
1415
  { class: "d-flex justify-content-end" },
1324
1416
  button(
@@ -1338,7 +1430,18 @@ router.get(
1338
1430
  req.__("Install")
1339
1431
  )
1340
1432
  )
1341
- )
1433
+ ) +
1434
+ script(
1435
+ domReady(`
1436
+ document.getElementById('tag_select').addEventListener('change', () => {
1437
+ const version = document.getElementById('tag_select').value;
1438
+ if (version) document.getElementById('version_select').value = version;
1439
+ });
1440
+ document.getElementById('version_select').addEventListener('change', () => {
1441
+ document.getElementById('tag_select').value = '';
1442
+ });
1443
+ `)
1444
+ )
1342
1445
  );
1343
1446
  } catch (error) {
1344
1447
  getState().log(
@@ -1350,7 +1453,17 @@ router.get(
1350
1453
  })
1351
1454
  );
1352
1455
 
1353
- const doInstall = async (req, res, version, runPull) => {
1456
+ const cleanNodeModules = async () => {
1457
+ const topSaltcornDir = path.join(__dirname, "..", "..", "..", "..", "..");
1458
+ if (path.basename(topSaltcornDir) === "@saltcorn")
1459
+ await fs.promises.rm(topSaltcornDir, { recursive: true, force: true });
1460
+ else
1461
+ throw new Error(
1462
+ `'${topSaltcornDir}' is not a Saltcorn installation directory`
1463
+ );
1464
+ };
1465
+
1466
+ const doInstall = async (req, res, version, deepClean, runPull) => {
1354
1467
  if (db.getTenantSchema() !== db.connectObj.default_schema) {
1355
1468
  req.flash("error", req.__("Not possible for tenant"));
1356
1469
  res.redirect("/admin");
@@ -1360,6 +1473,14 @@ const doInstall = async (req, res, version, runPull) => {
1360
1473
  ? req.__("Starting upgrade, please wait...\n")
1361
1474
  : req.__("Installing %s, please wait...\n", version)
1362
1475
  );
1476
+ if (deepClean) {
1477
+ res.write(req.__("Cleaning node_modules...\n"));
1478
+ try {
1479
+ await cleanNodeModules();
1480
+ } catch (e) {
1481
+ res.write(req.__("Error cleaning node_modules: %s\n", e.message));
1482
+ }
1483
+ }
1363
1484
  const child = spawn(
1364
1485
  "npm",
1365
1486
  ["install", "-g", `@saltcorn/cli@${version}`, "--unsafe"],
@@ -1374,10 +1495,26 @@ const doInstall = async (req, res, version, runPull) => {
1374
1495
  res.write(data);
1375
1496
  });
1376
1497
  child.on("exit", async function (code, signal) {
1377
- if (code === 0 && runPull) {
1378
- res.write(req.__("Pulling the cordova-builder docker image...") + "\n");
1379
- const pullCode = await pullCordovaBuilder(req, res);
1380
- res.write(req.__("Pull done with code %s", pullCode) + "\n");
1498
+ if (code === 0) {
1499
+ if (deepClean) {
1500
+ res.write(req.__("Installing sd-notify") + "\n");
1501
+ const sdNotifyCode = await tryInstallSdNotify(req, res);
1502
+ res.write(
1503
+ req.__("sd-notify install done with code %s", sdNotifyCode) + "\n"
1504
+ );
1505
+ }
1506
+ if (runPull) {
1507
+ res.write(
1508
+ req.__("Pulling the cordova-builder docker image...") + "\n"
1509
+ );
1510
+ const pullCode = await pullCordovaBuilder(req, res);
1511
+ res.write(req.__("Pull done with code %s", pullCode) + "\n");
1512
+ if (pullCode === 0) {
1513
+ res.write(req.__("Pruning docker...") + "\n");
1514
+ const pruneCode = await pruneDocker(req, res);
1515
+ res.write(req.__("Prune done with code %s", pruneCode) + "\n");
1516
+ }
1517
+ }
1381
1518
  }
1382
1519
  res.end(
1383
1520
  version === "latest"
@@ -1397,8 +1534,8 @@ const doInstall = async (req, res, version, runPull) => {
1397
1534
  };
1398
1535
 
1399
1536
  router.post("/install", isAdmin, async (req, res) => {
1400
- const { version } = req.body;
1401
- await doInstall(req, res, version, false);
1537
+ const { version, deep_clean } = req.body;
1538
+ await doInstall(req, res, version, deep_clean === "on", false);
1402
1539
  });
1403
1540
 
1404
1541
  /**
@@ -1411,7 +1548,7 @@ router.post(
1411
1548
  "/upgrade",
1412
1549
  isAdmin,
1413
1550
  error_catcher(async (req, res) => {
1414
- await doInstall(req, res, "latest", true);
1551
+ await doInstall(req, res, "latest", false, true);
1415
1552
  })
1416
1553
  );
1417
1554
  /**
@@ -1749,9 +1886,10 @@ router.get(
1749
1886
  });
1750
1887
  })
1751
1888
  );
1752
- const buildDialogScript = (cordovaBuilderAvailable) => {
1753
- return `<script>
1889
+ const buildDialogScript = (cordovaBuilderAvailable, isSbadmin2) =>
1890
+ `<script>
1754
1891
  var cordovaBuilderAvailable = ${cordovaBuilderAvailable};
1892
+ var isSbadmin2 = ${isSbadmin2};
1755
1893
  function showEntrySelect(type) {
1756
1894
  for( const currentType of ["view", "page", "pagegroup"]) {
1757
1895
  const tab = $('#' + currentType + 'NavLinkID');
@@ -1776,7 +1914,6 @@ const buildDialogScript = (cordovaBuilderAvailable) => {
1776
1914
  notifyAlert("Building the app, please wait.", true)
1777
1915
  }
1778
1916
  </script>`;
1779
- };
1780
1917
 
1781
1918
  const imageAvailable = async () => {
1782
1919
  try {
@@ -1834,19 +1971,29 @@ router.get(
1834
1971
  const plugins = (await Plugin.find()).filter(
1835
1972
  (plugin) => ["base", "sbadmin2"].indexOf(plugin.name) < 0
1836
1973
  );
1974
+ const pluginsReadyForMobile = plugins
1975
+ .filter((plugin) => plugin.ready_for_mobile())
1976
+ .map((plugin) => plugin.name);
1837
1977
  const builderSettings =
1838
1978
  getState().getConfig("mobile_builder_settings") || {};
1839
1979
  const dockerAvailable = await imageAvailable();
1840
1980
  const xcodeCheckRes = await checkXcodebuild();
1841
1981
  const xcodebuildAvailable = xcodeCheckRes.installed;
1842
1982
  const xcodebuildVersion = xcodeCheckRes.version;
1983
+ const layout = getState().getLayout(req.user);
1984
+ const isSbadmin2 = layout === getState().layouts.sbadmin2;
1843
1985
  send_admin_page({
1844
1986
  res,
1845
1987
  req,
1846
1988
  active_sub: "Mobile app",
1847
1989
  headers: [
1848
1990
  {
1849
- headerTag: buildDialogScript(dockerAvailable),
1991
+ headerTag: buildDialogScript(dockerAvailable, isSbadmin2),
1992
+ },
1993
+ {
1994
+ headerTag: `<script>var pluginsReadyForMobile = ${JSON.stringify(
1995
+ pluginsReadyForMobile
1996
+ )}</script>`,
1850
1997
  },
1851
1998
  ],
1852
1999
  contents: {
@@ -2884,17 +3031,66 @@ router.get(
2884
3031
  })
2885
3032
  );
2886
3033
 
3034
+ const validateBuildDirName = (buildDirName) => {
3035
+ // ensure characters
3036
+ if (!/^[a-zA-Z0-9_-]+$/.test(buildDirName)) {
3037
+ getState().log(
3038
+ 4,
3039
+ `Invalid characters in build directory name '${buildDirName}'`
3040
+ );
3041
+ return false;
3042
+ }
3043
+ // ensure format is 'build_1234567890'
3044
+ if (!/^build_\d+$/.test(buildDirName)) {
3045
+ getState().log(4, `Invalid build directory name format '${buildDirName}'`);
3046
+ return false;
3047
+ }
3048
+ return true;
3049
+ };
3050
+
3051
+ const validateBuildDir = (buildDir, rootPath) => {
3052
+ const resolvedBuildDir = path.resolve(buildDir);
3053
+ if (!resolvedBuildDir.startsWith(path.join(rootPath, "mobile_app"))) {
3054
+ getState().log(4, `Invalid build directory path '${buildDir}'`);
3055
+ return false;
3056
+ }
3057
+ return true;
3058
+ };
3059
+
2887
3060
  router.get(
2888
3061
  "/build-mobile-app/result",
2889
3062
  isAdmin,
2890
3063
  error_catcher(async (req, res) => {
2891
3064
  const { build_dir_name } = req.query;
3065
+ if (!validateBuildDirName(build_dir_name)) {
3066
+ return res.sendWrap(req.__(`Admin`), {
3067
+ above: [
3068
+ {
3069
+ type: "card",
3070
+ title: req.__("Build Result"),
3071
+ contents: div(req.__("Invalid build directory name")),
3072
+ },
3073
+ ],
3074
+ });
3075
+ }
2892
3076
  const rootFolder = await File.rootFolder();
2893
3077
  const buildDir = path.join(
2894
3078
  rootFolder.location,
2895
3079
  "mobile_app",
2896
3080
  build_dir_name
2897
3081
  );
3082
+ if (!validateBuildDir(buildDir, rootFolder.location)) {
3083
+ return res.sendWrap(req.__(`Admin`), {
3084
+ above: [
3085
+ {
3086
+ type: "card",
3087
+ title: req.__("Build Result"),
3088
+ contents: div(req.__("Invalid build directory path")),
3089
+ },
3090
+ ],
3091
+ });
3092
+ }
3093
+
2898
3094
  const files = await Promise.all(
2899
3095
  fs
2900
3096
  .readdirSync(buildDir)
@@ -2947,6 +3143,11 @@ router.post(
2947
3143
  } = req.body;
2948
3144
  if (!includedPlugins) includedPlugins = [];
2949
3145
  if (!synchedTables) synchedTables = [];
3146
+ if (!entryPoint) {
3147
+ return res.json({
3148
+ error: req.__("Please select an entry point."),
3149
+ });
3150
+ }
2950
3151
  if (!androidPlatform && !iOSPlatform) {
2951
3152
  return res.json({
2952
3153
  error: req.__("Please select at least one platform (android or iOS)."),
@@ -3093,8 +3294,8 @@ router.post(
3093
3294
  error_catcher(async (req, res) => {
3094
3295
  const state = getState();
3095
3296
  const child = spawn(
3096
- "docker",
3097
- ["image", "pull", "saltcorn/cordova-builder:latest"],
3297
+ `${process.env.DOCKER_BIN ? `${process.env.DOCKER_BIN}/` : ""}docker`,
3298
+ ["pull", "saltcorn/cordova-builder:latest"],
3098
3299
  {
3099
3300
  stdio: ["ignore", "pipe", "pipe"],
3100
3301
  cwd: ".",
@@ -3545,6 +3746,7 @@ router.get(
3545
3746
  send_admin_page({
3546
3747
  res,
3547
3748
  req,
3749
+ page_title: req.__(`%s code page`, name),
3548
3750
  active_sub: "Development",
3549
3751
  sub2_page: req.__(`%s code page`, name),
3550
3752
  contents: {
@@ -41,6 +41,7 @@ const {
41
41
  i,
42
42
  th,
43
43
  pre,
44
+ text,
44
45
  } = require("@saltcorn/markup/tags");
45
46
  const Table = require("@saltcorn/data/models/table");
46
47
  const { send_events_page } = require("../markup/admin.js");
@@ -442,7 +443,7 @@ router.get(
442
443
  ) +
443
444
  div(
444
445
  { class: "eventpayload" },
445
- ev.payload ? pre(JSON.stringify(ev.payload, null, 2)) : ""
446
+ ev.payload ? pre(text(JSON.stringify(ev.payload, null, 2))) : ""
446
447
  ),
447
448
  },
448
449
  });
package/routes/fields.js CHANGED
@@ -136,6 +136,9 @@ const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
136
136
  sublabel: req.__("Calculated from other fields with a formula"),
137
137
  type: "Bool",
138
138
  disabled: !!id,
139
+ help: {
140
+ topic: "Calculated fields",
141
+ },
139
142
  }),
140
143
  new Field({
141
144
  label: req.__("Required"),
@@ -169,6 +172,9 @@ const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
169
172
  type: "Bool",
170
173
  disabled: !!id,
171
174
  showIf: { calculated: true },
175
+ help: {
176
+ topic: "Calculated fields",
177
+ },
172
178
  }),
173
179
  new Field({
174
180
  label: req.__("Protected"),
@@ -176,6 +182,9 @@ const fieldForm = async (req, fkey_opts, existing_names, id, hasData) => {
176
182
  sublabel: req.__("Set role to access"),
177
183
  type: "Bool",
178
184
  showIf: { calculated: false },
185
+ help: {
186
+ topic: "Protected fields",
187
+ },
179
188
  }),
180
189
  {
181
190
  label: req.__("Minimum role to write"),
@@ -919,12 +928,19 @@ router.post(
919
928
  "/test-formula",
920
929
  isAdmin,
921
930
  error_catcher(async (req, res) => {
922
- const { formula, tablename, stored } = req.body;
931
+ let { formula, tablename, stored } = req.body;
932
+ if (stored === "false") stored = false;
933
+
923
934
  const table = Table.findOne({ name: tablename });
924
935
  const fields = table.getFields();
925
936
  const freeVars = freeVariables(formula);
926
937
  const joinFields = {};
927
- if (stored) add_free_variables_to_joinfields(freeVars, joinFields, fields);
938
+ add_free_variables_to_joinfields(freeVars, joinFields, fields);
939
+ if (!stored && Object.keys(joinFields).length > 0) {
940
+ return res
941
+ .status(400)
942
+ .send(`Joinfields only permitted in stored calculated fields`);
943
+ }
928
944
  const rows = await table.getJoinedRows({
929
945
  joinFields,
930
946
  orderBy: "RANDOM()",
@@ -950,9 +966,9 @@ router.post(
950
966
  );
951
967
  } catch (e) {
952
968
  console.error(e);
953
- return res.send(
954
- `Error on running on row with id=${rows[0].id}: ${e.message}`
955
- );
969
+ return res
970
+ .status(400)
971
+ .send(`Error on running on row with id=${rows[0].id}: ${e.message}`);
956
972
  }
957
973
  })
958
974
  );
@@ -14,7 +14,7 @@ const {
14
14
  } = require("../markup/admin.js");
15
15
  const { getState } = require("@saltcorn/data/db/state");
16
16
  const { div, a, i, text } = require("@saltcorn/markup/tags");
17
- const { mkTable, renderForm } = require("@saltcorn/markup");
17
+ const { mkTable, renderForm, post_delete_btn } = require("@saltcorn/markup");
18
18
  const Form = require("@saltcorn/data/models/form");
19
19
 
20
20
  /**
@@ -119,6 +119,15 @@ router.get(
119
119
  })
120
120
  : "",
121
121
  },
122
+ {
123
+ label: req.__("Delete"),
124
+ key: (r) =>
125
+ post_delete_btn(
126
+ `/site-structure/localizer/delete-lang/${r.locale}`,
127
+ req,
128
+ r.name
129
+ ),
130
+ },
122
131
  ],
123
132
  Object.values(cfgLangs)
124
133
  ),
@@ -234,7 +243,14 @@ router.post(
234
243
  isAdmin,
235
244
  error_catcher(async (req, res) => {
236
245
  const { lang, defstring } = req.params;
237
-
246
+ if (
247
+ lang === "__proto__" ||
248
+ defstring === "__proto__" ||
249
+ lang === "constructor"
250
+ ) {
251
+ res.redirect(`/`);
252
+ return;
253
+ }
238
254
  const cfgStrings = getState().getConfigCopy("localizer_strings");
239
255
  if (cfgStrings[lang]) cfgStrings[lang][defstring] = text(req.body.value);
240
256
  else cfgStrings[lang] = { [defstring]: text(req.body.value) };
@@ -280,3 +296,25 @@ router.post(
280
296
  }
281
297
  })
282
298
  );
299
+
300
+ /**
301
+ * @name post/localizer/save-lang
302
+ * @function
303
+ * @memberof module:routes/infoarch~infoarchRouter
304
+ * @function
305
+ */
306
+ router.post(
307
+ "/localizer/delete-lang/:lang",
308
+ isAdmin,
309
+ error_catcher(async (req, res) => {
310
+ const { lang } = req.params;
311
+
312
+ const cfgLangs = getState().getConfig("localizer_languages");
313
+ if (cfgLangs[lang]) {
314
+ delete cfgLangs[lang];
315
+ await getState().setConfig("localizer_languages", cfgLangs);
316
+ }
317
+ if (!req.xhr) res.redirect(`/site-structure/localizer`);
318
+ else res.json({ success: "ok" });
319
+ })
320
+ );
package/routes/menu.js CHANGED
@@ -269,6 +269,9 @@ const menuForm = async (req) => {
269
269
  name: "icon",
270
270
  class: "item-menu",
271
271
  input_type: "hidden",
272
+ showIf: {
273
+ type: ["View", "Page", "Page Group", "Link", "Header", "Action"],
274
+ },
272
275
  },
273
276
  {
274
277
  name: "tooltip",
@@ -183,6 +183,12 @@ const pageBuilderData = async (req, context) => {
183
183
  });
184
184
  }
185
185
  }
186
+ const actionsNotRequiringRow = Trigger.action_options({
187
+ notRequireRow: true,
188
+ apiNeverTriggers: true,
189
+ builtInLabel: "Page Actions",
190
+ builtIns: ["GoBack"],
191
+ });
186
192
  const library = (await Library.find({})).filter((l) => l.suitableFor("page"));
187
193
  const fixed_state_fields = {};
188
194
  for (const view of views) {
@@ -228,7 +234,7 @@ const pageBuilderData = async (req, context) => {
228
234
  images,
229
235
  pages,
230
236
  page_groups,
231
- actions,
237
+ actions: actionsNotRequiringRow,
232
238
  builtInActions: ["GoBack"],
233
239
  library,
234
240
  min_role: context.min_role,