@saltcorn/server 1.1.0-beta.9 → 1.1.0

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/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) => {
@@ -326,6 +344,11 @@ router.get(
326
344
  isAdmin,
327
345
  error_catcher(async (req, res) => {
328
346
  const form = await triggerForm(req);
347
+ if (req.query.table) {
348
+ const table = Table.findOne({ name: req.query.table });
349
+ if (table) form.values.table_id = table.id;
350
+ }
351
+
329
352
  send_events_page({
330
353
  res,
331
354
  req,
@@ -467,6 +490,388 @@ router.post(
467
490
  })
468
491
  );
469
492
 
493
+ function genWorkflowDiagram(steps) {
494
+ const stepNames = steps.map((s) => s.name);
495
+ const nodeLines = steps.map(
496
+ (s) => ` ${s.name}["\`**${s.name}**
497
+ ${s.action_name}\`"]:::wfstep${s.id}`
498
+ );
499
+
500
+ nodeLines.unshift(` _Start@{ shape: circle, label: "Start" }`);
501
+ const linkLines = [];
502
+ let step_ix = 0;
503
+ for (const step of steps) {
504
+ if (step.initial_step) linkLines.push(` _Start --> ${step.name}`);
505
+ if (step.action_name === "ForLoop") {
506
+ linkLines.push(
507
+ ` ${step.name} --> ${step.configuration.for_loop_step_name}`
508
+ );
509
+ } else if (stepNames.includes(step.next_step)) {
510
+ linkLines.push(` ${step.name} --> ${step.next_step}`);
511
+ } else if (step.next_step) {
512
+ for (const otherStep of stepNames)
513
+ if (step.next_step.includes(otherStep))
514
+ linkLines.push(` ${step.name} --> ${otherStep}`);
515
+ }
516
+ if (step.action_name === "EndForLoop") {
517
+ // TODO this is not correct. improve.
518
+ let forStep;
519
+ for (let i = step_ix; i >= 0; i -= 1) {
520
+ if (steps[i].action_name === "ForLoop") {
521
+ forStep = steps[i];
522
+ break;
523
+ }
524
+ }
525
+ if (forStep) linkLines.push(` ${step.name} --> ${forStep.name}`);
526
+ }
527
+ step_ix += 1;
528
+ }
529
+ const fc =
530
+ "flowchart TD\n" + nodeLines.join("\n") + "\n" + linkLines.join("\n");
531
+ return fc;
532
+ }
533
+
534
+ const getWorkflowConfig = async (req, id, table, trigger) => {
535
+ let steps = await WorkflowStep.find(
536
+ { trigger_id: trigger.id },
537
+ { orderBy: "id" }
538
+ );
539
+ const initial_step = steps.find((step) => step.initial_step);
540
+ if (initial_step)
541
+ steps = [initial_step, ...steps.filter((s) => !s.initial_step)];
542
+ const trigCfgForm = new Form({
543
+ action: addOnDoneRedirect(`/actions/configure/${id}`, req),
544
+ onChange: "saveAndContinue(this)",
545
+ noSubmitButton: true,
546
+ formStyle: "vert",
547
+ fields: [
548
+ {
549
+ name: "save_traces",
550
+ label: "Save step traces for each run",
551
+ type: "Bool",
552
+ },
553
+ ],
554
+ });
555
+ trigCfgForm.values = trigger.configuration;
556
+ return (
557
+ pre({ class: "mermaid" }, genWorkflowDiagram(steps)) +
558
+ script(
559
+ { defer: "defer" },
560
+ `function tryAddWFNodes() {
561
+ const ns = $("g.node");
562
+ if(!ns.length) setTimeout(tryAddWFNodes, 200)
563
+ else {
564
+ $("g.node").on("click", (e)=>{
565
+ const $e = $(e.target || e).closest("g.node")
566
+ const cls = $e.attr('class')
567
+ if(!cls || !cls.includes("wfstep")) return;
568
+ const id = cls.split(" ").find(c=>c.startsWith("wfstep")).
569
+ substr(6);
570
+ location.href = '/actions/stepedit/${trigger.id}/'+id;
571
+ //console.log($e.attr('class'), id)
572
+ })
573
+ }
574
+ }
575
+ window.addEventListener('DOMContentLoaded',tryAddWFNodes)`
576
+ ) +
577
+ a(
578
+ {
579
+ href: `/actions/stepedit/${trigger.id}${
580
+ initial_step ? "" : "?initial_step=true"
581
+ }`,
582
+ class: "btn btn-primary",
583
+ },
584
+ i({ class: "fas fa-plus me-2" }),
585
+ "Add step"
586
+ ) +
587
+ a(
588
+ {
589
+ href: `/actions/runs/?trigger=${trigger.id}`,
590
+ class: "d-block",
591
+ },
592
+ "Show runs »"
593
+ ) +
594
+ renderForm(trigCfgForm, req.csrfToken())
595
+ );
596
+ };
597
+
598
+ const jsIdentifierValidator = (s) => {
599
+ if (!s) return "An identifier is required";
600
+ if (s.includes(" ")) return "Spaces not allowd";
601
+ let badc = "'#:/\\@()[]{}\"!%^&*-+*~<>,.?|"
602
+ .split("")
603
+ .find((c) => s.includes(c));
604
+
605
+ if (badc) return `Character ${badc} not allowed`;
606
+ };
607
+
608
+ const getWorkflowStepForm = async (trigger, req, step_id) => {
609
+ const table = trigger.table_id ? Table.findOne(trigger.table_id) : null;
610
+ const actionExplainers = {};
611
+
612
+ let stateActions = getState().actions;
613
+ const stateActionKeys = Object.entries(stateActions)
614
+ .filter(([k, v]) => !v.disableInWorkflow)
615
+ .map(([k, v]) => k);
616
+
617
+ const actionConfigFields = [];
618
+ for (const [name, action] of Object.entries(stateActions)) {
619
+ if (!stateActionKeys.includes(name)) continue;
620
+
621
+ if (action.description) actionExplainers[name] = action.description;
622
+
623
+ try {
624
+ const cfgFields = await getActionConfigFields(action, table, {
625
+ mode: "workflow",
626
+ });
627
+
628
+ for (const field of cfgFields) {
629
+ const cfgFld = {
630
+ ...field,
631
+ showIf: {
632
+ wf_action_name: name,
633
+ ...(field.showIf || {}),
634
+ },
635
+ };
636
+ if (cfgFld.input_type === "code") cfgFld.input_type = "textarea";
637
+ actionConfigFields.push(cfgFld);
638
+ }
639
+ } catch {}
640
+ }
641
+ const actionsNotRequiringRow = Trigger.action_options({
642
+ notRequireRow: true,
643
+ noMultiStep: true,
644
+ builtInLabel: "Workflow Actions",
645
+ builtIns: [
646
+ "SetContext",
647
+ "TableQuery",
648
+ "Output",
649
+ "WaitUntil",
650
+ "WaitNextTick",
651
+ "UserForm",
652
+ ],
653
+ forWorkflow: true,
654
+ });
655
+ const triggers = Trigger.find({
656
+ when_trigger: { or: ["API call", "Never"] },
657
+ });
658
+ triggers.forEach((tr) => {
659
+ if (tr.description) actionExplainers[tr.name] = tr.description;
660
+ });
661
+ actionExplainers.SetContext = "Set variables in the context";
662
+ actionExplainers.TableQuery = "Query a table into a variable in the context";
663
+ actionExplainers.Output =
664
+ "Display a message to the user. Pause workflow until the message is read.";
665
+ actionExplainers.WaitUntil = "Pause until a time in the future";
666
+ actionExplainers.WaitNextTick =
667
+ "Pause until the next scheduler invocation (at most 5 minutes)";
668
+ actionExplainers.UserForm =
669
+ "Ask a user one or more questions, pause until they are answered";
670
+
671
+ actionConfigFields.push({
672
+ label: "Form header",
673
+ sublabel: "Text shown to the user at the top of the form",
674
+ name: "form_header",
675
+ type: "String",
676
+ showIf: { wf_action_name: "UserForm" },
677
+ });
678
+ actionConfigFields.push({
679
+ label: "User ID",
680
+ name: "user_id_expression",
681
+ type: "String",
682
+ sublabel: "Optional. If blank assigned to user starting the workflow",
683
+ showIf: { wf_action_name: "UserForm" },
684
+ });
685
+ actionConfigFields.push({
686
+ label: "Resume at",
687
+ name: "resume_at",
688
+ sublabel:
689
+ "JavaScript expression for the time to resume. <code>moment</code> is in scope.",
690
+ type: "String",
691
+ showIf: { wf_action_name: "WaitUntil" },
692
+ });
693
+ actionConfigFields.push({
694
+ label: "Context values",
695
+ name: "ctx_values",
696
+ sublabel:
697
+ "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",
698
+ type: "String",
699
+ fieldview: "textarea",
700
+ class: "validate-expression",
701
+ default: "{}",
702
+ showIf: { wf_action_name: "SetContext" },
703
+ });
704
+ actionConfigFields.push({
705
+ label: "Output text",
706
+ name: "output_text",
707
+ sublabel:
708
+ "Message shown to the user. Can contain HTML tags and use interpolations {{ }} to access the context",
709
+ type: "String",
710
+ fieldview: "textarea",
711
+ showIf: { wf_action_name: "Output" },
712
+ });
713
+ actionConfigFields.push({
714
+ label: "Table",
715
+ name: "query_table",
716
+ type: "String",
717
+ required: true,
718
+ attributes: { options: (await Table.find()).map((t) => t.name) },
719
+ showIf: { wf_action_name: "TableQuery" },
720
+ });
721
+ actionConfigFields.push({
722
+ label: "Query",
723
+ name: "query_object",
724
+ sublabel: "Where object, example <code>{manager: 1}</code>",
725
+ type: "String",
726
+ required: true,
727
+ class: "validate-expression",
728
+ default: "{}",
729
+ showIf: { wf_action_name: "TableQuery" },
730
+ });
731
+ actionConfigFields.push({
732
+ label: "Variable",
733
+ name: "query_variable",
734
+ sublabel: "Context variable to write to query results to",
735
+ type: "String",
736
+ required: true,
737
+ validator: jsIdentifierValidator,
738
+ showIf: { wf_action_name: "TableQuery" },
739
+ });
740
+ actionConfigFields.push(
741
+ new FieldRepeat({
742
+ name: "user_form_questions",
743
+ showIf: { wf_action_name: "UserForm" },
744
+ fields: [
745
+ {
746
+ label: "Label",
747
+ name: "label",
748
+ type: "String",
749
+ sublabel:
750
+ "The text that will shown to the user above the input elements",
751
+ },
752
+ {
753
+ label: "Variable name",
754
+ name: "var_name",
755
+ type: "String",
756
+ sublabel:
757
+ "The answer will be set in the context with this variable name",
758
+ validator: jsIdentifierValidator,
759
+ },
760
+ {
761
+ label: "Input Type",
762
+ name: "qtype",
763
+ type: "String",
764
+ required: true,
765
+ attributes: {
766
+ options: [
767
+ "Yes/No",
768
+ "Checkbox",
769
+ "Free text",
770
+ "Multiple choice",
771
+ //"Multiple checks",
772
+ "Integer",
773
+ "Float",
774
+ //"File upload",
775
+ ],
776
+ },
777
+ },
778
+ {
779
+ label: "Options",
780
+ name: "options",
781
+ type: "String",
782
+ sublabel: "Comma separated list of multiple choice options",
783
+ showIf: { qtype: ["Multiple choice", "Multiple checks"] },
784
+ },
785
+ ],
786
+ })
787
+ );
788
+
789
+ const form = new Form({
790
+ action: addOnDoneRedirect(`/actions/stepedit/${trigger.id}`, req),
791
+ onChange: "saveAndContinueIfValid(this)",
792
+ submitLabel: req.__("Done"),
793
+ additionalButtons: step_id
794
+ ? [
795
+ {
796
+ label: req.__("Delete"),
797
+ class: "btn btn-outline-danger",
798
+ onclick: `ajax_post('/actions/delete-step/${+step_id}')`,
799
+ afterSave: true,
800
+ },
801
+ ]
802
+ : undefined,
803
+ fields: [
804
+ {
805
+ input_type: "section_header",
806
+ label: req.__("Step settings"),
807
+ },
808
+ {
809
+ name: "wf_step_name",
810
+ label: req.__("Step name"),
811
+ type: "String",
812
+ required: true,
813
+ sublabel: "An identifier by which this step can be referred to.",
814
+ validator: jsIdentifierValidator,
815
+ },
816
+ {
817
+ name: "wf_initial_step",
818
+ label: req.__("Initial step"),
819
+ sublabel: "Is this the first step in the workflow?",
820
+ type: "Bool",
821
+ },
822
+ {
823
+ name: "wf_only_if",
824
+ label: req.__("Only if..."),
825
+ sublabel:
826
+ "Optional JavaScript expression based on the run context. If given, the chosen action will only be executed if evaluates to true",
827
+ type: "String",
828
+ },
829
+ {
830
+ name: "wf_next_step",
831
+ label: req.__("Next step"),
832
+ type: "String",
833
+ class: "validate-expression",
834
+ sublabel:
835
+ "Name of next step. Can be a JavaScript expression based on the run context. Blank if final step",
836
+ },
837
+ {
838
+ input_type: "section_header",
839
+ label: req.__("Action"),
840
+ },
841
+ {
842
+ name: "wf_action_name",
843
+ label: req.__("Action"),
844
+ type: "String",
845
+ required: true,
846
+ attributes: {
847
+ options: actionsNotRequiringRow,
848
+ explainers: actionExplainers,
849
+ },
850
+ },
851
+ {
852
+ input_type: "section_header",
853
+ label: req.__("Action settings"),
854
+ },
855
+ ...actionConfigFields,
856
+ ],
857
+ });
858
+ form.hidden("wf_step_id");
859
+ if (step_id) {
860
+ const step = await WorkflowStep.findOne({ id: step_id });
861
+ if (!step) throw new Error("Step not found");
862
+ form.values = {
863
+ wf_step_id: step.id,
864
+ wf_step_name: step.name,
865
+ wf_initial_step: step.initial_step,
866
+ wf_only_if: step.only_if,
867
+ wf_action_name: step.action_name,
868
+ wf_next_step: step.next_step,
869
+ ...step.configuration,
870
+ };
871
+ }
872
+ return form;
873
+ };
874
+
470
875
  const getMultiStepForm = async (req, id, table) => {
471
876
  let stateActions = getState().actions;
472
877
  const stateActionKeys = Object.entries(stateActions)
@@ -579,7 +984,35 @@ router.get(
579
984
  { href: `/actions/testrun/${id}`, class: "ms-2" },
580
985
  req.__("Test run") + "&nbsp;&raquo;"
581
986
  );
582
- if (trigger.action === "Multi-step action") {
987
+ if (trigger.action === "Workflow") {
988
+ const wfCfg = await getWorkflowConfig(req, id, table, trigger);
989
+ send_events_page({
990
+ res,
991
+ req,
992
+ active_sub: "Triggers",
993
+ sub2_page: "Configure",
994
+ page_title: req.__(`%s configuration`, trigger.name),
995
+ headers: [
996
+ {
997
+ script: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
998
+ },
999
+ {
1000
+ headerTag: `<script type="module">mermaid.initialize({securityLevel: 'loose'${
1001
+ getState().getLightDarkMode(req.user) === "dark"
1002
+ ? ",theme: 'dark',"
1003
+ : ""
1004
+ }});</script>`,
1005
+ },
1006
+ ],
1007
+ contents: {
1008
+ type: "card",
1009
+ titleAjaxIndicator: true,
1010
+ title: req.__("Configure trigger %s", trigger.name),
1011
+ subtitle,
1012
+ contents: wfCfg,
1013
+ },
1014
+ });
1015
+ } else if (trigger.action === "Multi-step action") {
583
1016
  const form = await getMultiStepForm(req, id, table);
584
1017
  form.values = trigger.configuration;
585
1018
  send_events_page({
@@ -725,6 +1158,11 @@ router.post(
725
1158
  let form;
726
1159
  if (trigger.action === "Multi-step action") {
727
1160
  form = await getMultiStepForm(req, id, table);
1161
+ } else if (trigger.action === "Workflow") {
1162
+ form = new Form({
1163
+ action: `/actions/configure/${id}`,
1164
+ fields: [{ name: "save_traces", label: "Save traces", type: "Bool" }],
1165
+ });
728
1166
  } else {
729
1167
  const cfgFields = await getActionConfigFields(action, table, {
730
1168
  mode: "trigger",
@@ -830,6 +1268,7 @@ router.get(
830
1268
  table,
831
1269
  row,
832
1270
  req,
1271
+ interactive: true,
833
1272
  ...(row || {}),
834
1273
  Table,
835
1274
  user: req.user,
@@ -848,7 +1287,13 @@ router.get(
848
1287
  ? script(domReady(`common_done(${JSON.stringify(runres)})`))
849
1288
  : ""
850
1289
  );
851
- res.redirect(`/actions/`);
1290
+ if (trigger.action === "Workflow")
1291
+ res.redirect(
1292
+ runres?.__wf_run_id
1293
+ ? `/actions/run/${runres?.__wf_run_id}`
1294
+ : `/actions/runs/?trigger=${trigger.id}`
1295
+ );
1296
+ else res.redirect(`/actions/`);
852
1297
  } else {
853
1298
  send_events_page({
854
1299
  res,
@@ -909,3 +1354,490 @@ router.post(
909
1354
  res.redirect(`/actions`);
910
1355
  })
911
1356
  );
1357
+
1358
+ /**
1359
+ * @name post/clone/:id
1360
+ * @function
1361
+ * @memberof module:routes/actions~actionsRouter
1362
+ * @function
1363
+ */
1364
+ router.get(
1365
+ "/stepedit/:trigger_id/:step_id?",
1366
+ isAdmin,
1367
+ error_catcher(async (req, res) => {
1368
+ const { trigger_id, step_id } = req.params;
1369
+ const { initial_step, name } = req.query;
1370
+ const trigger = await Trigger.findOne({ id: trigger_id });
1371
+ const form = await getWorkflowStepForm(trigger, req, step_id);
1372
+
1373
+ if (initial_step) form.values.wf_initial_step = true;
1374
+ if (!step_id) {
1375
+ const steps = await WorkflowStep.find({ trigger_id });
1376
+ const stepNames = new Set(steps.map((s) => s.name));
1377
+ let name_ix = steps.length + 1;
1378
+ while (stepNames.has(`step${name_ix}`)) name_ix += 1;
1379
+ form.values.wf_step_name = `step${name_ix}`;
1380
+ }
1381
+ send_events_page({
1382
+ res,
1383
+ req,
1384
+ active_sub: "Triggers",
1385
+ sub2_page: "Configure",
1386
+ page_title: req.__(`%s configuration`, trigger.name),
1387
+ contents: {
1388
+ type: "card",
1389
+ titleAjaxIndicator: true,
1390
+ title: req.__(
1391
+ "Configure trigger %s",
1392
+ a({ href: `/actions/configure/${trigger.id}` }, trigger.name)
1393
+ ),
1394
+ contents: renderForm(form, req.csrfToken()),
1395
+ },
1396
+ });
1397
+ })
1398
+ );
1399
+
1400
+ router.post(
1401
+ "/stepedit/:trigger_id",
1402
+ isAdmin,
1403
+ error_catcher(async (req, res) => {
1404
+ const { trigger_id } = req.params;
1405
+ const trigger = await Trigger.findOne({ id: trigger_id });
1406
+ const form = await getWorkflowStepForm(trigger, req);
1407
+ form.validate(req.body);
1408
+ if (form.hasErrors) {
1409
+ if (req.xhr) {
1410
+ res.json({ error: form.errorSummary });
1411
+ } else
1412
+ send_events_page({
1413
+ res,
1414
+ req,
1415
+ active_sub: "Triggers",
1416
+ sub2_page: "Configure",
1417
+ page_title: req.__(`%s configuration`, trigger.name),
1418
+ contents: {
1419
+ type: "card",
1420
+ titleAjaxIndicator: true,
1421
+ title: req.__(
1422
+ "Configure trigger %s",
1423
+ a({ href: `/actions/configure/${trigger.id}` }, trigger.name)
1424
+ ),
1425
+ contents: renderForm(form, req.csrfToken()),
1426
+ },
1427
+ });
1428
+ return;
1429
+ }
1430
+ const {
1431
+ wf_step_name,
1432
+ wf_action_name,
1433
+ wf_next_step,
1434
+ wf_initial_step,
1435
+ wf_only_if,
1436
+ wf_step_id,
1437
+ ...configuration
1438
+ } = form.values;
1439
+ Object.entries(configuration).forEach(([k, v]) => {
1440
+ if (v === null) delete configuration[k];
1441
+ });
1442
+ const step = {
1443
+ name: wf_step_name,
1444
+ action_name: wf_action_name,
1445
+ next_step: wf_next_step,
1446
+ only_if: wf_only_if,
1447
+ initial_step: wf_initial_step,
1448
+ trigger_id,
1449
+ configuration,
1450
+ };
1451
+ try {
1452
+ if (wf_step_id && wf_step_id !== "undefined") {
1453
+ const wfStep = new WorkflowStep({ id: wf_step_id, ...step });
1454
+
1455
+ await wfStep.update(step);
1456
+ if (req.xhr) res.json({ success: "ok" });
1457
+ else {
1458
+ req.flash("success", req.__("Step saved"));
1459
+ res.redirect(`/actions/configure/${step.trigger_id}`);
1460
+ }
1461
+ } else {
1462
+ //insert
1463
+
1464
+ const id = await WorkflowStep.create(step);
1465
+ if (req.xhr)
1466
+ res.json({ success: "ok", set_fields: { wf_step_id: id } });
1467
+ else {
1468
+ req.flash("success", req.__("Step saved"));
1469
+ res.redirect(`/actions/configure/${step.trigger_id}`);
1470
+ }
1471
+ }
1472
+ } catch (e) {
1473
+ const emsg =
1474
+ e.message ===
1475
+ 'duplicate key value violates unique constraint "workflow_steps_name_uniq"'
1476
+ ? `A step with the name ${wf_step_name} already exists`
1477
+ : e.message;
1478
+ if (req.xhr) res.json({ error: emsg });
1479
+ else {
1480
+ req.flash("error", emsg);
1481
+ res.redirect(`/actions/configure/${step.trigger_id}`);
1482
+ }
1483
+ }
1484
+ })
1485
+ );
1486
+
1487
+ router.post(
1488
+ "/delete-step/:step_id",
1489
+ isAdmin,
1490
+ error_catcher(async (req, res) => {
1491
+ const { step_id } = req.params;
1492
+ const step = await WorkflowStep.findOne({ id: step_id });
1493
+ await step.delete();
1494
+ res.json({ goto: `/actions/configure/${step.trigger_id}` });
1495
+ })
1496
+ );
1497
+
1498
+ router.get(
1499
+ "/runs",
1500
+ isAdmin,
1501
+ error_catcher(async (req, res) => {
1502
+ const trNames = {};
1503
+ const { _page, trigger } = req.query;
1504
+ for (const trig of await Trigger.find({ action: "Workflow" }))
1505
+ trNames[trig.id] = trig.name;
1506
+ const q = {};
1507
+ const selOpts = { orderBy: "started_at", orderDesc: true, limit: 20 };
1508
+ if (_page) selOpts.offset = 20 * (parseInt(_page) - 1);
1509
+ if (trigger) q.trigger_id = trigger;
1510
+ const runs = await WorkflowRun.find(q, selOpts);
1511
+ const count = await WorkflowRun.count(q);
1512
+
1513
+ const wfTable = mkTable(
1514
+ [
1515
+ { label: "Trigger", key: (run) => trNames[run.trigger_id] },
1516
+ { label: "Started", key: (run) => localeDateTime(run.started_at) },
1517
+ { label: "Status", key: "status" },
1518
+ {
1519
+ label: "",
1520
+ key: (run) => {
1521
+ switch (run.status) {
1522
+ case "Running":
1523
+ return run.current_step;
1524
+ case "Error":
1525
+ return run.error;
1526
+ case "Waiting":
1527
+ if (run.wait_info?.form || run.wait_info.output)
1528
+ return a(
1529
+ { href: `/actions/fill-workflow-form/${run.id}` },
1530
+ run.wait_info.output ? "Show " : "Fill ",
1531
+ run.current_step
1532
+ );
1533
+ return run.current_step;
1534
+ default:
1535
+ return "";
1536
+ }
1537
+ },
1538
+ },
1539
+ ],
1540
+ runs,
1541
+ {
1542
+ onRowSelect: (row) => `location.href='/actions/run/${row.id}'`,
1543
+ pagination: {
1544
+ current_page: parseInt(_page) || 1,
1545
+ pages: Math.ceil(count / 20),
1546
+ get_page_link: (n) => `gopage(${n}, 20)`,
1547
+ },
1548
+ }
1549
+ );
1550
+ send_events_page({
1551
+ res,
1552
+ req,
1553
+ active_sub: "Workflow runs",
1554
+ page_title: req.__(`Workflow runs`),
1555
+ contents: {
1556
+ type: "card",
1557
+ titleAjaxIndicator: true,
1558
+ title: req.__("Workflow runs"),
1559
+ contents: wfTable,
1560
+ },
1561
+ });
1562
+ })
1563
+ );
1564
+
1565
+ router.get(
1566
+ "/run/:id",
1567
+ isAdmin,
1568
+ error_catcher(async (req, res) => {
1569
+ const { id } = req.params;
1570
+
1571
+ const run = await WorkflowRun.findOne({ id });
1572
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1573
+ const traces = await WorkflowTrace.find(
1574
+ { run_id: run.id },
1575
+ { orderBy: "id" }
1576
+ );
1577
+ const traces_accordion_items = div(
1578
+ { class: "accordion" },
1579
+ traces.map((trace, ix) =>
1580
+ div(
1581
+ { class: "accordion-item" },
1582
+
1583
+ h2(
1584
+ { class: "accordion-header", id: `trhead${ix}` },
1585
+ button(
1586
+ {
1587
+ class: ["accordion-button", "collapsed"],
1588
+ type: "button",
1589
+
1590
+ "data-bs-toggle": "collapse",
1591
+ "data-bs-target": `#trtab${ix}`,
1592
+ "aria-expanded": "false",
1593
+ "aria-controls": `trtab${ix}`,
1594
+ },
1595
+ `${ix + 1}: ${trace.step_name_run}`
1596
+ )
1597
+ ),
1598
+ div(
1599
+ {
1600
+ class: ["accordion-collapse", "collapse"],
1601
+ id: `trtab${ix}`,
1602
+ "aria-labelledby": `trhead${ix}`,
1603
+ },
1604
+ div(
1605
+ { class: ["accordion-body"] },
1606
+ table(
1607
+ { class: "table table-condensed w-unset" },
1608
+ tbody(
1609
+ tr(
1610
+ th("Started at"),
1611
+ td(localeDateTime(trace.step_started_at))
1612
+ ),
1613
+ tr(th("Elapsed"), td(trace.elapsed, "s")),
1614
+ tr(th("Run by user"), td(trace.user_id)),
1615
+ tr(th("Status"), td(trace.status)),
1616
+ trace.status === "Waiting"
1617
+ ? tr(th("Waiting for"), td(JSON.stringify(trace.wait_info)))
1618
+ : null,
1619
+ tr(
1620
+ th("Context"),
1621
+ td(pre(text(JSON.stringify(trace.context, null, 2))))
1622
+ )
1623
+ )
1624
+ )
1625
+ )
1626
+ )
1627
+ )
1628
+ )
1629
+ );
1630
+
1631
+ send_events_page({
1632
+ res,
1633
+ req,
1634
+ active_sub: "Workflow runs",
1635
+ page_title: req.__(`Workflow runs`),
1636
+ sub2_page: trigger.name,
1637
+ contents: {
1638
+ above: [
1639
+ {
1640
+ type: "card",
1641
+ titleAjaxIndicator: true,
1642
+ title: req.__("Workflow run"),
1643
+ contents:
1644
+ table(
1645
+ { class: "table table-condensed w-unset" },
1646
+ tbody(
1647
+ tr(th("Run ID"), td(run.id)),
1648
+ tr(
1649
+ th("Trigger"),
1650
+ td(
1651
+ a(
1652
+ { href: `/actions/configure/${trigger.id}` },
1653
+ trigger.name
1654
+ )
1655
+ )
1656
+ ),
1657
+ tr(th("Started at"), td(localeDateTime(run.started_at))),
1658
+ tr(th("Started by user"), td(run.started_by)),
1659
+ tr(th("Status"), td(run.status)),
1660
+ run.status === "Waiting"
1661
+ ? tr(th("Waiting for"), td(JSON.stringify(run.wait_info)))
1662
+ : null,
1663
+ run.status === "Error"
1664
+ ? tr(th("Error message"), td(run.error))
1665
+ : null,
1666
+ tr(
1667
+ th("Context"),
1668
+ td(pre(text(JSON.stringify(run.context, null, 2))))
1669
+ )
1670
+ )
1671
+ ) + post_delete_btn("/actions/delete-run/" + run.id, req),
1672
+ },
1673
+ ...(traces.length
1674
+ ? [
1675
+ {
1676
+ type: "card",
1677
+ title: req.__("Step traces"),
1678
+ contents: traces_accordion_items,
1679
+ },
1680
+ ]
1681
+ : []),
1682
+ ],
1683
+ },
1684
+ });
1685
+ })
1686
+ );
1687
+
1688
+ router.post(
1689
+ "/delete-run/:id",
1690
+ isAdmin,
1691
+ error_catcher(async (req, res) => {
1692
+ const { id } = req.params;
1693
+
1694
+ const run = await WorkflowRun.findOne({ id });
1695
+ await run.delete();
1696
+ res.redirect("/actions/runs");
1697
+ })
1698
+ );
1699
+
1700
+ const getWorkflowStepUserForm = async (run, trigger, step, req) => {
1701
+ const form = new Form({
1702
+ action: `/actions/fill-workflow-form/${run.id}`,
1703
+ submitLabel: run.wait_info.output ? req.__("OK") : req.__("Submit"),
1704
+ blurb: run.wait_info.output || step.configuration?.form_header || "",
1705
+ formStyle: run.wait_info.output || req.xhr ? "vert" : undefined,
1706
+ fields: await run.userFormFields(step),
1707
+ });
1708
+ return form;
1709
+ };
1710
+
1711
+ router.get(
1712
+ "/fill-workflow-form/:id",
1713
+ error_catcher(async (req, res) => {
1714
+ const { id } = req.params;
1715
+
1716
+ const run = await WorkflowRun.findOne({ id });
1717
+
1718
+ if (!run.user_allowed_to_fill_form(req.user)) {
1719
+ if (req.xhr) res.json({ error: "Not authorized" });
1720
+ else {
1721
+ req.flash("danger", req.__("Not authorized"));
1722
+ res.redirect("/");
1723
+ }
1724
+ return;
1725
+ }
1726
+
1727
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1728
+ const step = await WorkflowStep.findOne({
1729
+ trigger_id: trigger.id,
1730
+ name: run.current_step,
1731
+ });
1732
+
1733
+ const form = await getWorkflowStepUserForm(run, trigger, step, req);
1734
+ if (req.xhr) form.xhrSubmit = true;
1735
+ const title = run.wait_info.output ? "Workflow output" : "Fill form";
1736
+ res.sendWrap(title, renderForm(form, req.csrfToken()));
1737
+ })
1738
+ );
1739
+
1740
+ router.post(
1741
+ "/fill-workflow-form/:id",
1742
+ error_catcher(async (req, res) => {
1743
+ const { id } = req.params;
1744
+
1745
+ const run = await WorkflowRun.findOne({ id });
1746
+ if (!run.user_allowed_to_fill_form(req.user)) {
1747
+ if (req.xhr) res.json({ error: "Not authorized" });
1748
+ else {
1749
+ req.flash("danger", req.__("Not authorized"));
1750
+ res.redirect("/");
1751
+ }
1752
+ return;
1753
+ }
1754
+
1755
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1756
+ const step = await WorkflowStep.findOne({
1757
+ trigger_id: trigger.id,
1758
+ name: run.current_step,
1759
+ });
1760
+
1761
+ const form = await getWorkflowStepUserForm(run, trigger, step, req);
1762
+ form.validate(req.body);
1763
+ if (form.hasErrors) {
1764
+ const title = "Fill form";
1765
+ res.sendWrap(title, renderForm(form, req.csrfToken()));
1766
+ } else {
1767
+ await run.provide_form_input(form.values);
1768
+ const runres = await run.run({
1769
+ user: req.user,
1770
+ trace: trigger.configuration?.save_traces,
1771
+ interactive: true,
1772
+ });
1773
+ if (req.xhr) {
1774
+ const retDirs = await run.popReturnDirectives();
1775
+
1776
+ if (runres?.popup) retDirs.popup = runres.popup;
1777
+ res.json({ success: "ok", ...retDirs });
1778
+ } else {
1779
+ if (run.context.goto) res.redirect(run.context.goto);
1780
+ else res.redirect("/");
1781
+ }
1782
+ }
1783
+ })
1784
+ );
1785
+
1786
+ router.post(
1787
+ "/resume-workflow/:id",
1788
+ error_catcher(async (req, res) => {
1789
+ const { id } = req.params;
1790
+
1791
+ const run = await WorkflowRun.findOne({ id });
1792
+ //TODO session if not logged in
1793
+ if (run.started_by !== req.user?.id) {
1794
+ if (req.xhr) res.json({ error: "Not authorized" });
1795
+ else {
1796
+ req.flash("danger", req.__("Not authorized"));
1797
+ res.redirect("/");
1798
+ }
1799
+ return;
1800
+ }
1801
+ const trigger = await Trigger.findOne({ id: run.trigger_id });
1802
+ const runResult = await run.run({
1803
+ user: req.user,
1804
+ interactive: true,
1805
+ trace: trigger.configuration?.save_traces,
1806
+ });
1807
+ if (req.xhr) {
1808
+ if (
1809
+ runResult &&
1810
+ typeof runResult === "object" &&
1811
+ Object.keys(runResult).length
1812
+ ) {
1813
+ res.json({ success: "ok", ...runResult });
1814
+ return;
1815
+ }
1816
+ const retDirs = await run.popReturnDirectives();
1817
+ res.json({ success: "ok", ...retDirs });
1818
+ } else {
1819
+ if (run.context.goto) res.redirect(run.context.goto);
1820
+ else res.redirect("/");
1821
+ }
1822
+ })
1823
+ );
1824
+
1825
+ /*
1826
+
1827
+ WORKFLOWS TODO
1828
+
1829
+ help file to explain steps, and context
1830
+
1831
+ workflow actions: ForLoop, EndForLoop, ReadFile, WriteFile, APIResponse
1832
+
1833
+ Error handlers
1834
+ other triggers can be steps
1835
+ interactive workflows for not logged in
1836
+ show end node in diagram
1837
+ actions can declare which variables they inject into scope
1838
+
1839
+ show unconnected steps
1840
+ why is code not initialising
1841
+ drag and drop edges
1842
+
1843
+ */