@syntax-syllogism/flow-delta 0.1.2 → 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 +444 -39
- package/package.json +1 -1
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) {
|
|
@@ -863,6 +865,66 @@ function measureBounds(nodes) {
|
|
|
863
865
|
return { width: maxX + 40, height: maxY + 40 };
|
|
864
866
|
}
|
|
865
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
|
+
|
|
866
928
|
// src/render/render-html.ts
|
|
867
929
|
function renderHtml(layout) {
|
|
868
930
|
const data = {
|
|
@@ -871,7 +933,8 @@ function renderHtml(layout) {
|
|
|
871
933
|
edges: layout.edges,
|
|
872
934
|
width: layout.width,
|
|
873
935
|
height: layout.height,
|
|
874
|
-
layouts: layout.views
|
|
936
|
+
layouts: layout.views,
|
|
937
|
+
sectionSchemas: Object.fromEntries(layout.diff.nodes.map((node) => [node.type, getSectionSchemas(node.type)]))
|
|
875
938
|
};
|
|
876
939
|
const json = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
877
940
|
const s = layout.diff.summary;
|
|
@@ -896,6 +959,7 @@ function renderHtml(layout) {
|
|
|
896
959
|
--modified: #d97706; --modified-fill: #fef3c7;
|
|
897
960
|
--unchanged: #94a3b8; --unchanged-fill: #e9edf3;
|
|
898
961
|
--edge: #64748b; --fault: #7c3aed;
|
|
962
|
+
--panel-width: 34vw;
|
|
899
963
|
}
|
|
900
964
|
* { box-sizing: border-box; }
|
|
901
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; }
|
|
@@ -938,7 +1002,8 @@ function renderHtml(layout) {
|
|
|
938
1002
|
.legend .deleted::before { background: var(--deleted); }
|
|
939
1003
|
.legend .modified::before { background: var(--modified); }
|
|
940
1004
|
.legend .unchanged::before { background: var(--unchanged); }
|
|
941
|
-
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; }
|
|
942
1007
|
.canvas { position: relative; overflow: hidden; }
|
|
943
1008
|
svg {
|
|
944
1009
|
width: 100%; height: 100%; display: block;
|
|
@@ -946,22 +1011,67 @@ function renderHtml(layout) {
|
|
|
946
1011
|
radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
|
|
947
1012
|
linear-gradient(#ffffff, #f7f9fc);
|
|
948
1013
|
}
|
|
949
|
-
.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; }
|
|
950
1031
|
.panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
|
|
951
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; }
|
|
952
1033
|
.panel.added .badge { background: var(--added-fill); color: var(--added); }
|
|
953
1034
|
.panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
|
|
954
1035
|
.panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
|
|
955
1036
|
.panel .empty { color: var(--muted); font-size: 13px; }
|
|
956
|
-
.
|
|
957
|
-
.
|
|
958
|
-
.
|
|
959
|
-
.
|
|
960
|
-
.
|
|
961
|
-
.change-
|
|
962
|
-
.change-
|
|
963
|
-
.
|
|
964
|
-
.
|
|
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; }
|
|
965
1075
|
.edge { fill: none; }
|
|
966
1076
|
.edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
|
|
967
1077
|
.edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
|
|
@@ -981,8 +1091,18 @@ function renderHtml(layout) {
|
|
|
981
1091
|
.node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
|
|
982
1092
|
.node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
|
|
983
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)); }
|
|
984
1105
|
@media (max-width: 1000px) {
|
|
985
|
-
main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
|
|
986
1106
|
.panel { padding: 16px; }
|
|
987
1107
|
}
|
|
988
1108
|
</style>
|
|
@@ -1032,10 +1152,15 @@ function renderHtml(layout) {
|
|
|
1032
1152
|
</svg>
|
|
1033
1153
|
</div>
|
|
1034
1154
|
<aside class="panel">
|
|
1035
|
-
<
|
|
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>
|
|
1036
1160
|
<div id="panel-badge" class="badge">No selection</div>
|
|
1037
1161
|
<div id="panel-body">Click a node to inspect its properties.</div>
|
|
1038
1162
|
</aside>
|
|
1163
|
+
<button type="button" id="panel-reopen" class="panel-reopen" aria-label="Show details panel" title="Show details">\u2039 Details</button>
|
|
1039
1164
|
</main>
|
|
1040
1165
|
<script>
|
|
1041
1166
|
const DATA = ${json};
|
|
@@ -1188,7 +1313,7 @@ function renderHtml(layout) {
|
|
|
1188
1313
|
panelBadge.textContent = node.status.toUpperCase();
|
|
1189
1314
|
const changes = node.changes || [];
|
|
1190
1315
|
panelBody.innerHTML = changes.length
|
|
1191
|
-
?
|
|
1316
|
+
? organizeChanges(node).map((section) => formatSection(section, node)).join("")
|
|
1192
1317
|
: node.status === "added"
|
|
1193
1318
|
? "<div class='empty'>This node was added in the new version.</div>"
|
|
1194
1319
|
: node.status === "deleted"
|
|
@@ -1196,6 +1321,228 @@ function renderHtml(layout) {
|
|
|
1196
1321
|
: "<div class='empty'>No property changes.</div>";
|
|
1197
1322
|
}
|
|
1198
1323
|
|
|
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>";
|
|
1472
|
+
}
|
|
1473
|
+
|
|
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
|
+
|
|
1199
1546
|
function selectNode(id) {
|
|
1200
1547
|
selectedNodeId = id;
|
|
1201
1548
|
document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
|
|
@@ -1205,24 +1552,6 @@ function renderHtml(layout) {
|
|
|
1205
1552
|
if (node) updatePanel(node);
|
|
1206
1553
|
}
|
|
1207
1554
|
|
|
1208
|
-
function formatChange(change) {
|
|
1209
|
-
const beforeMissing = change.before === undefined;
|
|
1210
|
-
const afterMissing = change.after === undefined;
|
|
1211
|
-
const action = beforeMissing ? "Added" : afterMissing ? "Removed" : "Changed";
|
|
1212
|
-
const values = beforeMissing
|
|
1213
|
-
? formatLabeledValue("New value", change.after)
|
|
1214
|
-
: afterMissing
|
|
1215
|
-
? formatLabeledValue("Previous value", change.before)
|
|
1216
|
-
: formatLabeledValue("Before", change.before) + formatLabeledValue("After", change.after);
|
|
1217
|
-
return "<li><div class='change-title'>" + action + ": " + escapeHtml(humanizePath(change.path)) + "</div>"
|
|
1218
|
-
+ "<div class='change-path'>Metadata path: <code>" + escapeHtml(change.path) + "</code></div>"
|
|
1219
|
-
+ "<div class='change-values'>" + values + "</div></li>";
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
function formatLabeledValue(label, value) {
|
|
1223
|
-
return "<div class='change-value'><div class='change-value-label'>" + label + "</div>" + formatValue(value) + "</div>";
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
1555
|
function humanizePath(path) {
|
|
1227
1556
|
const names = {
|
|
1228
1557
|
assignmentItems: "Assignment item",
|
|
@@ -1237,11 +1566,54 @@ function renderHtml(layout) {
|
|
|
1237
1566
|
}).join(" \u203A ");
|
|
1238
1567
|
}
|
|
1239
1568
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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>";
|
|
1243
1602
|
if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
|
|
1244
|
-
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);
|
|
1245
1617
|
}
|
|
1246
1618
|
|
|
1247
1619
|
function pickInitialNode(visibleNodeIds) {
|
|
@@ -1356,6 +1728,39 @@ function renderHtml(layout) {
|
|
|
1356
1728
|
});
|
|
1357
1729
|
});
|
|
1358
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
|
+
|
|
1359
1764
|
applyView("all");
|
|
1360
1765
|
</script>
|
|
1361
1766
|
</body>
|
|
@@ -1390,7 +1795,7 @@ function renderNode(node) {
|
|
|
1390
1795
|
const lines = wrapLabel(node.label, 22);
|
|
1391
1796
|
const lineHeight = 14;
|
|
1392
1797
|
const textY = node.height / 2 - (lines.length - 1) * lineHeight / 2 + 5;
|
|
1393
|
-
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})">
|
|
1394
1799
|
<rect width="${node.width}" height="${node.height}"></rect>
|
|
1395
1800
|
<text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
|
|
1396
1801
|
${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join("")}
|