@saltcorn/server 1.1.0-beta.13 → 1.1.0-beta.14

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/locales/en.json CHANGED
@@ -1498,6 +1498,17 @@
1498
1498
  "Pulling the capacitor-builder docker image...": "Pulling the capacitor-builder docker image...",
1499
1499
  "Pull done with code %s": "Pull done with code %s",
1500
1500
  "Default locale": "Default locale",
1501
+ "Next step": "Next step",
1502
+ "Step name": "Step name",
1503
+ "Step saved": "Step saved",
1504
+ "Initial step": "Initial step",
1501
1505
  "Confirm leaving unsaved": "Confirm leaving unsaved",
1502
- "Ask the user to confirm if they close a tab with unsaved changes": "Ask the user to confirm if they close a tab with unsaved changes"
1506
+ "Ask the user to confirm if they close a tab with unsaved changes": "Ask the user to confirm if they close a tab with unsaved changes",
1507
+ "Workflow runs": "Workflow runs",
1508
+ "Workflow run": "Workflow run",
1509
+ "Share to enabled": "Share to enabled",
1510
+ "Enable the share to feature": "Enable the share to feature",
1511
+ "Allocate new row": "Allocate new row",
1512
+ "If the view is run without existing row, allocate a new row on load. Defaults must be set on all required fields.": "If the view is run without existing row, allocate a new row on load. Defaults must be set on all required fields.",
1513
+ "Step traces": "Step traces"
1503
1514
  }
package/locales/pl.json CHANGED
@@ -57,7 +57,7 @@
57
57
  "If you did not request this, please ignore this email.": "Jeżeli nie żądałeś tego, nie klikaj w poniższy link.",
58
58
  "Your password will not change until you access the link above and set a new one.": "Twoje hasło nie ulegnie zmianie dopóki nie klikniesz w powyższy link i nie ustawisz nowego hasła.",
59
59
  "Change my password": "Zmień moje hasło",
60
- "Must be admin": "Musi być adminem",
60
+ "Must be admin": "Musisz być adminem",
61
61
  "Site identity": "Tożsamość strony",
62
62
  "Authentication": "Uwierzytelnianie",
63
63
  "Development": "Development",
@@ -1491,5 +1491,22 @@
1491
1491
  "Login and signup views should be accessible by public users": "Widoki logowania i rejestracji powinny być dostępne dla użytkowników publicznych",
1492
1492
  "Shared: %s": "Udostępnione: %s",
1493
1493
  "Sharing not enabled": "Udostępnianie nie jest włączone",
1494
- "You must be logged in to share": "Musisz być zalogowany, aby udostępniać"
1494
+ "You must be logged in to share": "Musisz być zalogowany, aby udostępniać",
1495
+ "Fluid layout": "Układ elastyczny",
1496
+ "Request fluid layout from theme for a wider display for this page": "Poproś o elastyczny układ z motywu, aby zapewnić szerszy widok dla tej strony",
1497
+ "Location of view to create new row": "Lokalizacja widoku do utworzenia nowego wiersza",
1498
+ "Capacitor builder": "Capacitor builder",
1499
+ "Pulling the capacitor-builder docker image...": "Pobieranie obrazu Docker capacitor-builder...",
1500
+ "Pull done with code %s": "Pobieranie zakończone z kodem %s",
1501
+ "Default locale": "Domyślny język/region",
1502
+ "Next step": "Następny krok",
1503
+ "Step name": "Nazwa kroku",
1504
+ "Step saved": "Krok zapisany",
1505
+ "Initial step": "Początkowy krok",
1506
+ "Confirm leaving unsaved": "Potwierdzenie opuszczenia bez zapisania",
1507
+ "Ask the user to confirm if they close a tab with unsaved changes": "Poproś użytkownika o potwierdzenie, czy chce zamknąć kartę z niezapisanymi zmianami",
1508
+ "Workflow runs": "Przepływ pracy jest uruchomiony",
1509
+ "Workflow run": "Uruchomienie przepływu pracy",
1510
+ "Share to enabled": "Udostępnij włączone",
1511
+ "Enable the share to feature": "Włącz udostępnianie funkcji"
1495
1512
  }
package/markup/admin.js CHANGED
@@ -310,6 +310,7 @@ const send_events_page = (args) => {
310
310
  { text: "Custom", href: "/eventlog/custom" },
311
311
  { text: "Settings", href: "/eventlog/settings" },
312
312
  { text: "Event log", href: "/eventlog" },
313
+ { text: "Workflow runs", href: "/actions/runs" },
313
314
  ...(isRoot ? [{ text: "Crash log", href: "/crashlog" }] : []),
314
315
  ],
315
316
  ...args,
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@saltcorn/server",
3
- "version": "1.1.0-beta.13",
3
+ "version": "1.1.0-beta.14",
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.0-beta.13",
11
- "@saltcorn/builder": "1.1.0-beta.13",
12
- "@saltcorn/data": "1.1.0-beta.13",
13
- "@saltcorn/admin-models": "1.1.0-beta.13",
14
- "@saltcorn/filemanager": "1.1.0-beta.13",
15
- "@saltcorn/markup": "1.1.0-beta.13",
16
- "@saltcorn/plugins-loader": "1.1.0-beta.13",
17
- "@saltcorn/sbadmin2": "1.1.0-beta.13",
10
+ "@saltcorn/base-plugin": "1.1.0-beta.14",
11
+ "@saltcorn/builder": "1.1.0-beta.14",
12
+ "@saltcorn/data": "1.1.0-beta.14",
13
+ "@saltcorn/admin-models": "1.1.0-beta.14",
14
+ "@saltcorn/filemanager": "1.1.0-beta.14",
15
+ "@saltcorn/markup": "1.1.0-beta.14",
16
+ "@saltcorn/plugins-loader": "1.1.0-beta.14",
17
+ "@saltcorn/sbadmin2": "1.1.0-beta.14",
18
18
  "@socket.io/cluster-adapter": "^0.2.1",
19
19
  "@socket.io/sticky": "^1.0.1",
20
20
  "adm-zip": "0.5.10",
@@ -40,6 +40,19 @@ $(window).resize(() => {
40
40
  setScreenInfoCookie();
41
41
  });
42
42
 
43
+ function get_current_state_url(e) {
44
+ const localizer = e ? $(e).closest("[data-sc-local-state]") : [];
45
+ let $modal = $("#scmodal");
46
+ if (localizer.length) {
47
+ const localState = localizer.attr("data-sc-local-state") || "";
48
+ return localState;
49
+ } else if ($modal.length === 0 || !$modal.hasClass("show"))
50
+ return getIsNode()
51
+ ? window.location.href
52
+ : parent.saltcorn.mobileApp.navigation.currentUrl();
53
+ else return $modal.prop("data-modal-state");
54
+ }
55
+
43
56
  //avoids hiding in overflow:hidden
