@syntax-syllogism/flow-delta 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/cli.js +745 -63
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -5,6 +5,11 @@ A semantic, visual diff for Salesforce Flows: parse two versions of a
|
|
|
5
5
|
interactive HTML artifact (plus `diff.json`) that shows added / deleted /
|
|
6
6
|
modified / unchanged nodes and edges with per-property deltas.
|
|
7
7
|
|
|
8
|
+
Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with
|
|
9
|
+
a focus on making it work with GitLab's pipelines. We also opted for our own HTML output over plantuml, graphviz, or mermaid.
|
|
10
|
+
|
|
11
|
+
[Sample Gitlab Project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/1)
|
|
12
|
+
|
|
8
13
|
## Usage
|
|
9
14
|
|
|
10
15
|
Runs as a TypeScript CLI via `tsx` (no build step). See [docs/cli.md](docs/cli.md)
|
package/dist/cli.js
CHANGED
|
@@ -737,7 +737,9 @@ function classifyNode(oldNode, newNode) {
|
|
|
737
737
|
type: newNode.type,
|
|
738
738
|
label: newNode.label,
|
|
739
739
|
status: "modified",
|
|
740
|
-
changes
|
|
740
|
+
changes,
|
|
741
|
+
before: oldNode.properties,
|
|
742
|
+
after: newNode.properties
|
|
741
743
|
};
|
|
742
744
|
}
|
|
743
745
|
function classifyEdge(oldEdge, newEdge) {
|
|
@@ -766,7 +768,38 @@ function toEdgeDiff(edge, status) {
|
|
|
766
768
|
// src/render/layout.ts
|
|
767
769
|
import ELK from "elkjs/lib/elk.bundled.js";
|
|
768
770
|
async function layoutDiff(diff) {
|
|
769
|
-
const
|
|
771
|
+
const union = await layoutView(diff, () => true, () => true);
|
|
772
|
+
const after = await layoutView(
|
|
773
|
+
diff,
|
|
774
|
+
(node) => node.status !== "deleted",
|
|
775
|
+
(edge) => edge.status !== "deleted"
|
|
776
|
+
);
|
|
777
|
+
const before = await layoutView(
|
|
778
|
+
diff,
|
|
779
|
+
(node) => node.status !== "added",
|
|
780
|
+
(edge) => edge.status !== "added"
|
|
781
|
+
);
|
|
782
|
+
return {
|
|
783
|
+
diff,
|
|
784
|
+
nodes: union.nodes,
|
|
785
|
+
edges: union.edges,
|
|
786
|
+
width: union.width,
|
|
787
|
+
height: union.height,
|
|
788
|
+
views: {
|
|
789
|
+
union,
|
|
790
|
+
after,
|
|
791
|
+
before
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
var elk = new ELK();
|
|
796
|
+
async function layoutView(diff, nodeVisible, edgeVisible) {
|
|
797
|
+
const nodes = diff.nodes.filter(nodeVisible);
|
|
798
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
799
|
+
const edges = diff.edges.filter((edge) => edgeVisible(edge) && nodeIds.has(edge.source) && nodeIds.has(edge.target));
|
|
800
|
+
if (nodes.length === 0) {
|
|
801
|
+
return { nodes: [], edges: [], width: 40, height: 40 };
|
|
802
|
+
}
|
|
770
803
|
const graph = {
|
|
771
804
|
id: "root",
|
|
772
805
|
layoutOptions: {
|
|
@@ -775,12 +808,12 @@ async function layoutDiff(diff) {
|
|
|
775
808
|
"elk.layered.spacing.nodeNodeBetweenLayers": 60,
|
|
776
809
|
"elk.spacing.nodeNode": 40
|
|
777
810
|
},
|
|
778
|
-
children:
|
|
811
|
+
children: nodes.map((node) => ({
|
|
779
812
|
id: node.id,
|
|
780
813
|
width: estimateWidth(node.label),
|
|
781
814
|
height: 48
|
|
782
815
|
})),
|
|
783
|
-
edges:
|
|
816
|
+
edges: edges.map((edge) => ({
|
|
784
817
|
id: edge.id,
|
|
785
818
|
sources: [edge.source],
|
|
786
819
|
targets: [edge.target]
|
|
@@ -788,10 +821,10 @@ async function layoutDiff(diff) {
|
|
|
788
821
|
};
|
|
789
822
|
const laidOut = await elk.layout(graph);
|
|
790
823
|
const children = laidOut.children ?? [];
|
|
791
|
-
const
|
|
824
|
+
const edgeLayouts = laidOut.edges ?? [];
|
|
792
825
|
const nodeMap = new Map(children.map((node) => [node.id, node]));
|
|
793
|
-
const edgeMap = new Map(
|
|
794
|
-
const
|
|
826
|
+
const edgeMap = new Map(edgeLayouts.map((edge) => [edge.id, edge]));
|
|
827
|
+
const positionedNodes = nodes.map((node) => {
|
|
795
828
|
const positioned = nodeMap.get(node.id);
|
|
796
829
|
return {
|
|
797
830
|
...node,
|
|
@@ -801,14 +834,13 @@ async function layoutDiff(diff) {
|
|
|
801
834
|
height: positioned?.height ?? 48
|
|
802
835
|
};
|
|
803
836
|
});
|
|
804
|
-
const positionedEdges =
|
|
837
|
+
const positionedEdges = edges.map((edge) => ({
|
|
805
838
|
...edge,
|
|
806
839
|
sections: normalizeSections(edgeMap.get(edge.id)?.sections)
|
|
807
840
|
}));
|
|
808
|
-
const bounds = measureBounds(
|
|
841
|
+
const bounds = measureBounds(positionedNodes);
|
|
809
842
|
return {
|
|
810
|
-
|
|
811
|
-
nodes,
|
|
843
|
+
nodes: positionedNodes,
|
|
812
844
|
edges: positionedEdges,
|
|
813
845
|
width: bounds.width,
|
|
814
846
|
height: bounds.height
|
|
@@ -833,6 +865,66 @@ function measureBounds(nodes) {
|
|
|
833
865
|
return { width: maxX + 40, height: maxY + 40 };
|
|
834
866
|
}
|
|
835
867
|
|
|
868
|
+
// src/render/section-schemas.ts
|
|
869
|
+
var MAPPING_COLUMNS = [
|
|
870
|
+
{ path: "field", label: "Field" },
|
|
871
|
+
{ path: "value", label: "Value" }
|
|
872
|
+
];
|
|
873
|
+
var FILTER_COLUMNS = [
|
|
874
|
+
{ path: "field", label: "Field" },
|
|
875
|
+
{ path: "operator", label: "Operator" },
|
|
876
|
+
{ path: "value", label: "Value" }
|
|
877
|
+
];
|
|
878
|
+
var PARAM_COLUMNS = [
|
|
879
|
+
{ path: "name", label: "Name" },
|
|
880
|
+
{ path: "value", label: "Value" },
|
|
881
|
+
{ path: "assignToReference", label: "Assign To" }
|
|
882
|
+
];
|
|
883
|
+
var SECTION_SCHEMAS = {
|
|
884
|
+
decision: [{
|
|
885
|
+
name: "Outcomes",
|
|
886
|
+
paths: ["rules"],
|
|
887
|
+
render: "grouped-table",
|
|
888
|
+
groupLabelPath: "label",
|
|
889
|
+
innerArray: "conditions",
|
|
890
|
+
innerColumns: [
|
|
891
|
+
{ path: "leftValueReference", label: "Resource" },
|
|
892
|
+
{ path: "operator", label: "Operator" },
|
|
893
|
+
{ path: "rightValue", label: "Value" }
|
|
894
|
+
]
|
|
895
|
+
}],
|
|
896
|
+
assignment: [{
|
|
897
|
+
name: "Assignment Items",
|
|
898
|
+
paths: ["assignmentItems"],
|
|
899
|
+
render: "table",
|
|
900
|
+
columns: [
|
|
901
|
+
{ path: "assignToReference", label: "Variable" },
|
|
902
|
+
{ path: "operator", label: "Operator" },
|
|
903
|
+
{ path: "value", label: "Value" }
|
|
904
|
+
]
|
|
905
|
+
}],
|
|
906
|
+
recordCreate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
|
|
907
|
+
recordUpdate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
|
|
908
|
+
recordLookup: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
|
|
909
|
+
recordDelete: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
|
|
910
|
+
actionCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
|
|
911
|
+
apexPluginCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
|
|
912
|
+
wait: [{
|
|
913
|
+
name: "Wait Events",
|
|
914
|
+
paths: ["waitEvents"],
|
|
915
|
+
render: "table",
|
|
916
|
+
columns: [
|
|
917
|
+
{ path: "label", label: "Label" },
|
|
918
|
+
{ path: "offset", label: "Offset" },
|
|
919
|
+
{ path: "offsetUnit", label: "Unit" },
|
|
920
|
+
{ path: "resumeDateReference", label: "Resume Date" }
|
|
921
|
+
]
|
|
922
|
+
}]
|
|
923
|
+
};
|
|
924
|
+
function getSectionSchemas(type) {
|
|
925
|
+
return SECTION_SCHEMAS[type] ?? [];
|
|
926
|
+
}
|
|
927
|
+
|
|
836
928
|
// src/render/render-html.ts
|
|
837
929
|
function renderHtml(layout) {
|
|
838
930
|
const data = {
|
|
@@ -840,7 +932,9 @@ function renderHtml(layout) {
|
|
|
840
932
|
nodes: layout.nodes,
|
|
841
933
|
edges: layout.edges,
|
|
842
934
|
width: layout.width,
|
|
843
|
-
height: layout.height
|
|
935
|
+
height: layout.height,
|
|
936
|
+
layouts: layout.views,
|
|
937
|
+
sectionSchemas: Object.fromEntries(layout.diff.nodes.map((node) => [node.type, getSectionSchemas(node.type)]))
|
|
844
938
|
};
|
|
845
939
|
const json = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
846
940
|
const s = layout.diff.summary;
|
|
@@ -865,6 +959,7 @@ function renderHtml(layout) {
|
|
|
865
959
|
--modified: #d97706; --modified-fill: #fef3c7;
|
|
866
960
|
--unchanged: #94a3b8; --unchanged-fill: #e9edf3;
|
|
867
961
|
--edge: #64748b; --fault: #7c3aed;
|
|
962
|
+
--panel-width: 34vw;
|
|
868
963
|
}
|
|
869
964
|
* { box-sizing: border-box; }
|
|
870
965
|
body { margin: 0; height: 100vh; display: grid; grid-template-rows: auto 1fr; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
|
|
@@ -877,13 +972,38 @@ function renderHtml(layout) {
|
|
|
877
972
|
header .meta .deleted:not(.zero) { color: var(--deleted); }
|
|
878
973
|
header .meta .modified:not(.zero) { color: var(--modified); }
|
|
879
974
|
header .meta .sep { color: var(--border); }
|
|
975
|
+
.header-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
|
|
976
|
+
.filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
|
977
|
+
.filters button {
|
|
978
|
+
border: 1px solid var(--border);
|
|
979
|
+
background: #f8fafc;
|
|
980
|
+
color: var(--muted);
|
|
981
|
+
padding: 6px 11px;
|
|
982
|
+
border-radius: 999px;
|
|
983
|
+
font: inherit;
|
|
984
|
+
font-size: 12px;
|
|
985
|
+
font-weight: 600;
|
|
986
|
+
cursor: pointer;
|
|
987
|
+
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
|
988
|
+
}
|
|
989
|
+
.filters button.active {
|
|
990
|
+
background: #eff6ff;
|
|
991
|
+
border-color: #93c5fd;
|
|
992
|
+
color: var(--text);
|
|
993
|
+
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
|
|
994
|
+
}
|
|
995
|
+
.filters button:focus-visible {
|
|
996
|
+
outline: 2px solid #93c5fd;
|
|
997
|
+
outline-offset: 2px;
|
|
998
|
+
}
|
|
880
999
|
.legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
|
|
881
1000
|
.legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
|
|
882
1001
|
.legend .added::before { background: var(--added); }
|
|
883
1002
|
.legend .deleted::before { background: var(--deleted); }
|
|
884
1003
|
.legend .modified::before { background: var(--modified); }
|
|
885
1004
|
.legend .unchanged::before { background: var(--unchanged); }
|
|
886
|
-
main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(
|
|
1005
|
+
main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, var(--panel-width), 70vw); min-height: 0; }
|
|
1006
|
+
body.panel-collapsed main { grid-template-columns: minmax(0, 1fr) 0; }
|
|
887
1007
|
.canvas { position: relative; overflow: hidden; }
|
|
888
1008
|
svg {
|
|
889
1009
|
width: 100%; height: 100%; display: block;
|
|
@@ -891,27 +1011,77 @@ function renderHtml(layout) {
|
|
|
891
1011
|
radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
|
|
892
1012
|
linear-gradient(#ffffff, #f7f9fc);
|
|
893
1013
|
}
|
|
894
|
-
.panel { min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
|
|
1014
|
+
.panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
|
|
1015
|
+
body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
|
|
1016
|
+
body.panel-collapsed .panel > *:not(.panel-resizer) { display: none; }
|
|
1017
|
+
/* drag-to-resize handle on the panel's left edge */
|
|
1018
|
+
.panel-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 9px; transform: translateX(-50%); cursor: col-resize; z-index: 6; touch-action: none; }
|
|
1019
|
+
.panel-resizer::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border); transform: translateX(-50%); transition: background-color 0.12s ease, width 0.12s ease; }
|
|
1020
|
+
.panel-resizer:hover::before, .panel-resizer.dragging::before { background: #93c5fd; width: 3px; }
|
|
1021
|
+
body.panel-collapsed .panel-resizer { display: none; }
|
|
1022
|
+
.panel-head { display: flex; align-items: flex-start; gap: 8px; }
|
|
1023
|
+
.panel-head h2 { flex: 1; min-width: 0; }
|
|
1024
|
+
.panel-toggle, .panel-reopen { border: 1px solid var(--border); background: #f8fafc; color: var(--muted); border-radius: 7px; cursor: pointer; font: inherit; line-height: 1; }
|
|
1025
|
+
.panel-toggle { flex: none; width: 26px; height: 26px; font-size: 16px; display: grid; place-items: center; }
|
|
1026
|
+
.panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: #93c5fd; background: #eff6ff; }
|
|
1027
|
+
.panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
|
|
1028
|
+
/* floating tab to reopen the panel once collapsed */
|
|
1029
|
+
.panel-reopen { display: none; position: absolute; top: 14px; right: 14px; z-index: 7; padding: 7px 12px; font-size: 12px; font-weight: 600; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12); }
|
|
1030
|
+
body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
|
|
895
1031
|
.panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
|
|
896
1032
|
.panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background: #eef1f6; color: var(--muted); margin-bottom: 16px; }
|
|
897
1033
|
.panel.added .badge { background: var(--added-fill); color: var(--added); }
|
|
898
1034
|
.panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
|
|
899
1035
|
.panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
|
|
900
1036
|
.panel .empty { color: var(--muted); font-size: 13px; }
|
|
901
|
-
.
|
|
902
|
-
.
|
|
903
|
-
.
|
|
904
|
-
.
|
|
905
|
-
.
|
|
906
|
-
.change-
|
|
907
|
-
.change-
|
|
908
|
-
.
|
|
909
|
-
.
|
|
910
|
-
|
|
911
|
-
.
|
|
912
|
-
.
|
|
1037
|
+
.detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; overflow: hidden; }
|
|
1038
|
+
.detail-section summary { cursor: pointer; padding: 11px 13px; font-weight: 650; }
|
|
1039
|
+
.detail-section[open] summary { border-bottom: 1px solid var(--border); }
|
|
1040
|
+
.section-count { color: var(--muted); font-size: 11px; font-weight: 500; }
|
|
1041
|
+
.section-body { padding: 12px; overflow-x: auto; }
|
|
1042
|
+
.change-kind { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; }
|
|
1043
|
+
.change-kind.added { color: var(--added); background: var(--added-fill); }
|
|
1044
|
+
.change-kind.modified { color: var(--modified); background: var(--modified-fill); }
|
|
1045
|
+
.change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
|
|
1046
|
+
/* git-diff value grammar */
|
|
1047
|
+
.val { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
|
|
1048
|
+
.val.ins { color: #14532d; background: var(--added-fill); }
|
|
1049
|
+
.val.del { color: #7f1d1d; background: var(--deleted-fill); text-decoration: line-through; text-decoration-color: rgba(127, 29, 29, 0.5); }
|
|
1050
|
+
.val.faint { color: var(--muted); background: transparent; padding-left: 0; }
|
|
1051
|
+
.val em { font-style: normal; color: var(--faint); }
|
|
1052
|
+
.val pre { margin: 4px 0 0; padding: 8px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11.5px; line-height: 1.5; }
|
|
1053
|
+
.arrow { color: var(--faint); margin: 0 2px; }
|
|
1054
|
+
/* scalar / fallback change lines */
|
|
1055
|
+
.changes { list-style: none; padding: 0; margin: 0; }
|
|
1056
|
+
.changes li { min-width: 0; margin-bottom: 9px; }
|
|
1057
|
+
.changes li:last-child { margin-bottom: 0; }
|
|
1058
|
+
.change-line-head { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
|
|
1059
|
+
.change-line-label { font-weight: 600; font-size: 12px; }
|
|
1060
|
+
.change-line-value { padding-left: 2px; }
|
|
1061
|
+
/* item tables (field mappings, conditions, filters, parameters) */
|
|
1062
|
+
.change-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
1063
|
+
.change-table th, .change-table td { padding: 7px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
|
1064
|
+
.change-table th { color: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
1065
|
+
.change-table tr:last-child td { border-bottom: 0; }
|
|
1066
|
+
.change-table .row-idx { color: var(--faint); font-variant-numeric: tabular-nums; width: 1%; white-space: nowrap; }
|
|
1067
|
+
.change-table tr.row-added { background: rgba(22, 163, 74, 0.06); }
|
|
1068
|
+
.change-table tr.row-removed { background: rgba(220, 38, 38, 0.05); }
|
|
1069
|
+
/* outcome groups (decisions) */
|
|
1070
|
+
.outcome-group { margin-bottom: 12px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
|
|
1071
|
+
.outcome-group:last-child { margin-bottom: 0; }
|
|
1072
|
+
.group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
|
|
1073
|
+
.group-label { font-weight: 700; font-size: 12.5px; }
|
|
1074
|
+
.outcome-group .changes { margin-bottom: 8px; }
|
|
1075
|
+
.edge { fill: none; }
|
|
1076
|
+
.edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
|
|
1077
|
+
.edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
|
|
1078
|
+
.edge.fault.unchanged { stroke: var(--fault); stroke-dasharray: 6 4; marker-end: url(#arrow-fault); }
|
|
1079
|
+
.edge.added { stroke: var(--added); stroke-width: 3; marker-end: url(#arrow-added); }
|
|
1080
|
+
.edge.deleted { stroke: var(--deleted); stroke-width: 2; stroke-dasharray: 7 5; marker-end: url(#arrow-deleted); }
|
|
913
1081
|
#arrow-normal path { fill: var(--edge); }
|
|
914
1082
|
#arrow-fault path { fill: var(--fault); }
|
|
1083
|
+
#arrow-added path { fill: var(--added); }
|
|
1084
|
+
#arrow-deleted path { fill: var(--deleted); }
|
|
915
1085
|
.node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.08)); }
|
|
916
1086
|
.node { cursor: pointer; }
|
|
917
1087
|
.node text { font-size: 12px; fill: #111827; pointer-events: none; }
|
|
@@ -921,8 +1091,18 @@ function renderHtml(layout) {
|
|
|
921
1091
|
.node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
|
|
922
1092
|
.node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
|
|
923
1093
|
.node-label { white-space: pre; }
|
|
1094
|
+
/* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
|
|
1095
|
+
modified/unchanged) remains the sole authority on fill/stroke COLOR, so
|
|
1096
|
+
the legend stays reliable. Start/end stand out via a pill shape, heavier
|
|
1097
|
+
stroke, and a neutral emphasis halo \u2014 never via a status-like color. */
|
|
1098
|
+
.node.type-start rect, .node.type-end rect { rx: 24; ry: 24; stroke-width: 2.5; filter: drop-shadow(0 0 0 3px rgba(15, 23, 42, 0.16)) drop-shadow(0 1px 3px rgba(15, 23, 42, 0.18)); }
|
|
1099
|
+
.node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
|
|
1100
|
+
.node.type-screen rect { rx: 6; ry: 6; }
|
|
1101
|
+
.node.type-subflow rect { stroke-width: 2.5; }
|
|
1102
|
+
.node.type-loop rect { stroke-dasharray: 2 3; }
|
|
1103
|
+
.node.type-wait rect { stroke-dasharray: 4 4; }
|
|
1104
|
+
.node.selected.type-start rect, .node.selected.type-end rect { stroke-width: 3.5; filter: drop-shadow(0 0 0 5px rgba(15, 23, 42, 0.22)) drop-shadow(0 2px 6px rgba(15, 23, 42, 0.2)); }
|
|
924
1105
|
@media (max-width: 1000px) {
|
|
925
|
-
main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
|
|
926
1106
|
.panel { padding: 16px; }
|
|
927
1107
|
}
|
|
928
1108
|
</style>
|
|
@@ -933,11 +1113,19 @@ function renderHtml(layout) {
|
|
|
933
1113
|
<h1>${escapeHtml(layout.diff.flowName)}</h1>
|
|
934
1114
|
<div class="meta">${metaHtml}</div>
|
|
935
1115
|
</div>
|
|
936
|
-
<div class="
|
|
937
|
-
<
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1116
|
+
<div class="header-actions">
|
|
1117
|
+
<div class="filters" role="group" aria-label="Diff view filters">
|
|
1118
|
+
<button type="button" class="active" data-view-mode="all">All</button>
|
|
1119
|
+
<button type="button" data-view-mode="after">After</button>
|
|
1120
|
+
<button type="button" data-view-mode="before">Before</button>
|
|
1121
|
+
<button type="button" data-view-mode="changes">Changes only</button>
|
|
1122
|
+
</div>
|
|
1123
|
+
<div class="legend">
|
|
1124
|
+
<span class="added">Added</span>
|
|
1125
|
+
<span class="deleted">Deleted</span>
|
|
1126
|
+
<span class="modified">Modified</span>
|
|
1127
|
+
<span class="unchanged">Unchanged</span>
|
|
1128
|
+
</div>
|
|
941
1129
|
</div>
|
|
942
1130
|
</header>
|
|
943
1131
|
<main>
|
|
@@ -950,6 +1138,12 @@ function renderHtml(layout) {
|
|
|
950
1138
|
<marker id="arrow-fault" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
951
1139
|
<path d="M0,0 L8,4 L0,8 Z"></path>
|
|
952
1140
|
</marker>
|
|
1141
|
+
<marker id="arrow-added" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
1142
|
+
<path d="M0,0 L8,4 L0,8 Z"></path>
|
|
1143
|
+
</marker>
|
|
1144
|
+
<marker id="arrow-deleted" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
|
1145
|
+
<path d="M0,0 L8,4 L0,8 Z"></path>
|
|
1146
|
+
</marker>
|
|
953
1147
|
</defs>
|
|
954
1148
|
<g id="viewport">
|
|
955
1149
|
${layout.edges.map(renderEdge).join("")}
|
|
@@ -958,10 +1152,15 @@ function renderHtml(layout) {
|
|
|
958
1152
|
</svg>
|
|
959
1153
|
</div>
|
|
960
1154
|
<aside class="panel">
|
|
961
|
-
<
|
|
1155
|
+
<div class="panel-resizer" id="panel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize panel"></div>
|
|
1156
|
+
<div class="panel-head">
|
|
1157
|
+
<h2 id="panel-title">Select a node</h2>
|
|
1158
|
+
<button type="button" id="panel-toggle" class="panel-toggle" aria-label="Collapse panel" title="Collapse panel">\u203A</button>
|
|
1159
|
+
</div>
|
|
962
1160
|
<div id="panel-badge" class="badge">No selection</div>
|
|
963
1161
|
<div id="panel-body">Click a node to inspect its properties.</div>
|
|
964
1162
|
</aside>
|
|
1163
|
+
<button type="button" id="panel-reopen" class="panel-reopen" aria-label="Show details panel" title="Show details">\u2039 Details</button>
|
|
965
1164
|
</main>
|
|
966
1165
|
<script>
|
|
967
1166
|
const DATA = ${json};
|
|
@@ -971,9 +1170,13 @@ function renderHtml(layout) {
|
|
|
971
1170
|
const panelTitle = document.getElementById("panel-title");
|
|
972
1171
|
const panelBadge = document.getElementById("panel-badge");
|
|
973
1172
|
const panelBody = document.getElementById("panel-body");
|
|
1173
|
+
const filterButtons = [...document.querySelectorAll(".filters button")];
|
|
974
1174
|
const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
|
|
1175
|
+
const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
|
|
1176
|
+
const edgeElements = new Map([...document.querySelectorAll(".edge")].map((edge) => [edge.dataset.edgeId, edge]));
|
|
975
1177
|
let viewBox = svg.viewBox.baseVal;
|
|
976
1178
|
let dragStart = null;
|
|
1179
|
+
let selectedNodeId = null;
|
|
977
1180
|
|
|
978
1181
|
function escapeHtml(value) {
|
|
979
1182
|
return String(value)
|
|
@@ -983,18 +1186,134 @@ function renderHtml(layout) {
|
|
|
983
1186
|
.replace(/"/g, """);
|
|
984
1187
|
}
|
|
985
1188
|
|
|
986
|
-
function
|
|
1189
|
+
function isNodeVisible(node, mode) {
|
|
1190
|
+
if (mode === "after") return node.status !== "deleted";
|
|
1191
|
+
if (mode === "before") return node.status !== "added";
|
|
1192
|
+
if (mode === "changes") return node.status !== "unchanged";
|
|
1193
|
+
return true;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function isEdgeVisible(edge, mode, visibleNodeIds) {
|
|
1197
|
+
if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
if (mode === "after") return edge.status !== "deleted";
|
|
1201
|
+
if (mode === "before") return edge.status !== "added";
|
|
1202
|
+
if (mode === "changes") {
|
|
1203
|
+
const source = nodesById.get(edge.source);
|
|
1204
|
+
const target = nodesById.get(edge.target);
|
|
1205
|
+
const sourceChanged = source?.status !== "unchanged";
|
|
1206
|
+
const targetChanged = target?.status !== "unchanged";
|
|
1207
|
+
return edge.status !== "unchanged" || (sourceChanged && targetChanged);
|
|
1208
|
+
}
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function edgePath(sections) {
|
|
1213
|
+
const points = sections.flatMap((section, index) => {
|
|
1214
|
+
const start = index === 0 ? [section.startPoint] : [];
|
|
1215
|
+
const bends = section.bendPoints ?? [];
|
|
1216
|
+
const end = [section.endPoint];
|
|
1217
|
+
return [...start, ...bends, ...end];
|
|
1218
|
+
});
|
|
1219
|
+
if (points.length === 0) {
|
|
1220
|
+
return "";
|
|
1221
|
+
}
|
|
1222
|
+
return points.map((point, index) => \`\${index === 0 ? "M" : "L"} \${point.x} \${point.y}\`).join(" ");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function updateToolbar(mode) {
|
|
1226
|
+
filterButtons.forEach((button) => {
|
|
1227
|
+
const active = button.dataset.viewMode === mode;
|
|
1228
|
+
button.classList.toggle("active", active);
|
|
1229
|
+
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function round(value) {
|
|
1234
|
+
return Math.round(value * 100) / 100;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function collectEdgePoints(sections) {
|
|
1238
|
+
return sections.flatMap((section, index) => {
|
|
1239
|
+
const start = index === 0 ? [section.startPoint] : [];
|
|
1240
|
+
const bends = section.bendPoints ?? [];
|
|
1241
|
+
const end = [section.endPoint];
|
|
1242
|
+
return [...start, ...bends, ...end];
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function measureVisibleBounds(nodes, edges) {
|
|
1247
|
+
const points = [
|
|
1248
|
+
...nodes.flatMap((node) => ([
|
|
1249
|
+
{ x: node.x, y: node.y },
|
|
1250
|
+
{ x: node.x + node.width, y: node.y + node.height },
|
|
1251
|
+
])),
|
|
1252
|
+
...edges.flatMap((edge) => collectEdgePoints(edge.sections)),
|
|
1253
|
+
];
|
|
1254
|
+
|
|
1255
|
+
if (points.length === 0) {
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
let minX = points[0].x;
|
|
1260
|
+
let minY = points[0].y;
|
|
1261
|
+
let maxX = points[0].x;
|
|
1262
|
+
let maxY = points[0].y;
|
|
1263
|
+
for (const point of points.slice(1)) {
|
|
1264
|
+
if (point.x < minX) minX = point.x;
|
|
1265
|
+
if (point.y < minY) minY = point.y;
|
|
1266
|
+
if (point.x > maxX) maxX = point.x;
|
|
1267
|
+
if (point.y > maxY) maxY = point.y;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return {
|
|
1271
|
+
x: minX - 20,
|
|
1272
|
+
y: minY - 20,
|
|
1273
|
+
width: (maxX - minX) + 40,
|
|
1274
|
+
height: (maxY - minY) + 40,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function fitViewBoxRect(bounds) {
|
|
1279
|
+
const minWidth = 720;
|
|
1280
|
+
const minHeight = 540;
|
|
1281
|
+
const width = Math.max(bounds.width, minWidth);
|
|
1282
|
+
const height = Math.max(bounds.height, minHeight);
|
|
1283
|
+
const x = bounds.x + (bounds.width - width) / 2;
|
|
1284
|
+
const y = bounds.y + (bounds.height - height) / 2;
|
|
1285
|
+
return {
|
|
1286
|
+
x: round(x),
|
|
1287
|
+
y: round(y),
|
|
1288
|
+
width: round(width),
|
|
1289
|
+
height: round(height),
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function updateViewBox(bounds) {
|
|
1294
|
+
const box = fitViewBoxRect(bounds);
|
|
1295
|
+
viewBox.x = box.x;
|
|
1296
|
+
viewBox.y = box.y;
|
|
1297
|
+
viewBox.width = box.width;
|
|
1298
|
+
viewBox.height = box.height;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function clearSelection(message) {
|
|
1302
|
+
selectedNodeId = null;
|
|
987
1303
|
document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1304
|
+
panel.className = "panel";
|
|
1305
|
+
panelTitle.textContent = "No visible nodes";
|
|
1306
|
+
panelBadge.textContent = "Hidden";
|
|
1307
|
+
panelBody.innerHTML = "<div class='empty'>" + escapeHtml(message) + "</div>";
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function updatePanel(node) {
|
|
992
1311
|
panel.className = "panel " + node.status;
|
|
993
1312
|
panelTitle.textContent = node.label;
|
|
994
1313
|
panelBadge.textContent = node.status.toUpperCase();
|
|
995
1314
|
const changes = node.changes || [];
|
|
996
1315
|
panelBody.innerHTML = changes.length
|
|
997
|
-
?
|
|
1316
|
+
? organizeChanges(node).map((section) => formatSection(section, node)).join("")
|
|
998
1317
|
: node.status === "added"
|
|
999
1318
|
? "<div class='empty'>This node was added in the new version.</div>"
|
|
1000
1319
|
: node.status === "deleted"
|
|
@@ -1002,22 +1321,235 @@ function renderHtml(layout) {
|
|
|
1002
1321
|
: "<div class='empty'>No property changes.</div>";
|
|
1003
1322
|
}
|
|
1004
1323
|
|
|
1005
|
-
function
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
return
|
|
1015
|
-
|
|
1016
|
-
|
|
1324
|
+
function owns(prefix, path) {
|
|
1325
|
+
return path === prefix || path.startsWith(prefix + ".") || path.startsWith(prefix + "[");
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function changeKind(change) {
|
|
1329
|
+
return change.before === undefined ? "added" : change.after === undefined ? "removed" : "modified";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function capitalize(value) {
|
|
1333
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Parse "base[idx]rest" \u2014 returns the item index and trailing path, or null.
|
|
1337
|
+
function matchItem(path, base) {
|
|
1338
|
+
if (!path.startsWith(base + "[")) return null;
|
|
1339
|
+
const close = path.indexOf("]", base.length + 1);
|
|
1340
|
+
if (close === -1) return null;
|
|
1341
|
+
const idx = path.slice(base.length + 1, close);
|
|
1342
|
+
if (!/^\\d+$/.test(idx)) return null;
|
|
1343
|
+
return { idx, rest: path.slice(close + 1) };
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Group a node's flat change list into ordered sections: schema-driven first,
|
|
1347
|
+
// then a generic fallback (array-prefixed groups, then a Configuration bucket).
|
|
1348
|
+
function organizeChanges(node) {
|
|
1349
|
+
const schemas = DATA.sectionSchemas[node.type] || [];
|
|
1350
|
+
const sections = schemas.map((schema) => ({ schema, changes: [] }));
|
|
1351
|
+
const fallback = new Map();
|
|
1352
|
+
for (const change of node.changes || []) {
|
|
1353
|
+
const sec = sections.find((s) => s.schema.paths.some((path) => owns(path, change.path)));
|
|
1354
|
+
if (sec) { sec.changes.push(change); continue; }
|
|
1355
|
+
const match = change.path.match(/^([^.[]+)(\\[\\d+\\])?/);
|
|
1356
|
+
const isArray = !!(match && match[2] !== undefined);
|
|
1357
|
+
const key = isArray ? match[1] : "Configuration";
|
|
1358
|
+
if (!fallback.has(key)) fallback.set(key, []);
|
|
1359
|
+
fallback.get(key).push(change);
|
|
1360
|
+
}
|
|
1361
|
+
const out = [];
|
|
1362
|
+
for (const s of sections) {
|
|
1363
|
+
if (s.changes.length) out.push({ name: s.schema.name, schema: s.schema, changes: s.changes });
|
|
1364
|
+
}
|
|
1365
|
+
for (const [key, changes] of fallback) {
|
|
1366
|
+
out.push({
|
|
1367
|
+
name: key === "Configuration" ? "Configuration" : humanizePath(key),
|
|
1368
|
+
schema: { render: "lines", paths: [key] },
|
|
1369
|
+
changes,
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
return out;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function formatSection(section, node) {
|
|
1376
|
+
const body = renderSectionBody(section, node);
|
|
1377
|
+
return "<details class='detail-section' open><summary>" + escapeHtml(section.name)
|
|
1378
|
+
+ " <span class='section-count'>(" + section.changes.length + ")</span></summary>"
|
|
1379
|
+
+ "<div class='section-body'>" + body + "</div></details>";
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function renderSectionBody(section, node) {
|
|
1383
|
+
const render = section.schema.render;
|
|
1384
|
+
if (render === "grouped-table") return renderGroupedTable(section, node);
|
|
1385
|
+
if (render === "table" && section.schema.columns) {
|
|
1386
|
+
return renderTable(section.changes, section.schema.paths, section.schema.columns, { before: node.before, after: node.after, prefix: "" });
|
|
1387
|
+
}
|
|
1388
|
+
return renderLines(section.changes, {});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Labeled before \u2192 after lines, for scalar changes and the generic fallback.
|
|
1392
|
+
// The plain option drops per-line badges \u2014 used under a wholly added/removed
|
|
1393
|
+
// outcome, where the single group badge already states the change (Finding 3).
|
|
1394
|
+
function renderLines(changes, options) {
|
|
1395
|
+
const plain = options && options.plain;
|
|
1396
|
+
return "<ul class='changes'>" + changes.map((change) => {
|
|
1397
|
+
const kind = changeKind(change);
|
|
1398
|
+
const badge = plain ? "" : "<span class='change-kind " + kind + "'>" + capitalize(kind) + "</span>";
|
|
1399
|
+
return "<li><div class='change-line-head'>" + badge
|
|
1400
|
+
+ "<span class='change-line-label'>" + escapeHtml(humanizePath(change.path)) + "</span></div>"
|
|
1401
|
+
+ "<div class='change-line-value'>" + diffValue(change.before, change.after) + "</div></li>";
|
|
1402
|
+
}).join("") + "</ul>";
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Walk a path like "rules[0].conditions[2]" into an object.
|
|
1406
|
+
function resolvePath(obj, path) {
|
|
1407
|
+
if (obj == null || !path) return undefined;
|
|
1408
|
+
let cur = obj;
|
|
1409
|
+
const tokens = path.match(/[^.[\\]]+|\\[\\d+\\]/g) || [];
|
|
1410
|
+
for (const token of tokens) {
|
|
1411
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
1412
|
+
cur = token[0] === "[" ? cur[Number(token.slice(1, -1))] : cur[token];
|
|
1413
|
+
}
|
|
1414
|
+
return cur;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Collapse arr[i].* leaf changes (and whole-item changes) into one row per
|
|
1418
|
+
// item index, then fill any unchanged columns from the item snapshot so a
|
|
1419
|
+
// modified row still shows its sibling context (Resource/Operator/etc.).
|
|
1420
|
+
function buildRows(changes, basePaths, columns, ctx) {
|
|
1421
|
+
const rows = new Map();
|
|
1422
|
+
let order = 0;
|
|
1423
|
+
for (const change of changes) {
|
|
1424
|
+
let base = null, idx = null, rest = "";
|
|
1425
|
+
for (const p of basePaths) {
|
|
1426
|
+
const m = matchItem(change.path, p);
|
|
1427
|
+
if (m) { base = p; idx = m.idx; rest = m.rest; break; }
|
|
1428
|
+
}
|
|
1429
|
+
if (base === null) continue;
|
|
1430
|
+
const itemKey = base + "[" + idx + "]";
|
|
1431
|
+
if (!rows.has(itemKey)) rows.set(itemKey, { kind: "modified", order: order++, idx, base, cells: {} });
|
|
1432
|
+
const row = rows.get(itemKey);
|
|
1433
|
+
if (rest === "") {
|
|
1434
|
+
row.kind = changeKind(change);
|
|
1435
|
+
for (const col of columns) {
|
|
1436
|
+
const before = extractField(change.before, col.path);
|
|
1437
|
+
const after = extractField(change.after, col.path);
|
|
1438
|
+
row.cells[col.path] = { before, after, changed: !valuesEqual(before, after) };
|
|
1439
|
+
}
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
const sub = rest.replace(/^\\./, "");
|
|
1443
|
+
const col = columns.find((c) => sub === c.path || sub.startsWith(c.path + ".") || sub.startsWith(c.path + "["));
|
|
1444
|
+
if (col) row.cells[col.path] = { before: unwrapValue(change.before), after: unwrapValue(change.after), changed: true };
|
|
1445
|
+
}
|
|
1446
|
+
const result = [...rows.values()].sort((a, b) => a.order - b.order);
|
|
1447
|
+
if (ctx) {
|
|
1448
|
+
for (const row of result) {
|
|
1449
|
+
const abs = ctx.prefix ? ctx.prefix + "." + row.base + "[" + row.idx + "]" : row.base + "[" + row.idx + "]";
|
|
1450
|
+
const snapshot = row.kind === "removed" ? resolvePath(ctx.before, abs) : resolvePath(ctx.after, abs);
|
|
1451
|
+
if (!snapshot || typeof snapshot !== "object") continue;
|
|
1452
|
+
for (const col of columns) {
|
|
1453
|
+
if (row.cells[col.path]) continue;
|
|
1454
|
+
const value = extractField(snapshot, col.path);
|
|
1455
|
+
if (value !== undefined) row.cells[col.path] = { before: value, after: value, changed: false };
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return result;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// options.uniformKind (a wholly added/removed group): drop the Change
|
|
1463
|
+
// column and per-row badges, colouring every cell by that single kind.
|
|
1464
|
+
function renderTable(changes, basePaths, columns, ctx, options) {
|
|
1465
|
+
const uniform = options && options.uniformKind;
|
|
1466
|
+
const rows = buildRows(changes, basePaths, columns, ctx);
|
|
1467
|
+
if (!rows.length) return renderLines(changes, {});
|
|
1468
|
+
const head = "<tr><th class='row-idx'>#</th>" + columns.map((c) => "<th>" + escapeHtml(c.label) + "</th>").join("")
|
|
1469
|
+
+ (uniform ? "" : "<th>Change</th>") + "</tr>";
|
|
1470
|
+
const body = rows.map((row) => renderRow(row, columns, uniform)).join("");
|
|
1471
|
+
return "<table class='change-table'><thead>" + head + "</thead><tbody>" + body + "</tbody></table>";
|
|
1017
1472
|
}
|
|
1018
1473
|
|
|
1019
|
-
function
|
|
1020
|
-
|
|
1474
|
+
function renderRow(row, columns, uniform) {
|
|
1475
|
+
const kind = uniform || row.kind;
|
|
1476
|
+
const cells = columns.map((col) => {
|
|
1477
|
+
const cell = row.cells[col.path];
|
|
1478
|
+
if (!cell) return "<td></td>";
|
|
1479
|
+
if (kind === "added") return "<td>" + insVal(cell.after !== undefined ? cell.after : cell.before) + "</td>";
|
|
1480
|
+
if (kind === "removed") return "<td>" + delVal(cell.before !== undefined ? cell.before : cell.after) + "</td>";
|
|
1481
|
+
return "<td>" + (cell.changed ? diffValue(cell.before, cell.after) : faintVal(cell.after)) + "</td>";
|
|
1482
|
+
}).join("");
|
|
1483
|
+
const badge = uniform ? "" : "<td><span class='change-kind " + row.kind + "'>" + capitalize(row.kind) + "</span></td>";
|
|
1484
|
+
return "<tr class='row-" + kind + "'><td class='row-idx'>" + (Number(row.idx) + 1) + "</td>" + cells + badge + "</tr>";
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// Decisions et al.: group by the outer array (one group per outcome/rule).
|
|
1488
|
+
// A wholly added/removed outcome shows ONE group badge + plain context; a
|
|
1489
|
+
// modified outcome shows its changed conditions in a per-row-badged table.
|
|
1490
|
+
function renderGroupedTable(section, node) {
|
|
1491
|
+
const schema = section.schema;
|
|
1492
|
+
const groups = new Map();
|
|
1493
|
+
let order = 0;
|
|
1494
|
+
for (const change of section.changes) {
|
|
1495
|
+
let matched = null;
|
|
1496
|
+
for (const p of schema.paths) {
|
|
1497
|
+
const m = matchItem(change.path, p);
|
|
1498
|
+
if (m) { matched = { base: p, idx: m.idx, rest: m.rest }; break; }
|
|
1499
|
+
}
|
|
1500
|
+
if (!matched) continue;
|
|
1501
|
+
const key = matched.idx;
|
|
1502
|
+
if (!groups.has(key)) groups.set(key, { idx: matched.idx, base: matched.base, order: order++, label: null, kind: "modified", whole: false, inner: [], scalars: [] });
|
|
1503
|
+
const g = groups.get(key);
|
|
1504
|
+
if (matched.rest === "") {
|
|
1505
|
+
g.kind = changeKind(change);
|
|
1506
|
+
g.whole = true;
|
|
1507
|
+
const obj = change.after !== undefined ? change.after : change.before;
|
|
1508
|
+
if (obj && typeof obj === "object") {
|
|
1509
|
+
if (schema.groupLabelPath && obj[schema.groupLabelPath]) g.label = obj[schema.groupLabelPath];
|
|
1510
|
+
const inner = obj[schema.innerArray];
|
|
1511
|
+
if (Array.isArray(inner)) {
|
|
1512
|
+
inner.forEach((item, j) => g.inner.push({
|
|
1513
|
+
path: schema.innerArray + "[" + j + "]",
|
|
1514
|
+
before: g.kind === "removed" ? item : undefined,
|
|
1515
|
+
after: g.kind === "removed" ? undefined : item,
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
for (const k of Object.keys(obj)) {
|
|
1519
|
+
if (k === schema.innerArray || k === schema.groupLabelPath) continue;
|
|
1520
|
+
g.scalars.push({ path: k, before: g.kind === "removed" ? obj[k] : undefined, after: g.kind === "removed" ? undefined : obj[k] });
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
const sub = matched.rest.replace(/^\\./, "");
|
|
1526
|
+
if (sub === schema.groupLabelPath) g.label = change.after !== undefined ? change.after : change.before;
|
|
1527
|
+
if (sub === schema.innerArray || sub.startsWith(schema.innerArray + "[")) {
|
|
1528
|
+
g.inner.push({ path: sub, before: change.before, after: change.after });
|
|
1529
|
+
} else {
|
|
1530
|
+
g.scalars.push({ path: sub, before: change.before, after: change.after });
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return [...groups.values()].sort((a, b) => a.order - b.order).map((g) => {
|
|
1534
|
+
const label = g.label != null ? String(g.label) : "Outcome " + (Number(g.idx) + 1);
|
|
1535
|
+
const head = "<div class='group-head'><span class='change-kind " + g.kind + "'>" + capitalize(g.kind) + "</span>"
|
|
1536
|
+
+ "<span class='group-label'>" + escapeHtml(label) + "</span></div>";
|
|
1537
|
+
const prefix = g.base + "[" + g.idx + "]";
|
|
1538
|
+
const scalars = g.scalars.length ? renderLines(g.scalars, { plain: g.whole }) : "";
|
|
1539
|
+
const inner = g.inner.length
|
|
1540
|
+
? renderTable(g.inner, [schema.innerArray], schema.innerColumns, { before: node.before, after: node.after, prefix }, g.whole ? { uniformKind: g.kind } : {})
|
|
1541
|
+
: "";
|
|
1542
|
+
return "<div class='outcome-group'>" + head + scalars + inner + "</div>";
|
|
1543
|
+
}).join("");
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function selectNode(id) {
|
|
1547
|
+
selectedNodeId = id;
|
|
1548
|
+
document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
|
|
1549
|
+
const selected = document.querySelector(\`.node[data-node-id="\${CSS.escape(id)}"]\`);
|
|
1550
|
+
if (selected) selected.classList.add("selected");
|
|
1551
|
+
const node = nodesById.get(id);
|
|
1552
|
+
if (node) updatePanel(node);
|
|
1021
1553
|
}
|
|
1022
1554
|
|
|
1023
1555
|
function humanizePath(path) {
|
|
@@ -1034,11 +1566,125 @@ function renderHtml(layout) {
|
|
|
1034
1566
|
}).join(" \u203A ");
|
|
1035
1567
|
}
|
|
1036
1568
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1569
|
+
// Collapse Salesforce typed-value wrappers to their scalar so reviewers see
|
|
1570
|
+
// "true" / "Something Else" / "{!myVar}" instead of a JSON blob.
|
|
1571
|
+
function unwrapValue(value) {
|
|
1572
|
+
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) return value;
|
|
1573
|
+
if ("elementReference" in value) return "{!" + value.elementReference + "}";
|
|
1574
|
+
const keys = Object.keys(value);
|
|
1575
|
+
if (keys.length === 1) {
|
|
1576
|
+
const key = keys[0];
|
|
1577
|
+
if (key === "stringValue" || key === "numberValue" || key === "dateValue" || key === "dateTimeValue") return value[key];
|
|
1578
|
+
if (key === "booleanValue") return String(value[key]) === "true" ? "true" : "false";
|
|
1579
|
+
}
|
|
1580
|
+
return value;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function extractField(obj, path) {
|
|
1584
|
+
if (obj === undefined || obj === null) return undefined;
|
|
1585
|
+
let cur = obj;
|
|
1586
|
+
for (const segment of path.split(".")) {
|
|
1587
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
1588
|
+
cur = cur[segment];
|
|
1589
|
+
}
|
|
1590
|
+
return unwrapValue(cur);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function valuesEqual(a, b) {
|
|
1594
|
+
if (a === b) return true;
|
|
1595
|
+
if (a && b && typeof a === "object" && typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
1596
|
+
return false;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function renderScalar(value) {
|
|
1600
|
+
if (value === undefined) return "<em>\u2014</em>";
|
|
1601
|
+
if (value === null) return "<em>empty</em>";
|
|
1040
1602
|
if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
|
|
1041
|
-
return
|
|
1603
|
+
return escapeHtml(String(value));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function insVal(value) { return "<span class='val ins'>" + renderScalar(value) + "</span>"; }
|
|
1607
|
+
function delVal(value) { return "<span class='val del'>" + renderScalar(value) + "</span>"; }
|
|
1608
|
+
function faintVal(value) { return "<span class='val faint'>" + renderScalar(value) + "</span>"; }
|
|
1609
|
+
|
|
1610
|
+
// git-diff value grammar: removed (red strike) \u2192 added (green).
|
|
1611
|
+
function diffValue(before, after) {
|
|
1612
|
+
before = unwrapValue(before);
|
|
1613
|
+
after = unwrapValue(after);
|
|
1614
|
+
if (before === undefined) return insVal(after);
|
|
1615
|
+
if (after === undefined) return delVal(before);
|
|
1616
|
+
return delVal(before) + " <span class='arrow'>\u2192</span> " + insVal(after);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function pickInitialNode(visibleNodeIds) {
|
|
1620
|
+
return DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status === "modified")
|
|
1621
|
+
|| DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status !== "unchanged")
|
|
1622
|
+
|| DATA.nodes.find((node) => visibleNodeIds.has(node.id))
|
|
1623
|
+
|| null;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function applyView(mode) {
|
|
1627
|
+
const layout = mode === "all" || mode === "changes"
|
|
1628
|
+
? DATA.layouts.union
|
|
1629
|
+
: DATA.layouts[mode];
|
|
1630
|
+
const nodeLayout = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
1631
|
+
const edgeLayout = new Map(layout.edges.map((edge) => [edge.id, edge]));
|
|
1632
|
+
const visibleNodeIds = new Set();
|
|
1633
|
+
const visibleNodes = [];
|
|
1634
|
+
const visibleEdges = [];
|
|
1635
|
+
|
|
1636
|
+
DATA.nodes.forEach((node) => {
|
|
1637
|
+
const element = nodeElements.get(node.id);
|
|
1638
|
+
if (!element) return;
|
|
1639
|
+
if (!isNodeVisible(node, mode)) {
|
|
1640
|
+
element.style.display = "none";
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const positioned = nodeLayout.get(node.id);
|
|
1644
|
+
if (!positioned) {
|
|
1645
|
+
element.style.display = "none";
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
element.style.display = "";
|
|
1649
|
+
element.setAttribute("transform", "translate(" + positioned.x + "," + positioned.y + ")");
|
|
1650
|
+
visibleNodeIds.add(node.id);
|
|
1651
|
+
visibleNodes.push(positioned);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
DATA.edges.forEach((edge) => {
|
|
1655
|
+
const element = edgeElements.get(edge.id);
|
|
1656
|
+
if (!element) return;
|
|
1657
|
+
if (!isEdgeVisible(edge, mode, visibleNodeIds)) {
|
|
1658
|
+
element.style.display = "none";
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const positioned = edgeLayout.get(edge.id);
|
|
1662
|
+
if (!positioned) {
|
|
1663
|
+
element.style.display = "none";
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
element.style.display = "";
|
|
1667
|
+
element.setAttribute("d", edgePath(positioned.sections));
|
|
1668
|
+
visibleEdges.push(positioned);
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
updateToolbar(mode);
|
|
1672
|
+
updateViewBox(measureVisibleBounds(visibleNodes, visibleEdges) || { x: 0, y: 0, width: layout.width, height: layout.height });
|
|
1673
|
+
|
|
1674
|
+
if (selectedNodeId && visibleNodeIds.has(selectedNodeId)) {
|
|
1675
|
+
const node = nodesById.get(selectedNodeId);
|
|
1676
|
+
if (node) {
|
|
1677
|
+
updatePanel(node);
|
|
1678
|
+
}
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const initialNode = pickInitialNode(visibleNodeIds);
|
|
1683
|
+
if (initialNode) {
|
|
1684
|
+
selectNode(initialNode.id);
|
|
1685
|
+
} else {
|
|
1686
|
+
clearSelection("This view has no visible nodes.");
|
|
1687
|
+
}
|
|
1042
1688
|
}
|
|
1043
1689
|
|
|
1044
1690
|
document.querySelectorAll(".node").forEach((node) => {
|
|
@@ -1076,10 +1722,46 @@ function renderHtml(layout) {
|
|
|
1076
1722
|
svg.addEventListener("pointerup", () => { dragStart = null; });
|
|
1077
1723
|
svg.addEventListener("pointercancel", () => { dragStart = null; });
|
|
1078
1724
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1725
|
+
filterButtons.forEach((button) => {
|
|
1726
|
+
button.addEventListener("click", () => {
|
|
1727
|
+
applyView(button.dataset.viewMode);
|
|
1728
|
+
});
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
// Panel: collapse to focus on the canvas, and drag the left edge to resize.
|
|
1732
|
+
const panelToggle = document.getElementById("panel-toggle");
|
|
1733
|
+
const panelReopen = document.getElementById("panel-reopen");
|
|
1734
|
+
const panelResizer = document.getElementById("panel-resizer");
|
|
1735
|
+
|
|
1736
|
+
function setPanelCollapsed(collapsed) {
|
|
1737
|
+
document.body.classList.toggle("panel-collapsed", collapsed);
|
|
1738
|
+
if (panelToggle) panelToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
1739
|
+
}
|
|
1740
|
+
if (panelToggle) panelToggle.addEventListener("click", () => setPanelCollapsed(true));
|
|
1741
|
+
if (panelReopen) panelReopen.addEventListener("click", () => setPanelCollapsed(false));
|
|
1742
|
+
|
|
1743
|
+
if (panelResizer) {
|
|
1744
|
+
let resizeStart = null;
|
|
1745
|
+
panelResizer.addEventListener("pointerdown", (event) => {
|
|
1746
|
+
resizeStart = { x: event.clientX, width: panel.getBoundingClientRect().width };
|
|
1747
|
+
panelResizer.classList.add("dragging");
|
|
1748
|
+
panelResizer.setPointerCapture(event.pointerId);
|
|
1749
|
+
event.preventDefault();
|
|
1750
|
+
});
|
|
1751
|
+
panelResizer.addEventListener("pointermove", (event) => {
|
|
1752
|
+
if (!resizeStart) return;
|
|
1753
|
+
// The handle is on the panel's left edge, so dragging left widens it.
|
|
1754
|
+
const next = resizeStart.width - (event.clientX - resizeStart.x);
|
|
1755
|
+
const max = Math.max(320, window.innerWidth - 360);
|
|
1756
|
+
const clamped = Math.max(300, Math.min(max, next));
|
|
1757
|
+
document.documentElement.style.setProperty("--panel-width", clamped + "px");
|
|
1758
|
+
});
|
|
1759
|
+
const endResize = () => { resizeStart = null; panelResizer.classList.remove("dragging"); };
|
|
1760
|
+
panelResizer.addEventListener("pointerup", endResize);
|
|
1761
|
+
panelResizer.addEventListener("pointercancel", endResize);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
applyView("all");
|
|
1083
1765
|
</script>
|
|
1084
1766
|
</body>
|
|
1085
1767
|
</html>`;
|
|
@@ -1107,13 +1789,13 @@ function renderEdge(edge) {
|
|
|
1107
1789
|
return "";
|
|
1108
1790
|
}
|
|
1109
1791
|
const d = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
|
1110
|
-
return `<path class="edge ${edge.kind}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
|
|
1792
|
+
return `<path class="edge ${edge.kind} ${edge.status}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
|
|
1111
1793
|
}
|
|
1112
1794
|
function renderNode(node) {
|
|
1113
1795
|
const lines = wrapLabel(node.label, 22);
|
|
1114
1796
|
const lineHeight = 14;
|
|
1115
1797
|
const textY = node.height / 2 - (lines.length - 1) * lineHeight / 2 + 5;
|
|
1116
|
-
return `<g class="node ${node.status}" data-node-id="${escapeHtml(node.id)}" transform="translate(${node.x},${node.y})">
|
|
1798
|
+
return `<g class="node ${node.status} type-${node.type}" data-node-id="${escapeHtml(node.id)}" transform="translate(${node.x},${node.y})">
|
|
1117
1799
|
<rect width="${node.width}" height="${node.height}"></rect>
|
|
1118
1800
|
<text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
|
|
1119
1801
|
${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join("")}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syntax-syllogism/flow-delta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Semantic visual diff for Salesforce Flows",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,19 +27,19 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "node scripts/build.mjs",
|
|
29
29
|
"smoke:gitlab": "node --import tsx scripts/smoke-gitlab.ts",
|
|
30
|
-
"test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts",
|
|
30
|
+
"test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts test/smoke-gitlab.test.ts",
|
|
31
31
|
"test:parser": "node --import tsx --test test/parser.test.ts",
|
|
32
32
|
"prepublishOnly": "npm run build && npm test",
|
|
33
33
|
"render:fixtures": "bash bin/render-fixtures.sh"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"esbuild": "^0.28.0",
|
|
37
|
-
"@types/node": "^
|
|
37
|
+
"@types/node": "^25.9.2",
|
|
38
38
|
"@types/xml2js": "^0.4.14",
|
|
39
39
|
"tsx": "^4.19.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"elkjs": "^0.
|
|
42
|
+
"elkjs": "^0.11.1",
|
|
43
43
|
"xml2js": "^0.6.2"
|
|
44
44
|
},
|
|
45
45
|
"bin": {
|