@saltcorn/server 1.1.0-beta.12 → 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/load_plugins.js +9 -3
- package/locales/en.json +14 -1
- package/locales/pl.json +19 -2
- package/markup/admin.js +1 -0
- package/package.json +9 -9
- package/public/saltcorn-common.js +53 -38
- package/public/saltcorn.js +31 -11
- package/routes/actions.js +895 -4
- package/routes/eventlog.js +36 -0
- package/routes/fields.js +8 -2
- package/routes/tables.js +33 -1
- package/serve.js +1 -5
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 {
|
|
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({
|
|
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") + " »"
|
|
581
947
|
);
|
|
582
|
-
if (trigger.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
|
-
|
|
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
|
+
*/
|