44
57
  function init_bs5_dropdowns() {
45
58
  $("body").on(
@@ -704,7 +717,7 @@ function doMobileTransforms() {
704
717
  href: [
705
718
  {
706
719
  web: "javascript:history.back()",
707
- mobile: "javascript:parent.goBack()",
720
+ mobile: "javascript:parent.saltcorn.mobileApp.navigation.goBack()",
708
721
  },
709
722
  {
710
723
  web: "javascript:ajax_modal",
@@ -714,7 +727,7 @@ function doMobileTransforms() {
714
727
  onclick: [
715
728
  {
716
729
  web: "history.back()",
717
- mobile: "parent.goBack()",
730
+ mobile: "parent.saltcorn.mobileApp.navigation.goBack()",
718
731
  },
719
732
  {
720
733
  web: "ajax_modal",
@@ -823,48 +836,46 @@ function doMobileTransforms() {
823
836
  });
824
837
 
825
838
  $("[mobile-img-path]").each(async function () {
826
- if (parent.loadEncodedFile) {
827
- const fileId = $(this).attr("mobile-img-path");
828
- const base64Encoded = await parent.loadEncodedFile(fileId);
829
- this.src = base64Encoded;
830
- }
839
+ const fileId = $(this).attr("mobile-img-path");
840
+ const base64Encoded =
841
+ await parent.saltcorn.mobileApp.common.loadEncodedFile(fileId);
842
+ this.src = base64Encoded;
831
843
  });
832
844
 
833
845
  $("[mobile-bg-img-path]").each(async function () {
834
- if (parent.loadEncodedFile) {
835
- const fileId = $(this).attr("mobile-bg-img-path");
836
- if (fileId) {
837
- const base64Encoded = await parent.loadEncodedFile(fileId);
838
- this.style.backgroundImage = `url("${base64Encoded}")`;
839
- }
846
+ const fileId = $(this).attr("mobile-bg-img-path");
847
+ if (fileId) {
848
+ const base64Encoded =
849
+ await parent.saltcorn.mobileApp.common.loadEncodedFile(fileId);
850
+ this.style.backgroundImage = `url("${base64Encoded}")`;
840
851
  }
841
852
  });
842
853
 
843
854
  $("img:not([mobile-img-path]):not([mobile-bg-img-path])").each(
844
855
  async function () {
845
- if (parent.loadEncodedFile) {
846
- const jThis = $(this);
847
- const src = jThis.attr("src");
848
- if (src?.includes("/files/serve/")) {
849
- const tokens = src.split("/files/serve/");
850
- if (tokens.length > 1) {
851
- const fileId = tokens[1];
852
- const base64Encoded = await parent.loadEncodedFile(fileId);
853
- this.src = base64Encoded;
854
- }
855
- } else if (src?.includes("/files/resize/")) {
856
- const tokens = src.split("/files/resize/");
857
- if (tokens.length > 1) {
858
- const idAndDims = tokens[1].split("/");
859
- const width = idAndDims[0];
860
- const height = idAndDims.length > 2 ? idAndDims[1] : undefined;
861
- const fileId = idAndDims[idAndDims.length - 1];
862
- const style = { width: `${width || 50}px` };
863
- if (height > 0) style.height = `${height}px`;
864
- const base64Encoded = await parent.loadEncodedFile(fileId);
865
- this.src = base64Encoded;
866
- jThis.css(style);
867
- }
856
+ const jThis = $(this);
857
+ const src = jThis.attr("src");
858
+ if (src?.includes("/files/serve/")) {
859
+ const tokens = src.split("/files/serve/");
860
+ if (tokens.length > 1) {
861
+ const fileId = tokens[1];
862
+ const base64Encoded =
863
+ await parent.saltcorn.mobileApp.common.loadEncodedFile(fileId);
864
+ this.src = base64Encoded;
865
+ }
866
+ } else if (src?.includes("/files/resize/")) {
867
+ const tokens = src.split("/files/resize/");
868
+ if (tokens.length > 1) {
869
+ const idAndDims = tokens[1].split("/");
870
+ const width = idAndDims[0];
871
+ const height = idAndDims.length > 2 ? idAndDims[1] : undefined;
872
+ const fileId = idAndDims[idAndDims.length - 1];
873
+ const style = { width: `${width || 50}px` };
874
+ if (height > 0) style.height = `${height}px`;
875
+ const base64Encoded =
876
+ await parent.saltcorn.mobileApp.common.loadEncodedFile(fileId);
877
+ this.src = base64Encoded;
878
+ jThis.css(style);
868
879
  }
869
880
  }
870
881
  }
@@ -1569,14 +1580,15 @@ async function common_done(res, viewnameOrElem, isWeb = true) {
1569
1580
  });
1570
1581
  }
1571
1582
  if (res.eval_js) await handle(res.eval_js, eval_it);
1583
+ /// TODO got and resume_workflow - use localStorage
1572
1584
  if (res.goto) {
1573
1585
  if (!isWeb) {
1574
1586
  const next = new URL(res.goto, "http://localhost");
1575
1587
  const pathname = next.pathname;
1576
1588
  if (pathname.startsWith("/view/") || pathname.startsWith("/page/")) {
1577
1589
  const route = `get${pathname}${next.search ? "?" + next.search : ""}`;
1578
- await parent.handleRoute(route);
1579
- } else parent.cordova.InAppBrowser.open(res.goto, "_system");
1590
+ await parent.saltcorn.mobileApp.navigation.handleRoute(route);
1591
+ } else parent.cordova.InAppBrowser.open(res.goto, "_system"); // TODO
1580
1592
  } else if (res.target === "_blank") window.open(res.goto, "_blank").focus();
1581
1593
  else {
1582
1594
  const prev = new URL(window.location.href);
@@ -1591,6 +1603,9 @@ async function common_done(res, viewnameOrElem, isWeb = true) {
1591
1603
  location.reload();
1592
1604
  }
1593
1605
  }
1606
+ if (res.resume_workflow) {
1607
+ ajax_post_json(`/actions/resume-workflow/${res.resume_workflow}`, {});
1608
+ }
1594
1609
  if (res.reload_page) {
1595
1610
  (isWeb ? location : parent).reload(); //TODO notify to cookie if reload or goto
1596
1611
  }
@@ -84,17 +84,6 @@ function removeQueryStringParameter(uri1, key) {
84
84
  return uri + hash;
85
85
  }
86
86
 
87
- function get_current_state_url(e) {
88
- const localizer = e ? $(e).closest("[data-sc-local-state]") : [];
89
- let $modal = $("#scmodal");
90
- if (localizer.length) {
91
- const localState = localizer.attr("data-sc-local-state") || "";
92
- return localState;
93
- } else if ($modal.length === 0 || !$modal.hasClass("show"))
94
- return window.location.href;
95
- else return $modal.prop("data-modal-state");
96
- }
97
-
98
87
  function select_id(id, e) {
99
88
  pjax_to(updateQueryStringParameter(get_current_state_url(e), "id", id), e);
100
89
  }
@@ -423,6 +412,27 @@ function saveAndContinueAsync(e) {
423
412
  });
424
413
  }
425
414
 
415
+ function saveAndContinueIfValid(e, k, event) {
416
+ //wait for applyShowIf
417
+ setTimeout(() => {
418
+ if (
419
+ event &&
420
+ event.target &&
421
+ event.target.classList &&
422
+ event.target.classList.contains("no-form-change")
423
+ )
424
+ return;
425
+ var form = $(e).closest("form");
426
+
427
+ if (form[0].checkValidity?.() === false) {
428
+ form[0].reportValidity();
429
+ return;
430
+ }
431
+
432
+ saveAndContinue(e, k, event);
433
+ });
434
+ }
435
+
426
436
  function saveAndContinue(e, k, event) {
427
437
  if (
428
438
  event &&
@@ -432,6 +442,7 @@ function saveAndContinue(e, k, event) {
432
442
  )
433
443
  return;
434
444
  var form = $(e).closest("form");
445
+
435
446
  let focusedEl = null;
436
447
  if (!event || !event.srcElement) {
437
448
  const el = form.find("select[sc-received-focus]")[0];
package/routes/actions.js CHANGED
@@ -17,6 +17,9 @@ const Trigger = require("@saltcorn/data/models/trigger");
17
17
  const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
18
18
  const { getTriggerList } = require("./common_lists");
19
19
  const TagEntry = require("@saltcorn/data/models/tag_entry");
20
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
21
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
22
+ const WorkflowTrace = require("@saltcorn/data/models/workflow_trace");
20
23
  const Tag = require("@saltcorn/data/models/tag");
21
24
  const db = require("@saltcorn/data/db");
22
25
 
@@ -29,7 +32,13 @@ const db = require("@saltcorn/data/db");
29
32
  */
30
33
  const router = new Router();
31
34
  module.exports = router;
32
- const { renderForm, link } = require("@saltcorn/markup");
35
+ const {
36
+ renderForm,
37
+ link,
38
+ mkTable,
39
+ localeDateTime,
40
+ post_delete_btn,
41
+ } = require("@saltcorn/markup");
33
42
  const Form = require("@saltcorn/data/models/form");
34
43
  const {
35
44
  div,
@@ -45,8 +54,13 @@ const {
45
54
  td,
46
55
  h6,
47
56
  pre,
57
+ th,
48
58
  text,
49
59
  i,
60
+ ul,
61
+ li,
62
+ h2,
63
+ h4,
50
64
  } = require("@saltcorn/markup/tags");
51
65
  const Table = require("@saltcorn/data/models/table");
52
66
  const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
@@ -154,11 +168,15 @@ const triggerForm = async (req, trigger) => {
154
168
  .filter(([k, v]) => v.hasChannel)
155
169
  .map(([k, v]) => k);
156
170
 
157
- const allActions = Trigger.action_options({ notRequireRow: false });
171
+ const allActions = Trigger.action_options({
172
+ notRequireRow: false,
173
+ workflow: true,
174
+ });
158
175
  const table_triggers = ["Insert", "Update", "Delete", "Validate"];
159
176
  const action_options = {};
160
177
  const actionsNotRequiringRow = Trigger.action_options({
161
178
  notRequireRow: true,
179
+ workflow: true,
162
180
  });
163
181
 
164
182
  Trigger.when_options.forEach((t) => {
@@ -467,6 +485,354 @@ router.post(
467
485
  })
468
486
  );
469
487
 
488
+ function genWorkflowDiagram(steps) {
489
+ const stepNames = steps.map((s) => s.name);
490
+ const nodeLines = steps
491
+ .map(
492
+ (s) => ` ${s.name}["\`**${s.name}**
493
+ ${s.action_name}\`"]:::wfstep${s.id}`
494
+ )
495
+ .join("\n");
496
+ const linkLines = [];
497
+ let step_ix = 0;
498
+ for (const step of steps) {
499
+ if (step.action_name === "ForLoop") {
500
+ linkLines.push(
501
+ ` ${step.name} --> ${step.configuration.for_loop_step_name}`
502
+ );
503
+ } else if (stepNames.includes(step.next_step)) {
504
+ linkLines.push(` ${step.name} --> ${step.next_step}`);
505
+ } else if (step.next_step) {
506
+ for (const otherStep of stepNames)
507
+ if (step.next_step.includes(otherStep))
508
+ linkLines.push(` ${step.name} --> ${otherStep}`);
509
+ }
510
+ if (step.action_name === "EndForLoop") {
511
+ // TODO this is not correct. improve.
512
+ let forStep;
513
+ for (let i = step_ix; i >= 0; i -= 1) {
514
+ if (steps[i].action_name === "ForLoop") {
515
+ forStep = steps[i];
516
+ break;
517
+ }
518
+ }
519
+ if (forStep) linkLines.push(` ${step.name} --> ${forStep.name}`);
520
+ }
521
+ step_ix += 1;
522
+ }
523
+ return "flowchart TD\n" + nodeLines + "\n" + linkLines.join("\n");
524
+ }
525
+
526
+ const getWorkflowConfig = async (req, id, table, trigger) => {
527
+ let steps = await WorkflowStep.find(
528
+ { trigger_id: trigger.id },
529
+ { orderBy: "id" }
530
+ );
531
+ const initial_step = steps.find((step) => step.initial_step);
532
+ if (initial_step)
533
+ steps = [initial_step, ...steps.filter((s) => !s.initial_step)];
534
+ const trigCfgForm = new Form({
535
+ action: addOnDoneRedirect(`/actions/configure/${id}`, req),
536
+ onChange: "saveAndContinue(this)",
537
+ noSubmitButton: true,
538
+ formStyle: "vert",
539
+ fields: [
540
+ {
541
+ name: "save_traces",
542
+ label: "Save step traces for each run",
543
+ type: "Bool",
544
+ },
545
+ ],
546
+ });
547
+ trigCfgForm.values = trigger.configuration;
548
+ return (
549
+ /*ul(
550
+ steps.map((step) =>
551
+ li(
552
+ a(
553
+ {
554
+ href: `/actions/stepedit/${trigger.id}/${step.id}`,
555
+ },
556
+ step.name
557
+ )
558
+ )
559
+ )
560
+ ) +*/
561
+ pre({ class: "mermaid" }, genWorkflowDiagram(steps)) +
562
+ script(
563
+ { defer: "defer" },
564
+ `function tryAddWFNodes() {
565
+ const ns = $("g.node");
566
+ if(!ns.length) setTimeout(tryAddWFNodes, 200)
567
+ else {
568
+ $("g.node").on("click", (e)=>{
569
+ const $e = $(e.target || e).closest("g.node")
570
+ const cls = $e.attr('class')
571
+ if(!cls || !cls.includes("wfstep")) return;
572
+ const id = cls.split(" ").find(c=>c.startsWith("wfstep")).
573
+ substr(6);
574
+ location.href = '/actions/stepedit/${trigger.id}/'+id;
575
+ //console.log($e.attr('class'), id)
576
+ })
577
+ }
578
+ }
579
+ window.addEventListener('DOMContentLoaded',tryAddWFNodes)`
580
+ ) +
581
+ a(
582
+ {
583
+ href: `/actions/stepedit/${trigger.id}?name=step${steps.length + 1}${
584
+ initial_step ? "" : "&initial_step=true"
585
+ }`,
586
+ class: "btn btn-primary",
587
+ },
588
+ i({ class: "fas fa-plus me-2" }),
589
+ "Add step"
590
+ ) +
591
+ a(
592
+ {
593
+ href: `/actions/runs/?trigger=${trigger.id}`,
594
+ class: "d-block",
595
+ },
596
+ "Show runs »"
597
+ ) +
598
+ renderForm(trigCfgForm, req.csrfToken())
599
+ );
600
+ };
601
+
602
+ const jsIdentifierValidator = (s) => {
603
+ if (!s) return "An identifier is required";
604
+ if (s.includes(" ")) return "Spaces not allowd";
605
+ let badc = "'#:/\\@()[]{}\"!%^&*-+*~<>,.?|"
606
+ .split("")
607
+ .find((c) => s.includes(c));
608
+
609
+ if (badc) return `Character ${badc} not allowed`;
610
+ };
611
+
612
+ const getWorkflowStepForm = async (trigger, req, step_id) => {
613
+ const table = trigger.table_id ? Table.findOne(trigger.table_id) : null;
614
+ let stateActions = getState().actions;
615
+ const stateActionKeys = Object.entries(stateActions)
616
+ .filter(([k, v]) => !v.disableInWorkflow)
617
+ .map(([k, v]) => k);
618
+
619
+ const actionConfigFields = [];
620
+ for (const [name, action] of Object.entries(stateActions)) {
621
+ if (!stateActionKeys.includes(name)) continue;
622
+ try {
623
+ const cfgFields = await getActionConfigFields(action, table, {
624
+ mode: "workflow",
625
+ });
626
+
627
+ for (const field of cfgFields) {
628
+ const cfgFld = {
629
+ ...field,
630
+ showIf: {
631
+ wf_action_name: name,
632
+ ...(field.showIf || {}),
633
+ },
634
+ };
635
+ if (cfgFld.input_type === "code") cfgFld.input_type = "textarea";
636
+ actionConfigFields.push(cfgFld);
637
+ }
638
+ } catch {}
639
+ }
640
+ const actionsNotRequiringRow = Trigger.action_options({
641
+ notRequireRow: true,
642
+ noMultiStep: true,
643
+ builtInLabel: "Workflow Actions",
644
+ builtIns: [
645
+ "SetContext",
646
+ "TableQuery",
647
+ "WaitUntil",
648
+ "UserForm",
649
+ "WaitNextTick",
650
+ ],
651
+ forWorkflow: true,
652
+ });
653
+
654
+ actionConfigFields.push({
655
+ label: "Form header",
656
+ name: "form_header",
657
+ type: "String",
658
+ showIf: { wf_action_name: "UserForm" },
659
+ });
660
+ actionConfigFields.push({
661
+ label: "User ID",
662
+ name: "user_id_expression",
663
+ type: "String",
664
+ sublabel: "Optional. If blank assigned to user starting the workflow",
665
+ showIf: { wf_action_name: "UserForm" },
666
+ });
667
+ actionConfigFields.push({
668
+ label: "Resume at",
669
+ name: "resume_at",
670
+ sublabel:
671
+ "JavaScript expression for the time to resume. <code>moment</code> is in scope.",
672
+ type: "String",
673
+ showIf: { wf_action_name: "WaitUntil" },
674
+ });
675
+ actionConfigFields.push({
676
+ label: "Context values",
677
+ name: "ctx_values",
678
+ sublabel:
679
+ "JavaScript object expression for the variables to set. Example <code>{x: 5, y:y+1}</code> will set x to 5 and increment existing context variable y",
680
+ type: "String",
681
+ fieldview: "textarea",
682
+ class: "validate-expression",
683
+ default: "{}",
684
+ showIf: { wf_action_name: "SetContext" },
685
+ });
686
+
687
+ actionConfigFields.push({
688
+ label: "Table",
689
+ name: "query_table",
690
+ type: "String",
691
+ required: true,
692
+ attributes: { options: (await Table.find()).map((t) => t.name) },
693
+ showIf: { wf_action_name: "TableQuery" },
694
+ });
695
+ actionConfigFields.push({
696
+ label: "Query",
697
+ name: "query_object",
698
+ sublabel: "Where object, example <code>{manager: 1}</code>",
699
+ type: "String",
700
+ required: true,
701
+ class: "validate-expression",
702
+ default: "{}",
703
+ showIf: { wf_action_name: "TableQuery" },
704
+ });
705
+ actionConfigFields.push({
706
+ label: "Variable",
707
+ name: "query_variable",
708
+ sublabel: "Context variable to write to query results to",
709
+ type: "String",
710
+ required: true,
711
+ validator: jsIdentifierValidator,
712
+ showIf: { wf_action_name: "TableQuery" },
713
+ });
714
+ actionConfigFields.push(
715
+ new FieldRepeat({
716
+ name: "user_form_questions",
717
+ showIf: { wf_action_name: "UserForm" },
718
+ fields: [
719
+ {
720
+ label: "Label",
721
+ name: "label",
722
+ type: "String",
723
+ sublabel:
724
+ "The text that will shown to the user above the input elements",
725
+ },
726
+ {
727
+ label: "Variable name",
728
+ name: "var_name",
729
+ type: "String",
730
+ sublabel:
731
+ "The answer will be set in the context with this variable name",
732
+ validator: jsIdentifierValidator,
733
+ },
734
+ {
735
+ label: "Input Type",
736
+ name: "qtype",
737
+ type: "String",
738
+ required: true,
739
+ attributes: {
740
+ options: [
741
+ "Yes/No",
742
+ "Checkbox",
743
+ "Free text",
744
+ "Multiple choice",
745
+ //"Multiple checks",
746
+ "Integer",
747
+ "Float",
748
+ //"File upload",
749
+ ],
750
+ },
751
+ },
752
+ {
753
+ label: "Options",
754
+ name: "options",
755
+ type: "String",
756
+ sublabel: "Comma separated list of multiple choice options",
757
+ showIf: { qtype: ["Multiple choice", "Multiple checks"] },
758
+ },
759
+ ],
760
+ })
761
+ );
762
+
763
+ const form = new Form({
764
+ action: addOnDoneRedirect(`/actions/stepedit/${trigger.id}`, req),
765
+ onChange: "saveAndContinueIfValid(this)",
766
+ submitLabel: req.__("Done"),
767
+ additionalButtons: step_id
768
+ ? [
769
+ {
770
+ label: req.__("Delete"),
771
+ class: "btn btn-outline-danger",
772
+ onclick: `ajax_post('/actions/delete-step/${+step_id}')`,
773
+ afterSave: true,
774
+ },
775
+ ]
776
+ : undefined,
777
+ fields: [
778
+ {
779
+ name: "wf_step_name",
780
+ label: req.__("Step name"),
781
+ type: "String",
782
+ required: true,
783
+ sublabel: "An identifier by which this step can be referred to.",
784
+ validator: jsIdentifierValidator,
785
+ },
786
+ {
787
+ name: "wf_initial_step",
788
+ label: req.__("Initial step"),
789
+ sublabel: "Is this the first step in the workflow?",
790
+ type: "Bool",
791
+ },
792
+ {
793
+ name: "wf_only_if",
794
+ label: req.__("Only if..."),
795
+ sublabel:
796
+ "Optional JavaScript expression based on the run context. If given, the chosen action will only be executed if evaluates to true",
797
+ type: "String",
798
+ },
799
+ {
800
+ name: "wf_next_step",
801
+ label: req.__("Next step"),
802
+ type: "String",
803
+ class: "validate-expression",
804
+ sublabel:
805
+ "Name of next step. Can be a JavaScript expression based on the run context. Blank if final step",
806
+ },
807
+ {
808
+ name: "wf_action_name",
809
+ label: req.__("Action"),
810
+ type: "String",
811
+ required: true,
812
+ attributes: {
813
+ options: actionsNotRequiringRow,
814
+ },
815
+ },
816
+ ...actionConfigFields,
817
+ ],
818
+ });
819
+ form.hidden("wf_step_id");
820
+ if (step_id) {
821
+ const step = await WorkflowStep.findOne({ id: step_id });
822
+ if (!step) throw new Error("Step not found");
823
+ form.values = {
824
+ wf_step_id: step.id,
825
+ wf_step_name: step.name,
826
+ wf_initial_step: step.initial_step,
827
+ wf_only_if: step.only_if,
828
+ wf_action_name: step.action_name,
829
+ wf_next_step: step.next_step,
830
+ ...step.configuration,
831
+ };
832
+ }
833
+ return form;
834
+ };
835
+
470
836
  const getMultiStepForm = async (req, id, table) => {
471
837
  let stateActions = getState().actions;
472
838
  const stateActionKeys = Object.entries(stateActions)
@@ -579,7 +945,33 @@ router.get(
579
945
  { href: `/actions/testrun/${id}`, class: "ms-2" },
580
946
  req.__("Test run") + "&nbsp;&raquo;"
581
947
  );
582
- if (trigger.action === "Multi-step action") {
948
+ if (trigger.action === "Workflow") {
949
+ const wfCfg = await getWorkflowConfig(req, id, table, trigger);
950
+ send_events_page({
951
+ res,
952
+ req,
953
+ active_sub: "Triggers",
954
+ sub2_page: "Configure",
955
+ page_title: req.__(`%s configuration`, trigger.name),
956
+ headers: [
957
+ {
958
+ script: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
959
+ },
960
+ {
961
+ headerTag: `<script type="module">mermaid.initialize({securityLevel: 'loose'${
962
+ getState().getLightDarkMode(req.user) ? ",theme: 'dark'," : ""
963
+ }});</script>`,
964
+ },
965
+ ],
966
+ contents: {
967
+ type: "card",
968
+ titleAjaxIndicator: true,
969
+ title: req.__("Configure trigger %s", trigger.name),
970
+ subtitle,
971
+ contents: wfCfg,
972
+ },
973
+ });
974
+ } else if (trigger.action === "Multi-step action") {
583
975
  const form = await getMultiStepForm(req, id, table);
584
976
  form.values = trigger.configuration;
585
977
  send_events_page({
@@ -725,6 +1117,11 @@ router.post(
725
1117
  let form;
726
1118
  if (trigger.action === "Multi-step action") {
727
1119
  form = await getMultiStepForm(req, id, table);
1120
+ } else if (trigger.action === "Workflow") {
1121
+ form = new Form({
1122
+ action: `/actions/configure/${id}`,
1123
+ fields: [{ name: "save_traces", label: "Save traces", type: "Bool" }],
1124
+ });
728
1125
  } else {
729
1126
  const cfgFields = await getActionConfigFields(action, table, {
730
1127
  mode: "trigger",
@@ -830,6 +1227,7 @@ router.get(
830
1227
  table,
831
1228
  row,
832
1229
  req,
1230
+ interactive: true,
833
1231
  ...(row || {}),
834
1232
  Table,
835
1233
  user: req.user,
@@ -848,7 +1246,13 @@ router.get(
848
1246
  ? script(domReady(`common_done(${JSON.stringify(runres)})`))
849
1247
  : ""
850
1248
  );
851
- res.redirect(`/actions/`);
1249
+ if (trigger.action === "Workflow")
1250
+ res.redirect(
1251
+ runres?.__wf_run_id
1252
+ ? `/actions/run/${runres?.__wf_run_id}`
1253
+ : `/actions/runs/?trigger=${trigger.id}`
1254
+ );
1255
+ else res.redirect(`/actions/`);
852
1256
  } else {
853
1257
  send_events_page({
854
1258
  res,
@@ -909,3 +1313,490 @@ router.post(
909
1313
  res.redirect(`/actions`);
910
1314
  })
911
1315
  );
1316
+
1317
+ /**
1318
+ * @name post/clone/:id
1319
+ * @function
1320
+ * @memberof module:routes/actions~actionsRouter
1321
+ * @function
1322
+ */
1323
+ router.get(
1324
+ "/stepedit/:trigger_id/:step_id?",
1325
+ isAdmin,
1326
+ error_catcher(async (req, res) => {
1327
+ const { trigger_id, step_id } = req.params;
1328
+ const { initial_step, name } = req.query;
1329
+ const trigger = await Trigger.findOne({ id: trigger_id });
1330
+ const form = await getWorkflowStepForm(trigger, req, step_id);
1331
+
1332
+ if (initial_step) form.values.wf_initial_step = true;
1333
+ if (name) form.values.wf_step_name = name;
1334
+ send_events_page({
1335
+ res,
1336
+ req,
1337
+ active_sub: "Triggers",
1338
+ sub2_page: "Configure",
1339
+ page_title: req.__(`%s configuration`, trigger.name),
1340
+ contents: {
1341
+ type: "card",
1342
+ titleAjaxIndicator: true,
1343
+ title: req.__(
1344
+ "Configure trigger %s",
1345
+ a({ href: `/actions/configure/${trigger.id}` }, trigger.name)
1346
+ ),
1347
+ contents: renderForm(form, req.csrfToken()),
1348
+ },
1349
+ });
1350
+ })
1351
+ );
1352
+
1353
+ router.post(
1354
+ "/stepedit/:trigger_id",
1355
+ isAdmin,
1356
+ error_catcher(async (req, res) => {
1357
+ const { trigger_id } = req.params;
1358
+ const trigger = await Trigger.findOne({ id: trigger_id });
1359
+ const form = await getWorkflowStepForm(trigger, req);
1360
+ form.validate(req.body);
1361
+ if (form.hasErrors) {
1362
+ if (req.xhr) {
1363
+ res.json({ error: form.errorSummary });
1364
+ } else
1365
+ send_events_page({
1366
+ res,
1367
+ req,
1368
+ active_sub: "Triggers",
1369
+ sub2_page: "Configure",
1370
+ page_title: req.__(`%s configuration`, trigger.name),
1371
+ contents: {
1372
+ type: "card",
1373
+ titleAjaxIndicator: true,
1374
+ title: req.__(
1375
+ "Configure trigger %s",
1376
+ a({ href: `/actions/configure/${trigger.id}` }, trigger.name)
1377
+ ),
1378
+ contents: renderForm(form, req.csrfToken()),
1379
+ },
1380
+ });
1381
+ return;
1382
+ }
1383
+ const {
1384
+ wf_step_name,
1385
+ wf_action_name,
1386
+ wf_next_step,
1387
+ wf_initial_step,
1388
+ wf_only_if,
1389
+ wf_step_id,
1390
+ ...configuration
1391
+ } = form.values;
1392
+ Object.entries(configuration).forEach(([k, v]) => {
1393
+ if (v === null) delete configuration[k];
1394
+ });
1395
+ const step = {
1396
+ name: wf_step_name,
1397
+ action_name: wf_action_name,
1398
+ next_step: wf_next_step,
1399
+ only_if: wf_only_if,
1400
+ initial_step: wf_initial_step,
1401
+ trigger_id,
1402
+ configuration,
1403
+ };
1404
+
1405
+ if (wf_step_id && wf_step_id !== "undefined") {
1406
+ const wfStep = new WorkflowStep({ id: wf_step_id, ...step });
1407
+
1408
+ await wfStep.update(step);
1409
+ if (req.xhr) res.json({ success: "ok" });
1410
+ else {
1411
+ req.flash("success", req.__("Step saved"));
1412
+ res.redirect(`/actions/configure/${step.trigger_id}`);
1413
+ }
1414
+ } else {
1415
+ //insert
1416
+ const id = await WorkflowStep.create(step);
1417
+ if (req.xhr) res.json({ success: "ok", set_fields: { wf_step_id: id } });
1418
+ else {
1419
+ req.flash("success", req.__("Step saved"));
1420
+ res.redirect(`/actions/configure/${step.trigger_id}`);
1421
+ }
1422
+ }
1423
+ })
1424
+ );
1425
+
1426
+ router.post(
1427
+ "/delete-step/:step_id",
1428
+ isAdmin,
1429
+ error_catcher(async (req, res) => {
1430
+ const { step_id } = req.params;
1431
+ const step = await WorkflowStep.findOne({ id: step_id });
1432
+ await step.delete();
1433
+ res.json({ goto: `/actions/configure/${step.trigger_id}` });
1434
+ })
1435
+ );
1436
+
1437
+ router.get(
1438
+ "/runs",
1439
+ isAdmin,
1440
+ error_catcher(async (req, res) => {
1441
+ const trNames = {};
1442
+ const { _page, trigger } = req.query;
1443
+ for (const trig of await Trigger.find({ action: "Workflow" }))
1444
+ trNames[trig.id] = trig.name;
1445
+ const q = {};
1446
+ const selOpts = { orderBy: "started_at", orderDesc: true, limit: 20 };
1447
+ if (_page) selOpts.offset = 20 * (parseInt(_page) - 1);
1448
+ if (trigger) q.trigger_id = trigger;
1449
+ const runs = await WorkflowRun.find(q, selOpts);
1450
+ const count = await WorkflowRun.count(q);
1451
+
1452
+ const wfTable = mkTable(
1453
+ [
1454
+ { label: "Trigger", key: (run) => trNames[run.trigger_id] },
1455
+ { label: "Started", key: (run) => localeDateTime(run.started_at) },
1456
+ { label: "Status", key: "status" },
1457
+ {
1458
+ label: "",
1459
+ key: (run) => {
1460
+ switch (run.status) {
1461
+ case "Running":
1462
+ return run.current_step;
1463
+ case "Error":
1464
+ return run.error;
1465
+ case "Waiting":
1466
+ if (run.wait_info?.form)
1467
+ return a(
1468
+ { href: `/actions/fill-workflow-form/${run.id}` },
1469
+ "Fill ",
1470
+ run.current_step
1471
+ );
1472
+ return run.current_step;
1473
+ default:
1474
+ return "";
1475
+ }
1476
+ },
1477
+ },
1478
+ ],
1479
+ runs,
1480
+ {
1481
+ onRowSelect: (row) => `location.href='/actions/run/${row.id}'`,
1482
+ pagination: {
1483
+ current_page: parseInt(_page) || 1,
1484
+ pages: Math.ceil(count / 20),
1485
+ get_page_link: (n) => `gopage(${n}, 20)`,
1486
+ },
1487
+ }
1488
+ );
1489
+ send_events_page({
1490
+ res,
1491
+ req,
1492
+ active_sub: "Workflow runs",
1493
+ page_title: req.__(`Workflow runs`),
1494
+ contents: {
1495
+ type: "card",
1496
+ titleAjaxIndicator: true,
1497
+ title: req.__("Workflow runs"),
1498
+ contents: wfTable,
1499
+ },
1500
+ });
1501
+ })
1502
+ );
1503
+
1504
+ router.get(
1505
+ "/run/:id",
1506
+ isAdmin,
1507
+ error_catcher(async (req, res) => {
1508
+ const { id } = req.params;
1509
+
1510
+ const run = await WorkflowRun.findOne({ id });
1511
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1512
+ const traces = await WorkflowTrace.find(
1513
+ { run_id: run.id },
1514
+ { orderBy: "id" }
1515
+ );
1516
+ const traces_accordion_items = div(
1517
+ { class: "accordion" },
1518
+ traces.map((trace, ix) =>
1519
+ div(
1520
+ { class: "accordion-item" },
1521
+
1522
+ h2(
1523
+ { class: "accordion-header", id: `trhead${ix}` },
1524
+ button(
1525
+ {
1526
+ class: ["accordion-button", "collapsed"],
1527
+ type: "button",
1528
+
1529
+ "data-bs-toggle": "collapse",
1530
+ "data-bs-target": `#trtab${ix}`,
1531
+ "aria-expanded": "false",
1532
+ "aria-controls": `trtab${ix}`,
1533
+ },
1534
+ `${ix + 1}: ${trace.step_name_run}`
1535
+ )
1536
+ ),
1537
+ div(
1538
+ {
1539
+ class: ["accordion-collapse", "collapse"],
1540
+ id: `trtab${ix}`,
1541
+ "aria-labelledby": `trhead${ix}`,
1542
+ },
1543
+ div(
1544
+ { class: ["accordion-body"] },
1545
+ table(
1546
+ { class: "table table-condensed w-unset" },
1547
+ tbody(
1548
+ tr(
1549
+ th("Started at"),
1550
+ td(localeDateTime(trace.step_started_at))
1551
+ ),
1552
+ tr(th("Elapsed"), td(trace.elapsed, "s")),
1553
+ tr(th("Run by user"), td(trace.user_id)),
1554
+ tr(th("Status"), td(trace.status)),
1555
+ trace.status === "Waiting"
1556
+ ? tr(th("Waiting for"), td(JSON.stringify(trace.wait_info)))
1557
+ : null,
1558
+ tr(
1559
+ th("Context"),
1560
+ td(pre(text(JSON.stringify(trace.context, null, 2))))
1561
+ )
1562
+ )
1563
+ )
1564
+ )
1565
+ )
1566
+ )
1567
+ )
1568
+ );
1569
+
1570
+ send_events_page({
1571
+ res,
1572
+ req,
1573
+ active_sub: "Workflow runs",
1574
+ page_title: req.__(`Workflow runs`),
1575
+ sub2_page: trigger.name,
1576
+ contents: {
1577
+ above: [
1578
+ {
1579
+ type: "card",
1580
+ titleAjaxIndicator: true,
1581
+ title: req.__("Workflow run"),
1582
+ contents:
1583
+ table(
1584
+ { class: "table table-condensed w-unset" },
1585
+ tbody(
1586
+ tr(th("Run ID"), td(run.id)),
1587
+ tr(
1588
+ th("Trigger"),
1589
+ td(
1590
+ a(
1591
+ { href: `/actions/configure/${trigger.id}` },
1592
+ trigger.name
1593
+ )
1594
+ )
1595
+ ),
1596
+ tr(th("Started at"), td(localeDateTime(run.started_at))),
1597
+ tr(th("Started by user"), td(run.started_by)),
1598
+ tr(th("Status"), td(run.status)),
1599
+ run.status === "Waiting"
1600
+ ? tr(th("Waiting for"), td(JSON.stringify(run.wait_info)))
1601
+ : null,
1602
+ tr(
1603
+ th("Context"),
1604
+ td(pre(text(JSON.stringify(run.context, null, 2))))
1605
+ )
1606
+ )
1607
+ ) + post_delete_btn("/actions/delete-run/" + run.id, req),
1608
+ },
1609
+ ...(traces.length
1610
+ ? [
1611
+ {
1612
+ type: "card",
1613
+ title: req.__("Step traces"),
1614
+ contents: traces_accordion_items,
1615
+ },
1616
+ ]
1617
+ : []),
1618
+ ],
1619
+ },
1620
+ });
1621
+ })
1622
+ );
1623
+
1624
+ router.post(
1625
+ "/delete-run/:id",
1626
+ isAdmin,
1627
+ error_catcher(async (req, res) => {
1628
+ const { id } = req.params;
1629
+
1630
+ const run = await WorkflowRun.findOne({ id });
1631
+ await run.delete();
1632
+ res.redirect("/actions/runs");
1633
+ })
1634
+ );
1635
+
1636
+ const getWorkflowStepUserForm = async (run, trigger, step, user) => {
1637
+ const qTypeToField = (q) => {
1638
+ switch (q.qtype) {
1639
+ case "Yes/No":
1640
+ return {
1641
+ type: "String",
1642
+ attributes: { options: "Yes,No" },
1643
+ fieldview: "radio_group",
1644
+ };
1645
+ case "Checkbox":
1646
+ return { type: "Bool" };
1647
+ case "Free text":
1648
+ return { type: "String" };
1649
+ case "Multiple choice":
1650
+ return {
1651
+ type: "String",
1652
+ attributes: { options: q.options },
1653
+ fieldview: "radio_group",
1654
+ };
1655
+ case "Integer":
1656
+ return { type: "Integer" };
1657
+ case "Float":
1658
+ return { type: "Float" };
1659
+ default:
1660
+ return {};
1661
+ }
1662
+ };
1663
+
1664
+ const form = new Form({
1665
+ action: `/actions/fill-workflow-form/${run.id}`,
1666
+ blurb: step.configuration?.form_header || "",
1667
+ fields: (step.configuration.user_form_questions || []).map((q) => ({
1668
+ label: q.label,
1669
+ name: q.var_name,
1670
+ ...qTypeToField(q),
1671
+ })),
1672
+ });
1673
+ return form;
1674
+ };
1675
+
1676
+ router.get(
1677
+ "/fill-workflow-form/:id",
1678
+ error_catcher(async (req, res) => {
1679
+ const { id } = req.params;
1680
+
1681
+ const run = await WorkflowRun.findOne({ id });
1682
+
1683
+ if (!run.user_allowed_to_fill_form(req.user)) {
1684
+ if (req.xhr) res.json({ error: "Not authorized" });
1685
+ else {
1686
+ req.flash("danger", req.__("Not authorized"));
1687
+ res.redirect("/");
1688
+ }
1689
+ return;
1690
+ }
1691
+
1692
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1693
+ const step = await WorkflowStep.findOne({
1694
+ trigger_id: trigger.id,
1695
+ name: run.current_step,
1696
+ });
1697
+
1698
+ const form = await getWorkflowStepUserForm(run, trigger, step, req.user);
1699
+ if (req.xhr) form.xhrSubmit = true;
1700
+ const title = "Fill form";
1701
+ res.sendWrap(title, renderForm(form, req.csrfToken()));
1702
+ })
1703
+ );
1704
+
1705
+ router.post(
1706
+ "/fill-workflow-form/:id",
1707
+ error_catcher(async (req, res) => {
1708
+ const { id } = req.params;
1709
+
1710
+ const run = await WorkflowRun.findOne({ id });
1711
+ if (!run.user_allowed_to_fill_form(req.user)) {
1712
+ if (req.xhr) res.json({ error: "Not authorized" });
1713
+ else {
1714
+ req.flash("danger", req.__("Not authorized"));
1715
+ res.redirect("/");
1716
+ }
1717
+ return;
1718
+ }
1719
+
1720
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1721
+ const step = await WorkflowStep.findOne({
1722
+ trigger_id: trigger.id,
1723
+ name: run.current_step,
1724
+ });
1725
+
1726
+ const form = await getWorkflowStepUserForm(run, trigger, step, req.user);
1727
+ form.validate(req.body);
1728
+ if (form.hasErrors) {
1729
+ const title = "Fill form";
1730
+ res.sendWrap(title, renderForm(form, req.csrfToken()));
1731
+ } else {
1732
+ await run.provide_form_input(form.values);
1733
+ await run.run({
1734
+ user: req.user,
1735
+ trace: trigger.configuration?.save_traces,
1736
+ });
1737
+ if (req.xhr) {
1738
+ const retDirs = await run.popReturnDirectives();
1739
+ res.json({ success: "ok", ...retDirs });
1740
+ } else {
1741
+ if (run.context.goto) res.redirect(run.context.goto);
1742
+ else res.redirect("/");
1743
+ }
1744
+ }
1745
+ })
1746
+ );
1747
+
1748
+ router.post(
1749
+ "/resume-workflow/:id",
1750
+ error_catcher(async (req, res) => {
1751
+ const { id } = req.params;
1752
+
1753
+ const run = await WorkflowRun.findOne({ id });
1754
+ //TODO session if not logged in
1755
+ if (run.started_by !== req.user?.id) {
1756
+ if (req.xhr) res.json({ error: "Not authorized" });
1757
+ else {
1758
+ req.flash("danger", req.__("Not authorized"));
1759
+ res.redirect("/");
1760
+ }
1761
+ return;
1762
+ }
1763
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1764
+ const runResult = await run.run({
1765
+ user: req.user,
1766
+ interactive: true,
1767
+ trace: trigger.configuration?.save_traces,
1768
+ });
1769
+ if (req.xhr) {
1770
+ if (
1771
+ runResult &&
1772
+ typeof runResult === "object" &&
1773
+ Object.keys(runResult).length
1774
+ ) {
1775
+ res.json({ success: "ok", ...runResult });
1776
+ return;
1777
+ }
1778
+ const retDirs = await run.popReturnDirectives();
1779
+ res.json({ success: "ok", ...retDirs });
1780
+ } else {
1781
+ if (run.context.goto) res.redirect(run.context.goto);
1782
+ else res.redirect("/");
1783
+ }
1784
+ })
1785
+ );
1786
+
1787
+ /*
1788
+
1789
+ WORKFLOWS TODO
1790
+
1791
+ delete is not always working?
1792
+ help file to explain steps, and context
1793
+
1794
+ workflow actions: ForLoop, EndForLoop, Output, ReadFile, WriteFile, APIResponse
1795
+
1796
+ interactive workflows for not logged in
1797
+
1798
+ show unconnected steps
1799
+ why is code not initialising
1800
+ drag and drop edges
1801
+
1802
+ */
@@ -81,6 +81,31 @@ const logSettingsForm = async (req) => {
81
81
  input_type: "date",
82
82
  attributes: { minDate: new Date(), maxDate: hoursFuture(24 * 7 * 2) },
83
83
  },
84
+ {
85
+ input_type: "section_header",
86
+ label: req.__("Delete old workflow runs with status after days"),
87
+ },
88
+ {
89
+ name: "delete_finished_workflows_days",
90
+ label: req.__("Finished"),
91
+ type: "Integer",
92
+ },
93
+ {
94
+ name: "delete_error_workflows_days",
95
+ label: req.__("Error"),
96
+ type: "Integer",
97
+ },
98
+ {
99
+ name: "delete_waiting_workflows_days",
100
+ label: req.__("Waiting"),
101
+ type: "Integer",
102
+ },
103
+
104
+ {
105
+ name: "delete_running_workflows_days",
106
+ label: req.__("Running"),
107
+ type: "Integer",
108
+ },
84
109
  {
85
110
  input_type: "section_header",
86
111
  label: req.__("Which events should be logged?"),
@@ -143,6 +168,10 @@ router.get(
143
168
  "next_weekly_event",
144
169
  {}
145
170
  );
171
+ ["error", "finished", "running", "waiting"].forEach((k) => {
172
+ let cfgk = `delete_${k}_workflows_days`;
173
+ form.values[cfgk] = getState().getConfig(cfgk);
174
+ });
146
175
 
147
176
  send_events_page({
148
177
  res,
@@ -348,6 +377,13 @@ router.post(
348
377
  delete form.values[k];
349
378
  }
350
379
  }
380
+ for (const status of ["error", "finished", "running", "waiting"]) {
381
+ let k = `delete_${status}_workflows_days`;
382
+ if (form.values[k]) {
383
+ await getState().setConfig(k, form.values[k]);
384
+ delete form.values[k];
385
+ }
386
+ }
351
387
 
352
388
  await getState().setConfig("event_log_settings", form.values);
353
389
 
package/routes/fields.js CHANGED
@@ -301,6 +301,10 @@ const fieldFlow = (req) =>
301
301
  if (context.id) {
302
302
  const field = await Field.findOne({ id: context.id });
303
303
  try {
304
+ if (fldRow.label && field.label != fldRow.label) {
305
+ fldRow.name = Field.labelToName(fldRow.label);
306
+ }
307
+
304
308
  await field.update(fldRow);
305
309
  } catch (e) {
306
310
  return {
package/routes/tables.js CHANGED
@@ -757,6 +757,8 @@ router.get(
757
757
  const triggers = table.id ? Trigger.find({ table_id: table.id }) : [];
758
758
  triggers.sort(comparingCaseInsensitive("name"));
759
759
  let fieldCard;
760
+ const nPrimaryKeys = fields.filter((f) => f.primary_key).length;
761
+
760
762
  if (fields.length === 0) {
761
763
  fieldCard = [
762
764
  h4(req.__(`No fields defined in %s table`, table.name)),
@@ -818,6 +820,19 @@ router.get(
818
820
  { hover: true }
819
821
  );
820
822
  fieldCard = [
823
+ nPrimaryKeys > 1 &&
824
+ div(
825
+ { class: "alert alert-danger", role: "alert" },
826
+ i({ class: "fas fa-exclamation-triangle" }),
827
+ "This table has composite primary keys which is not supported in Saltcorn. A procedure to introduce a single autoincrementing primary key is available.",
828
+ post_btn(
829
+ `/table/repair-composite-primary/${table.id}`,
830
+ "Add autoincrementing primary key",
831
+ req.csrfToken(),
832
+ { btnClass: "btn-danger" }
833
+ )
834
+ ),
835
+
821
836
  tableHtml,
822
837
  inbound_refs.length > 0
823
838
  ? req.__("Inbound keys: ") +
@@ -1111,7 +1126,7 @@ router.post(
1111
1126
  const v = req.body;
1112
1127
  if (typeof v.id === "undefined" && typeof v.external === "undefined") {
1113
1128
  // insert
1114
- v.name = v.name.trim()
1129
+ v.name = v.name.trim();
1115
1130
  const { name, ...rest } = v;
1116
1131
  const alltables = await Table.find({});
1117
1132
  const existing_tables = [
@@ -2074,3 +2089,20 @@ router.post(
2074
2089
  respondWorkflow(table, workflow, wfres, req, res);
2075
2090
  })
2076
2091
  );
2092
+
2093
+ router.post(
2094
+ "/repair-composite-primary/:id",
2095
+ isAdmin,
2096
+ error_catcher(async (req, res) => {
2097
+ const { id } = req.params;
2098
+
2099
+ const table = Table.findOne({ id });
2100
+ if (!table) {
2101
+ req.flash("error", `Table not found`);
2102
+ res.redirect(`/table`);
2103
+ return;
2104
+ }
2105
+ await table.repairCompositePrimary();
2106
+ res.redirect(`/table/${table.id}`);
2107
+ })
2108
+ );