@transitrix/diagrams 1.6.0 → 1.8.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/dist/activities/layout.js +7 -7
- package/dist/activities/types.d.ts +4 -0
- package/dist/capability-map/dsm-schema.d.ts +1 -1
- package/dist/capability-map/dsm-schema.js +1 -1
- package/dist/capability-map/layout-tree.d.ts +5 -1
- package/dist/capability-map/layout-tree.js +11 -9
- package/dist/capability-map/render-capability-tree.d.ts +2 -9
- package/dist/capability-map/render-capability-tree.js +16 -28
- package/dist/codex/__tests__/example.test.d.ts +1 -0
- package/dist/codex/__tests__/example.test.js +33 -0
- package/dist/codex/__tests__/validate.test.d.ts +1 -0
- package/dist/codex/__tests__/validate.test.js +68 -0
- package/dist/codex/index.d.ts +4 -0
- package/dist/codex/index.js +2 -0
- package/dist/codex/types.d.ts +7 -0
- package/dist/codex/types.js +8 -0
- package/dist/codex/validate.d.ts +11 -0
- package/dist/codex/validate.js +103 -0
- package/dist/compliance/__tests__/canon-catalog.test.d.ts +1 -0
- package/dist/compliance/__tests__/canon-catalog.test.js +60 -0
- package/dist/compliance/__tests__/view-resolution.test.d.ts +1 -0
- package/dist/compliance/__tests__/view-resolution.test.js +59 -0
- package/dist/compliance/canon-catalog.d.ts +19 -0
- package/dist/compliance/canon-catalog.js +59 -0
- package/dist/compliance/coverage-metric.d.ts +12 -0
- package/dist/compliance/coverage-metric.js +35 -0
- package/dist/compliance/impact.d.ts +7 -1
- package/dist/compliance/impact.js +53 -0
- package/dist/compliance/index.d.ts +6 -4
- package/dist/compliance/index.js +3 -2
- package/dist/constraint/__tests__/example.test.d.ts +1 -0
- package/dist/constraint/__tests__/example.test.js +14 -0
- package/dist/constraint/__tests__/validate.test.d.ts +1 -0
- package/dist/constraint/__tests__/validate.test.js +58 -0
- package/dist/constraint/index.d.ts +4 -0
- package/dist/constraint/index.js +2 -0
- package/dist/constraint/types.d.ts +4 -0
- package/dist/constraint/types.js +2 -0
- package/dist/constraint/validate.d.ts +7 -0
- package/dist/constraint/validate.js +115 -0
- package/dist/fgca/__tests__/parse-canonical.test.js +31 -0
- package/dist/fgca/parse-canonical.d.ts +1 -1
- package/dist/fgca/parse-canonical.js +18 -4
- package/dist/fgca/preview-layout.d.ts +8 -4
- package/dist/fgca/preview-layout.js +13 -13
- package/dist/goals/GoalNode.d.ts +2 -1
- package/dist/goals/GoalNode.js +7 -2
- package/dist/goals/GoalTreeView.d.ts +3 -1
- package/dist/goals/GoalTreeView.js +31 -14
- package/dist/goals/__tests__/GoalTreeView.test.js +41 -0
- package/dist/goals/__tests__/mutations.test.js +39 -0
- package/dist/goals/__tests__/parse-canonical.test.js +2 -0
- package/dist/goals/layout.js +2 -1
- package/dist/goals/mutations.js +6 -6
- package/dist/goals/parse-canonical.d.ts +5 -0
- package/dist/goals/parse-canonical.js +5 -0
- package/dist/goals/types.d.ts +2 -0
- package/dist/node-size-presets.d.ts +50 -0
- package/dist/node-size-presets.js +103 -0
- package/dist/process-blueprint/layout.js +4 -47
- package/dist/repo-validate/types.js +2 -3
- package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
- package/dist/webview/__tests__/entity-text-layout.test.js +43 -0
- package/dist/webview/__tests__/entry.test.js +3 -3
- package/dist/webview/__tests__/render-blocks.test.js +1 -7
- package/dist/webview/__tests__/render-goals.test.js +17 -3
- package/dist/webview/entity-text-layout.d.ts +95 -0
- package/dist/webview/entity-text-layout.js +279 -0
- package/dist/webview/entry.js +7 -3
- package/dist/webview/notation-style.d.ts +1 -1
- package/dist/webview/notation-style.js +1 -1
- package/dist/webview/render-activities.d.ts +3 -1
- package/dist/webview/render-activities.js +29 -32
- package/dist/webview/render-blocks.d.ts +4 -1
- package/dist/webview/render-blocks.js +27 -108
- package/dist/webview/render-fgca.d.ts +15 -8
- package/dist/webview/render-fgca.js +41 -44
- package/dist/webview/render-goals.d.ts +2 -0
- package/dist/webview/render-goals.js +23 -37
- package/dist/webview/render-process-blueprint.d.ts +4 -10
- package/dist/webview/render-process-blueprint.js +18 -64
- package/package.json +1 -1
- package/src/activities/layout.ts +12 -7
- package/src/activities/types.ts +4 -0
- package/src/capability-map/dsm-schema.ts +1 -1
- package/src/capability-map/index.ts +1 -1
- package/src/capability-map/layout-tree.ts +16 -8
- package/src/capability-map/render-capability-tree.ts +18 -30
- package/src/codex/__tests__/example.test.ts +40 -0
- package/src/codex/__tests__/validate.test.ts +84 -0
- package/src/codex/index.ts +4 -0
- package/src/codex/types.ts +13 -0
- package/src/codex/validate.ts +125 -0
- package/src/compliance/__tests__/canon-catalog.test.ts +68 -0
- package/src/compliance/__tests__/view-resolution.test.ts +80 -0
- package/src/compliance/canon-catalog.ts +76 -0
- package/src/compliance/coverage-metric.ts +50 -0
- package/src/compliance/impact.ts +67 -1
- package/src/compliance/index.ts +10 -1
- package/src/constraint/__tests__/example.test.ts +19 -0
- package/src/constraint/__tests__/validate.test.ts +68 -0
- package/src/constraint/index.ts +4 -0
- package/src/constraint/types.ts +5 -0
- package/src/constraint/validate.ts +131 -0
- package/src/fgca/__tests__/parse-canonical.test.ts +34 -0
- package/src/fgca/parse-canonical.ts +17 -4
- package/src/fgca/preview-layout.ts +21 -15
- package/src/goals/GoalNode.tsx +8 -1
- package/src/goals/GoalTreeView.tsx +61 -27
- package/src/goals/__tests__/GoalTreeView.test.tsx +48 -0
- package/src/goals/__tests__/mutations.test.ts +42 -0
- package/src/goals/__tests__/parse-canonical.test.ts +2 -0
- package/src/goals/layout.ts +2 -1
- package/src/goals/mutations.ts +6 -6
- package/src/goals/parse-canonical.ts +6 -0
- package/src/goals/types.ts +2 -0
- package/src/node-size-presets.ts +157 -0
- package/src/process-blueprint/layout.ts +4 -48
- package/src/repo-validate/types.ts +2 -3
- package/src/webview/__tests__/entity-text-layout.test.ts +52 -0
- package/src/webview/__tests__/entry.test.ts +3 -3
- package/src/webview/__tests__/render-blocks.test.ts +1 -9
- package/src/webview/__tests__/render-goals.test.ts +17 -3
- package/src/webview/entity-text-layout.ts +355 -0
- package/src/webview/entry.ts +8 -4
- package/src/webview/notation-style.ts +1 -1
- package/src/webview/render-activities.ts +38 -27
- package/src/webview/render-blocks.ts +35 -118
- package/src/webview/render-fgca.ts +59 -44
- package/src/webview/render-goals.ts +24 -35
- package/src/webview/render-process-blueprint.ts +30 -65
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// CONSTRAINT validator — methodology ELEMENT_PRIMITIVES.md §7.13 (sibling to
|
|
2
|
+
// 15-requirement.md). Implements CONST-001..005 for the compliance suite (#518 C4).
|
|
3
|
+
|
|
4
|
+
import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
|
|
5
|
+
import { typeOfId, isCanonicalIdOfType, type CanonCatalog } from '../typed-id.js';
|
|
6
|
+
import { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
|
|
7
|
+
|
|
8
|
+
export interface ConstraintValidateOptions {
|
|
9
|
+
/** When provided, CONST-004/005 enforce artefact existence for cross-refs. */
|
|
10
|
+
catalog?: CanonCatalog;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REQUIRED_FIELDS = [
|
|
14
|
+
'notation',
|
|
15
|
+
'name',
|
|
16
|
+
'statement',
|
|
17
|
+
'status',
|
|
18
|
+
'zone',
|
|
19
|
+
'admitted_at',
|
|
20
|
+
'admitted_by',
|
|
21
|
+
'gate_checks',
|
|
22
|
+
'valid_from',
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export function validateConstraint(
|
|
26
|
+
input: unknown,
|
|
27
|
+
options: ConstraintValidateOptions = {},
|
|
28
|
+
): ValidationResult {
|
|
29
|
+
const errors: ValidationError[] = [];
|
|
30
|
+
const warnings: ValidationWarning[] = [];
|
|
31
|
+
|
|
32
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
33
|
+
errors.push({ code: 'CONST-001', message: 'Constraint must be a YAML mapping.' });
|
|
34
|
+
return { valid: false, errors, warnings };
|
|
35
|
+
}
|
|
36
|
+
const c = input as Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
if (!isCanonicalIdOfType(c.id, 'CONSTRAINT')) {
|
|
39
|
+
errors.push({
|
|
40
|
+
code: 'CONST-001',
|
|
41
|
+
message: `id "${String(c.id)}" must match CONSTRAINT-[<middle>-]<INTEGER>.`,
|
|
42
|
+
path: 'id',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (c.notation !== 'constraint') {
|
|
46
|
+
errors.push({ code: 'CONST-001', message: 'notation must be the fixed value "constraint".', path: 'notation' });
|
|
47
|
+
}
|
|
48
|
+
for (const f of REQUIRED_FIELDS) {
|
|
49
|
+
if (f === 'notation') continue;
|
|
50
|
+
if (f === 'gate_checks') {
|
|
51
|
+
if (c.gate_checks === null || typeof c.gate_checks !== 'object') {
|
|
52
|
+
errors.push({ code: 'CONST-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const v = c[f];
|
|
57
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
58
|
+
errors.push({ code: 'CONST-001', message: `${f} is required.`, path: f });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (c.zone !== 'canon') {
|
|
62
|
+
errors.push({ code: 'CONST-001', message: 'zone must be "canon" for a CONSTRAINT.', path: 'zone' });
|
|
63
|
+
}
|
|
64
|
+
if (!('valid_to' in c) || !(typeof c.valid_to === 'string' || c.valid_to === null)) {
|
|
65
|
+
errors.push({ code: 'CONST-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const status = typeof c.status === 'string' ? c.status.trim() : '';
|
|
69
|
+
if (status && !(CONSTRAINT_STATUSES as readonly string[]).includes(status)) {
|
|
70
|
+
errors.push({
|
|
71
|
+
code: 'CONST-002',
|
|
72
|
+
message: `status must be one of ${CONSTRAINT_STATUSES.join(', ')}.`,
|
|
73
|
+
path: 'status',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (c.severity !== undefined) {
|
|
78
|
+
const severity = typeof c.severity === 'string' ? c.severity.trim() : '';
|
|
79
|
+
if (!severity || !(CONSTRAINT_SEVERITIES as readonly string[]).includes(severity)) {
|
|
80
|
+
errors.push({
|
|
81
|
+
code: 'CONST-003',
|
|
82
|
+
message: `severity must be one of ${CONSTRAINT_SEVERITIES.join(', ')}.`,
|
|
83
|
+
path: 'severity',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (c.applies_to !== undefined) {
|
|
89
|
+
if (!Array.isArray(c.applies_to)) {
|
|
90
|
+
errors.push({ code: 'CONST-001', message: 'applies_to must be a list of typed IDs.', path: 'applies_to' });
|
|
91
|
+
} else {
|
|
92
|
+
c.applies_to.forEach((ref, i) => {
|
|
93
|
+
const type = typeOfId(ref);
|
|
94
|
+
if (!type) {
|
|
95
|
+
errors.push({
|
|
96
|
+
code: 'CONST-004',
|
|
97
|
+
message: `applies_to[${i}] "${String(ref)}" is not a resolvable typed ID.`,
|
|
98
|
+
path: `applies_to[${i}]`,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (options.catalog && options.catalog.typeOf(ref as string) === undefined) {
|
|
103
|
+
errors.push({
|
|
104
|
+
code: 'CONST-004',
|
|
105
|
+
message: `applies_to[${i}] "${String(ref)}" does not resolve to an admitted artefact.`,
|
|
106
|
+
path: `applies_to[${i}]`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (c.owner_role !== undefined) {
|
|
114
|
+
const role = c.owner_role;
|
|
115
|
+
if (typeof role !== 'string' || !isCanonicalIdOfType(role, 'ROLE')) {
|
|
116
|
+
errors.push({
|
|
117
|
+
code: 'CONST-005',
|
|
118
|
+
message: `owner_role "${String(role)}" must be a typed ROLE id.`,
|
|
119
|
+
path: 'owner_role',
|
|
120
|
+
});
|
|
121
|
+
} else if (options.catalog && options.catalog.typeOf(role) === undefined) {
|
|
122
|
+
errors.push({
|
|
123
|
+
code: 'CONST-005',
|
|
124
|
+
message: `owner_role "${role}" does not resolve to an admitted artefact.`,
|
|
125
|
+
path: 'owner_role',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
131
|
+
}
|
|
@@ -35,6 +35,31 @@ describe('parseCanonicalFGCA', () => {
|
|
|
35
35
|
expect(r.parsed?.activities).toHaveLength(1);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it('accepts ACTION-* ids (methodology 1.0) alongside legacy ACTIVITY-*', () => {
|
|
39
|
+
const r = parseCanonicalFGCA({
|
|
40
|
+
...VALID,
|
|
41
|
+
actions: [
|
|
42
|
+
{ id: 'ACTION-CRM-EU-1', name: 'CRM rollout', changes: ['CHANGE-1'] },
|
|
43
|
+
{ id: 'ACTIVITY-2', name: 'Legacy workstream', changes: ['CHANGE-1'] },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
47
|
+
expect(r.parsed?.activities.map((a) => a.id)).toEqual(['ACTION-CRM-EU-1', 'ACTIVITY-2']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('defaults changes to [] when view_config.layers.changes is off (DGA-in-dgca)', () => {
|
|
51
|
+
const r = parseCanonicalFGCA({
|
|
52
|
+
...VALID,
|
|
53
|
+
changes: undefined,
|
|
54
|
+
view_config: { layers: { changes: 'off' } },
|
|
55
|
+
actions: [
|
|
56
|
+
{ id: 'ACTION-DISCOVERY-1', name: 'Gap assessment', goals: ['GOAL-1'] },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
60
|
+
expect(r.parsed?.changes).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
38
63
|
it('FGCA-001: rejects non-object input', () => {
|
|
39
64
|
expect(parseCanonicalFGCA(null).errors[0].code).toBe('FGCA-001');
|
|
40
65
|
expect(parseCanonicalFGCA('string').errors[0].code).toBe('FGCA-001');
|
|
@@ -198,6 +223,15 @@ describe('parseCanonicalFGA', () => {
|
|
|
198
223
|
expect(r.parsed?.activities).toHaveLength(1);
|
|
199
224
|
});
|
|
200
225
|
|
|
226
|
+
it('accepts ACTION-* ids in FGA mode', () => {
|
|
227
|
+
const r = parseCanonicalFGA({
|
|
228
|
+
...VALID_FGA,
|
|
229
|
+
actions: [{ id: 'ACTION-GDPR-DSR-WORKFLOW-1', name: 'DSR workflow', goals: ['GOAL-1'] }],
|
|
230
|
+
});
|
|
231
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
232
|
+
expect(r.parsed?.activities[0].id).toBe('ACTION-GDPR-DSR-WORKFLOW-1');
|
|
233
|
+
});
|
|
234
|
+
|
|
201
235
|
it('populates activity.goal_id from activity.goals[] (the FGA edge-driving field)', () => {
|
|
202
236
|
// The "FGA nodes render, no edges" bug: activities had no resolvable
|
|
203
237
|
// goal_id, so the renderer drew no goal → activity edges.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - Flat top-level arrays, no `fgca:` wrapper.
|
|
8
8
|
* - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
|
|
9
|
-
* `GOAL-RET-1`, `CHANGE-1`, `
|
|
9
|
+
* `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
|
|
10
10
|
* - Plural canonical-direction cross-references: `goal.factors`,
|
|
11
11
|
* `change.goals`, `activity.changes`, `activity.goals`.
|
|
12
12
|
* - Per-notation validation codes `FGCA-001 … FGCA-015`.
|
|
@@ -50,7 +50,8 @@ const DRIVER_ID_RE = /^DRIVER(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
50
50
|
const FACTOR_OR_DRIVER_ID_RE = /^(FACTOR|DRIVER)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
51
51
|
const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
52
52
|
const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
53
|
-
|
|
53
|
+
// ACTION is canonical since methodology 1.0; ACTIVITY accepted for legacy docs.
|
|
54
|
+
const ACTION_OR_ACTIVITY_ID_RE = /^(ACTION|ACTIVITY)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
54
55
|
const FGCA_DOC_ID_RE = /^(FGCA|DGCA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
55
56
|
const CONSTRAINT_ID_RE = /^CONSTRAINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
56
57
|
|
|
@@ -63,6 +64,15 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
|
|
|
63
64
|
return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
|
|
68
|
+
function isChangesLayerOff(raw: Record<string, unknown>): boolean {
|
|
69
|
+
const vc = raw['view_config'];
|
|
70
|
+
if (!vc || typeof vc !== 'object') return false;
|
|
71
|
+
const layers = (vc as Record<string, unknown>)['layers'];
|
|
72
|
+
if (!layers || typeof layers !== 'object') return false;
|
|
73
|
+
return (layers as Record<string, unknown>)['changes'] === 'off';
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
|
|
67
77
|
/**
|
|
68
78
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
@@ -114,7 +124,10 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
114
124
|
|
|
115
125
|
const factorsRaw = asObjectArray(raw['factors']);
|
|
116
126
|
const goalsRaw = asObjectArray(raw['goals']);
|
|
117
|
-
|
|
127
|
+
let changesRaw = asObjectArray(raw['changes']);
|
|
128
|
+
if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
|
|
129
|
+
changesRaw = [];
|
|
130
|
+
}
|
|
118
131
|
const activitiesRaw = asObjectArray(raw['actions']);
|
|
119
132
|
if (activitiesRaw === null && 'activities' in raw) {
|
|
120
133
|
errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
|
|
@@ -177,7 +190,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
|
|
|
177
190
|
factors.forEach((el, i) => checkLayerElement(el, `factors[${i}]`, FACTOR_OR_DRIVER_ID_RE, factorIds));
|
|
178
191
|
goals.forEach((el, i) => checkLayerElement(el, `goals[${i}]`, GOAL_ID_RE, goalIds));
|
|
179
192
|
changes.forEach((el, i) => checkLayerElement(el, `changes[${i}]`, CHANGE_ID_RE, changeIds));
|
|
180
|
-
activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`,
|
|
193
|
+
activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
|
|
181
194
|
|
|
182
195
|
if (errors.length > 0) return { valid: false, errors, warnings };
|
|
183
196
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Pure column-layout geometry for the static
|
|
1
|
+
// Pure column-layout geometry for the static DGCA / DGA previews.
|
|
2
2
|
//
|
|
3
|
-
// This is the layout the Studio extension's `
|
|
3
|
+
// This is the layout the Studio extension's `dgca-preview.ts` renders to SVG
|
|
4
4
|
// (NOT the ReactFlow `buildFGCALayout` in `./layout.ts`, which targets the
|
|
5
5
|
// interactive web UI). It lives here — rather than inline in the extension —
|
|
6
6
|
// so the gap geometry is unit-testable: the extension has no test harness.
|
|
@@ -33,7 +33,7 @@ export interface FGCAPreviewActivity {
|
|
|
33
33
|
goal_id?: number | string | null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** Structural input the preview layout needs — a subset of the parsed
|
|
36
|
+
/** Structural input the preview layout needs — a subset of the parsed DGCA/DGA doc. */
|
|
37
37
|
export interface FGCAPreviewDoc {
|
|
38
38
|
factors: FGCAPreviewFactor[];
|
|
39
39
|
goals: FGCAPreviewGoal[];
|
|
@@ -42,7 +42,7 @@ export interface FGCAPreviewDoc {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export interface FGCAPreviewLayoutOptions {
|
|
45
|
-
/**
|
|
45
|
+
/** DGA hides the Changes column and links Goal → Activity directly. */
|
|
46
46
|
hideChanges?: boolean;
|
|
47
47
|
/** Horizontal gap (px) between columns. Default matches the historical hardcoded value. */
|
|
48
48
|
colGap?: number;
|
|
@@ -50,12 +50,16 @@ export interface FGCAPreviewLayoutOptions {
|
|
|
50
50
|
rowGap?: number;
|
|
51
51
|
/** Trim to a level cap or a single root goal (vkgeorgia/strategy#77). Defaults to 'all'. */
|
|
52
52
|
scope?: Scope;
|
|
53
|
+
/** Entity node width (px). Default {@link FGCA_NODE_W}. */
|
|
54
|
+
nodeWidth?: number;
|
|
55
|
+
/** Entity node height (px). Default {@link FGCA_NODE_H}. */
|
|
56
|
+
nodeHeight?: number;
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
|
-
* Trims
|
|
60
|
+
* Trims a DGCA/DGA doc to a scope.
|
|
57
61
|
*
|
|
58
|
-
*
|
|
62
|
+
* DGCA/DGA goals are flat (no parent_id), so:
|
|
59
63
|
* - 'level' → goals with `(level ?? 0) <= maxLevel`.
|
|
60
64
|
* - 'root' → the single goal whose id matches `rootGoalId` (empty when absent).
|
|
61
65
|
*
|
|
@@ -119,8 +123,8 @@ export interface FGCAPreviewLayout {
|
|
|
119
123
|
height: number;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
126
|
+
// Default node + frame geometry. Inter-node gaps are user-configurable via
|
|
127
|
+
// Inter-node gaps are user-configurable via spacing settings; node size uses presets.
|
|
124
128
|
export const FGCA_NODE_W = 220;
|
|
125
129
|
export const FGCA_NODE_H = 72;
|
|
126
130
|
export const FGCA_HEADER_H = 32;
|
|
@@ -137,12 +141,14 @@ export function layoutFGCAPreview(
|
|
|
137
141
|
colGap = FGCA_DEFAULT_COL_GAP,
|
|
138
142
|
rowGap = FGCA_DEFAULT_ROW_GAP,
|
|
139
143
|
scope = { mode: 'all' },
|
|
144
|
+
nodeWidth = FGCA_NODE_W,
|
|
145
|
+
nodeHeight = FGCA_NODE_H,
|
|
140
146
|
} = options;
|
|
141
147
|
|
|
142
148
|
// Trim to scope first; everything below lays out the visible subset only.
|
|
143
149
|
const doc = selectScopedFGCA(inputDoc, scope);
|
|
144
150
|
|
|
145
|
-
const colStride =
|
|
151
|
+
const colStride = nodeWidth + colGap;
|
|
146
152
|
const cols: FGCAPreviewColumn[] = hideChanges
|
|
147
153
|
? ['driver', 'goal', 'activity']
|
|
148
154
|
: ['driver', 'goal', 'change', 'activity'];
|
|
@@ -206,7 +212,7 @@ export function layoutFGCAPreview(
|
|
|
206
212
|
const nodeMap = new Map<string, FGCAPreviewNode>();
|
|
207
213
|
const nodes: FGCAPreviewNode[] = [];
|
|
208
214
|
const columns: FGCAPreviewColumnPos[] = [];
|
|
209
|
-
const yCenters = new Map<string, number>(); // nodeId → y +
|
|
215
|
+
const yCenters = new Map<string, number>(); // nodeId → y + nodeHeight / 2
|
|
210
216
|
|
|
211
217
|
for (let ci = 0; ci < cols.length; ci++) {
|
|
212
218
|
const col = cols[ci];
|
|
@@ -218,8 +224,8 @@ export function layoutFGCAPreview(
|
|
|
218
224
|
const node: FGCAPreviewNode = { id: item.id, x, y, label: item.label, col };
|
|
219
225
|
nodes.push(node);
|
|
220
226
|
nodeMap.set(item.id, node);
|
|
221
|
-
yCenters.set(item.id, y +
|
|
222
|
-
y +=
|
|
227
|
+
yCenters.set(item.id, y + nodeHeight / 2);
|
|
228
|
+
y += nodeHeight + rowGap;
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
|
|
@@ -228,7 +234,7 @@ export function layoutFGCAPreview(
|
|
|
228
234
|
const s = nodeMap.get(sourceId);
|
|
229
235
|
const t = nodeMap.get(targetId);
|
|
230
236
|
if (!s || !t) return;
|
|
231
|
-
edges.push({ sx: s.x +
|
|
237
|
+
edges.push({ sx: s.x + nodeWidth, sy: s.y + nodeHeight / 2, tx: t.x, ty: t.y + nodeHeight / 2 });
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
for (const g of doc.goals) {
|
|
@@ -259,8 +265,8 @@ export function layoutFGCAPreview(
|
|
|
259
265
|
}
|
|
260
266
|
|
|
261
267
|
const maxNodeBottom = nodes.reduce(
|
|
262
|
-
(m, n) => Math.max(m, n.y +
|
|
263
|
-
FGCA_PAD + FGCA_HEADER_H + rowGap +
|
|
268
|
+
(m, n) => Math.max(m, n.y + nodeHeight),
|
|
269
|
+
FGCA_PAD + FGCA_HEADER_H + rowGap + nodeHeight,
|
|
264
270
|
);
|
|
265
271
|
const width = FGCA_PAD * 2 + cols.length * colStride - colGap;
|
|
266
272
|
const height = maxNodeBottom + FGCA_PAD;
|
package/src/goals/GoalNode.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { memo, useState } from 'react';
|
|
2
2
|
import { Handle, Position } from 'reactflow';
|
|
3
3
|
import type { Factor, Goal } from './types.js';
|
|
4
|
+
import { displayGoalId } from './parse-canonical.js';
|
|
4
5
|
import type { ThemeTokens } from './theme.js';
|
|
5
6
|
|
|
6
7
|
const NODE_WIDTH = 250;
|
|
@@ -23,6 +24,7 @@ export interface GoalNodeData {
|
|
|
23
24
|
onAddChild: (parentId: number, parentLevel: number) => void;
|
|
24
25
|
onDelete: (id: number) => void;
|
|
25
26
|
onToggleCollapse: (id: number) => void;
|
|
27
|
+
onFactorClick?: (factor: Factor) => void;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
@@ -91,7 +93,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
91
93
|
{goal.name}
|
|
92
94
|
</div>
|
|
93
95
|
<div style={{ fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }}>
|
|
94
|
-
ID: {goal
|
|
96
|
+
ID: {displayGoalId(goal)} | Lvl: {goal.level}
|
|
95
97
|
{goal.tag ? ` | #${goal.tag}` : ''}
|
|
96
98
|
</div>
|
|
97
99
|
</div>
|
|
@@ -130,6 +132,10 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
130
132
|
{(openPanel === 'positive' ? positive : negative).map((f) => (
|
|
131
133
|
<div
|
|
132
134
|
key={f.id}
|
|
135
|
+
onClick={(e) => {
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
data.onFactorClick?.(f);
|
|
138
|
+
}}
|
|
133
139
|
style={{
|
|
134
140
|
background: openPanel === 'positive' ? '#f0fdf4' : '#fef2f2',
|
|
135
141
|
border: `1px solid ${openPanel === 'positive' ? '#bbf7d0' : '#fecaca'}`,
|
|
@@ -138,6 +144,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
|
|
|
138
144
|
padding: '4px 6px',
|
|
139
145
|
fontSize: 10,
|
|
140
146
|
textAlign: 'left',
|
|
147
|
+
cursor: data.onFactorClick ? 'pointer' : 'default',
|
|
141
148
|
}}
|
|
142
149
|
>
|
|
143
150
|
<div style={{ fontWeight: 700 }}>{f.name}</div>
|
|
@@ -12,7 +12,7 @@ import ReactFlow, {
|
|
|
12
12
|
type NodeDragHandler,
|
|
13
13
|
} from 'reactflow';
|
|
14
14
|
import 'reactflow/dist/style.css';
|
|
15
|
-
import type { Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
|
|
15
|
+
import type { Factor, Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
|
|
16
16
|
import { layoutGoalTree } from './layout.js';
|
|
17
17
|
import { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
|
|
18
18
|
import type { ThemeTokens } from './theme.js';
|
|
@@ -28,9 +28,11 @@ export interface GoalTreeViewProps {
|
|
|
28
28
|
theme?: ThemeTokens;
|
|
29
29
|
readOnly?: boolean;
|
|
30
30
|
showBacklog?: boolean;
|
|
31
|
+
defaultBacklogOpen?: boolean;
|
|
31
32
|
showMiniMap?: boolean;
|
|
32
33
|
onChange?: (event: GoalTreeChange) => void;
|
|
33
34
|
onEditRequest?: (goal: Goal) => void;
|
|
35
|
+
onFactorClick?: (factor: Factor) => void;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export type GoalTreeChange =
|
|
@@ -99,13 +101,16 @@ function GoalTreeViewInner({
|
|
|
99
101
|
theme: themeProp,
|
|
100
102
|
readOnly = false,
|
|
101
103
|
showBacklog = false,
|
|
104
|
+
defaultBacklogOpen = true,
|
|
102
105
|
showMiniMap = false,
|
|
103
106
|
onChange,
|
|
104
107
|
onEditRequest,
|
|
108
|
+
onFactorClick,
|
|
105
109
|
}: GoalTreeViewProps): React.ReactElement {
|
|
106
110
|
const theme = useMemo(() => resolveTheme(themeProp), [themeProp]);
|
|
107
111
|
const [collapsedIds, setCollapsedIds] = useState<Set<number>>(new Set());
|
|
108
112
|
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
|
113
|
+
const [isBacklogOpen, setIsBacklogOpen] = useState(defaultBacklogOpen);
|
|
109
114
|
|
|
110
115
|
const { canvas: canvasGoals, backlog: backlogGoals } = useMemo(
|
|
111
116
|
() => partitionCanvasAndBacklog(tree.goals),
|
|
@@ -179,9 +184,10 @@ function GoalTreeViewInner({
|
|
|
179
184
|
onAddChild,
|
|
180
185
|
onDelete: onDeleteNode,
|
|
181
186
|
onToggleCollapse,
|
|
187
|
+
onFactorClick,
|
|
182
188
|
},
|
|
183
189
|
}));
|
|
184
|
-
}, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse]);
|
|
190
|
+
}, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse, onFactorClick]);
|
|
185
191
|
|
|
186
192
|
const buildEdges = useCallback((): Edge[] => {
|
|
187
193
|
return layout.edges.map((e) => ({
|
|
@@ -286,38 +292,66 @@ function GoalTreeViewInner({
|
|
|
286
292
|
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
|
|
287
293
|
{showBacklogPanel && (
|
|
288
294
|
<div
|
|
289
|
-
data-goaltree-backlog-zone
|
|
295
|
+
{...(isBacklogOpen ? { 'data-goaltree-backlog-zone': true } : {})}
|
|
290
296
|
style={{
|
|
291
|
-
width: 220,
|
|
297
|
+
width: isBacklogOpen ? 220 : 28,
|
|
292
298
|
flexShrink: 0,
|
|
293
299
|
borderRight: '1px solid #e2e8f0',
|
|
294
|
-
padding: 10,
|
|
295
|
-
overflowY: 'auto',
|
|
296
300
|
background: '#f8fafc',
|
|
301
|
+
display: 'flex',
|
|
302
|
+
flexDirection: 'column',
|
|
303
|
+
position: 'relative',
|
|
297
304
|
}}
|
|
298
305
|
>
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
aria-label={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
|
|
309
|
+
title={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
|
|
310
|
+
onClick={() => setIsBacklogOpen((v) => !v)}
|
|
311
|
+
style={{
|
|
312
|
+
alignSelf: 'flex-end',
|
|
313
|
+
margin: '6px 6px 0 6px',
|
|
314
|
+
width: 16,
|
|
315
|
+
height: 16,
|
|
316
|
+
border: '1px solid #cbd5e1',
|
|
317
|
+
borderRadius: 4,
|
|
318
|
+
background: '#ffffff',
|
|
319
|
+
color: '#64748b',
|
|
320
|
+
fontSize: 10,
|
|
321
|
+
lineHeight: '14px',
|
|
322
|
+
padding: 0,
|
|
323
|
+
cursor: 'pointer',
|
|
324
|
+
flexShrink: 0,
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{isBacklogOpen ? '◀' : '▶'}
|
|
328
|
+
</button>
|
|
329
|
+
{isBacklogOpen && (
|
|
330
|
+
<div style={{ padding: '4px 10px 10px', overflowY: 'auto', flex: 1 }}>
|
|
331
|
+
<div style={{ fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }}>Backlog</div>
|
|
332
|
+
{backlogGoals.map((goal) => (
|
|
333
|
+
<div
|
|
334
|
+
key={goal.id}
|
|
335
|
+
draggable={!readOnly}
|
|
336
|
+
onDragStart={(e) => onBacklogItemDragStart(e, goal.id)}
|
|
337
|
+
onDoubleClick={() => onEditRequest?.(goal)}
|
|
338
|
+
title="Drag onto a canvas card to set its parent"
|
|
339
|
+
style={{
|
|
340
|
+
padding: 8,
|
|
341
|
+
marginBottom: 6,
|
|
342
|
+
background: '#ffffff',
|
|
343
|
+
border: '1px solid #cbd5e1',
|
|
344
|
+
borderRadius: 6,
|
|
345
|
+
fontSize: 11,
|
|
346
|
+
cursor: readOnly ? 'default' : 'grab',
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<div style={{ fontWeight: 600 }}>{goal.name}</div>
|
|
350
|
+
<div style={{ color: '#64748b' }}>Level: {goal.level}</div>
|
|
351
|
+
</div>
|
|
352
|
+
))}
|
|
319
353
|
</div>
|
|
320
|
-
)
|
|
354
|
+
)}
|
|
321
355
|
</div>
|
|
322
356
|
)}
|
|
323
357
|
|
|
@@ -297,6 +297,54 @@ describe('GoalTreeView', () => {
|
|
|
297
297
|
});
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
+
it('fires onFactorClick with the clicked factor when a popover item is clicked', async () => {
|
|
301
|
+
const onFactorClick = vi.fn();
|
|
302
|
+
render(<GoalTreeView tree={makeTree()} onFactorClick={onFactorClick} />);
|
|
303
|
+
await waitFor(() => {
|
|
304
|
+
expect(screen.getByText('Cut churn')).toBeTruthy();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Open the negative-factor panel on "Cut churn" (id=3, has risk factor "Support backlog")
|
|
308
|
+
const negativeBadge = screen.getByLabelText('1 negative factor');
|
|
309
|
+
negativeBadge.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
310
|
+
|
|
311
|
+
// The popover item for the risk factor should now be in the DOM
|
|
312
|
+
const factorItem = await waitFor(() => screen.getByText('Support backlog'));
|
|
313
|
+
factorItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
314
|
+
|
|
315
|
+
await waitFor(() => expect(onFactorClick).toHaveBeenCalledTimes(1));
|
|
316
|
+
expect(onFactorClick.mock.calls[0][0]).toMatchObject({ id: 10, name: 'Support backlog', impact_type: 'risk' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('backlog panel is collapsible — toggle hides and restores content', async () => {
|
|
320
|
+
const tree: GoalTree = {
|
|
321
|
+
goal_types: [
|
|
322
|
+
{ name: 'Strategy', level: 0 },
|
|
323
|
+
{ name: 'Business Goal', level: 1 },
|
|
324
|
+
],
|
|
325
|
+
goals: [
|
|
326
|
+
{ id: 1, name: 'Root goal', type: 'Strategy', level: 0, parent_id: 0 },
|
|
327
|
+
{ id: 2, name: 'Orphaned goal', type: 'Business Goal', level: 1, parent_id: 999 },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
render(<GoalTreeView tree={tree} showBacklog />);
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(screen.getByText('Backlog')).toBeTruthy();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Collapse
|
|
336
|
+
screen.getByLabelText('Collapse backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(screen.queryByText('Backlog')).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Expand again
|
|
342
|
+
screen.getByLabelText('Expand backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
343
|
+
await waitFor(() => {
|
|
344
|
+
expect(screen.getByText('Backlog')).toBeTruthy();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
300
348
|
it('fires onEditRequest on double-click', async () => {
|
|
301
349
|
const onEditRequest = vi.fn();
|
|
302
350
|
render(<GoalTreeView tree={makeTree()} onEditRequest={onEditRequest} />);
|
|
@@ -109,3 +109,45 @@ describe('restoreFromBacklog', () => {
|
|
|
109
109
|
expect(r.result!.goals.find(g => g.id === 3)!.parent_id).toBe(1);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
describe('reparent — descendant type relabeling and level cap', () => {
|
|
114
|
+
const DEEP: GoalTree = {
|
|
115
|
+
goal_types: [
|
|
116
|
+
{ name: 'Strategy', level: 0 },
|
|
117
|
+
{ name: 'Business Goal', level: 1 },
|
|
118
|
+
{ name: 'Project', level: 2 },
|
|
119
|
+
{ name: 'Task', level: 3 },
|
|
120
|
+
],
|
|
121
|
+
goals: [
|
|
122
|
+
{ id: 1, name: 'Root', type: 'Strategy', level: 0, parent_id: 0 },
|
|
123
|
+
{ id: 2, name: 'BG1', type: 'Business Goal', level: 1, parent_id: 1 },
|
|
124
|
+
{ id: 3, name: 'Proj1', type: 'Project', level: 2, parent_id: 2 },
|
|
125
|
+
{ id: 4, name: 'Task1', type: 'Task', level: 3, parent_id: 3 },
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
it('relabels descendant types via getTypeForLevel when reparenting shifts levels', () => {
|
|
130
|
+
// Move Proj1 (id=3, level=2) directly under Root (id=1, level=0); delta = -1
|
|
131
|
+
// Task1 (child of Proj1, level=3): new level = 2, type should be 'Project'
|
|
132
|
+
const r = reparent(DEEP, 3, 1);
|
|
133
|
+
expect(r.ok).toBe(true);
|
|
134
|
+
const proj = r.result!.goals.find(g => g.id === 3)!;
|
|
135
|
+
const task = r.result!.goals.find(g => g.id === 4)!;
|
|
136
|
+
expect(proj.level).toBe(1);
|
|
137
|
+
expect(proj.type).toBe('Business Goal');
|
|
138
|
+
expect(task.level).toBe(2);
|
|
139
|
+
expect(task.type).toBe('Project');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('caps descendant level at maxLevel and refuses when that violates GOALS-012', () => {
|
|
143
|
+
// Move Root (id=1, level=0) under Root2 (id=5, level=0); delta = +1
|
|
144
|
+
// BG1: 1→2, Proj1: 2→3 (maxLevel), Task1: 3→4, capped at 3
|
|
145
|
+
// Task1 (capped level=3) under Proj1 (level=3) violates GOALS-012 → refused
|
|
146
|
+
const tree: GoalTree = {
|
|
147
|
+
...DEEP,
|
|
148
|
+
goals: [...DEEP.goals, { id: 5, name: 'Root2', type: 'Strategy', level: 0, parent_id: 0 }],
|
|
149
|
+
};
|
|
150
|
+
const r = reparent(tree, 1, 5);
|
|
151
|
+
expect(r.ok).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -122,8 +122,10 @@ describe('parseCanonicalGoals', () => {
|
|
|
122
122
|
it('maps canonical IDs to internal numeric ids; parent_id is the parent\'s mapped id', () => {
|
|
123
123
|
const r = parseCanonicalGoals(VALID);
|
|
124
124
|
expect(r.parsed?.goals[0].id).toBe(1);
|
|
125
|
+
expect(r.parsed?.goals[0].canonical_id).toBe('GOAL-1');
|
|
125
126
|
expect(r.parsed?.goals[0].parent_id).toBe(0);
|
|
126
127
|
expect(r.parsed?.goals[1].id).toBe(2);
|
|
128
|
+
expect(r.parsed?.goals[1].canonical_id).toBe('GOAL-2');
|
|
127
129
|
expect(r.parsed?.goals[1].parent_id).toBe(1);
|
|
128
130
|
});
|
|
129
131
|
});
|
package/src/goals/layout.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Goal, GoalTree, LayoutOptions, GoalTreeLayout, LaidOutNode, LaidOutEdge } from './types.js';
|
|
2
2
|
import type { Scope } from '../scope.js';
|
|
3
|
+
import { displayGoalId } from './parse-canonical.js';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_NODE_WIDTH = 250;
|
|
5
6
|
const DEFAULT_NODE_HEIGHT = 80;
|
|
@@ -23,7 +24,7 @@ export function selectScopedGoals(goals: Goal[], scope: Scope): Goal[] {
|
|
|
23
24
|
if (!children.has(g.parent_id)) children.set(g.parent_id, []);
|
|
24
25
|
children.get(g.parent_id)!.push(g.id);
|
|
25
26
|
}
|
|
26
|
-
const root = goals.find(g =>
|
|
27
|
+
const root = goals.find(g => displayGoalId(g) === scope.rootGoalId);
|
|
27
28
|
if (!root) return [];
|
|
28
29
|
const keep = new Set<number>();
|
|
29
30
|
const stack = [root.id];
|