@transitrix/diagrams 1.6.0 → 1.7.6
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/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/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/repo-validate/types.js +2 -3
- package/dist/webview/__tests__/render-goals.test.js +17 -3
- package/dist/webview/render-goals.js +2 -1
- package/package.json +1 -1
- 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/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/repo-validate/types.ts +2 -3
- package/src/webview/__tests__/render-goals.test.ts +17 -3
- package/src/webview/render-goals.ts +2 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { validateConstraint } from '../validate.js';
|
|
7
|
+
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../../tests/fixtures/notation-corpus/constraint');
|
|
8
|
+
describe('constraint worked example (notation-corpus)', () => {
|
|
9
|
+
it('validates CONSTRAINT-GDPR-RESIDENCY-1.yaml without error', () => {
|
|
10
|
+
const data = yaml.load(readFileSync(path.join(dir, 'CONSTRAINT-GDPR-RESIDENCY-1.yaml'), 'utf-8'));
|
|
11
|
+
const v = validateConstraint(data);
|
|
12
|
+
expect(v.valid, JSON.stringify(v.errors)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateConstraint } from '../validate.js';
|
|
3
|
+
function valid() {
|
|
4
|
+
return {
|
|
5
|
+
notation: 'constraint',
|
|
6
|
+
id: 'CONSTRAINT-GDPR-RESIDENCY-1',
|
|
7
|
+
name: 'EU customer personal data must be stored within EU/EEA jurisdictions',
|
|
8
|
+
statement: 'Personal data of EU customers MUST NOT be persisted outside EU / EEA jurisdictions.',
|
|
9
|
+
status: 'active',
|
|
10
|
+
zone: 'canon',
|
|
11
|
+
admitted_at: '2026-05-29',
|
|
12
|
+
admitted_by: 'v.korobeinikov',
|
|
13
|
+
gate_checks: { uniqueness: 'pass', consistency: 'pass', completeness: 'pass' },
|
|
14
|
+
valid_from: '2018-05-25',
|
|
15
|
+
valid_to: null,
|
|
16
|
+
severity: 'mandatory',
|
|
17
|
+
owner_role: 'ROLE-DPO-1',
|
|
18
|
+
applies_to: ['APPLICATION-CRM-1'],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const codes = (input, opts) => validateConstraint(input, opts).errors.map((e) => e.code);
|
|
22
|
+
describe('validateConstraint — positive', () => {
|
|
23
|
+
it('accepts a well-formed constraint', () => {
|
|
24
|
+
const r = validateConstraint(valid());
|
|
25
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('validateConstraint — CONST-001', () => {
|
|
29
|
+
it('flags missing required fields and wrong notation', () => {
|
|
30
|
+
const r = valid();
|
|
31
|
+
delete r.statement;
|
|
32
|
+
expect(codes(r)).toContain('CONST-001');
|
|
33
|
+
expect(codes({ ...valid(), notation: 'requirement' })).toContain('CONST-001');
|
|
34
|
+
expect(codes({ ...valid(), id: 'CONSTRAINT-001' })).toContain('CONST-001');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('validateConstraint — CONST-002/003', () => {
|
|
38
|
+
it('flags invalid status and severity', () => {
|
|
39
|
+
expect(codes({ ...valid(), status: 'enabled' })).toContain('CONST-002');
|
|
40
|
+
expect(codes({ ...valid(), severity: 'critical' })).toContain('CONST-003');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('validateConstraint — CONST-004/005 (catalog)', () => {
|
|
44
|
+
it('flags unresolved applies_to and owner_role with catalog', () => {
|
|
45
|
+
const catalog = { typeOf: () => undefined };
|
|
46
|
+
expect(codes(valid(), { catalog })).toContain('CONST-004');
|
|
47
|
+
expect(codes(valid(), { catalog })).toContain('CONST-005');
|
|
48
|
+
});
|
|
49
|
+
it('does not flag CONST-004 without catalog for well-formed applies_to', () => {
|
|
50
|
+
expect(codes(valid())).not.toContain('CONST-004');
|
|
51
|
+
});
|
|
52
|
+
it('resolves applies_to when catalogue admits the target', () => {
|
|
53
|
+
const catalog = {
|
|
54
|
+
typeOf: (id) => (id === 'APPLICATION-CRM-1' ? 'APPLICATION' : id === 'ROLE-DPO-1' ? 'ROLE' : undefined),
|
|
55
|
+
};
|
|
56
|
+
expect(validateConstraint(valid(), { catalog }).valid).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const CONSTRAINT_STATUSES: readonly ["active", "proposed", "deprecated", "retired"];
|
|
2
|
+
export type ConstraintStatus = (typeof CONSTRAINT_STATUSES)[number];
|
|
3
|
+
export declare const CONSTRAINT_SEVERITIES: readonly ["mandatory", "recommended", "advisory"];
|
|
4
|
+
export type ConstraintSeverity = (typeof CONSTRAINT_SEVERITIES)[number];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ValidationResult } from '../validation-types.js';
|
|
2
|
+
import { type CanonCatalog } from '../typed-id.js';
|
|
3
|
+
export interface ConstraintValidateOptions {
|
|
4
|
+
/** When provided, CONST-004/005 enforce artefact existence for cross-refs. */
|
|
5
|
+
catalog?: CanonCatalog;
|
|
6
|
+
}
|
|
7
|
+
export declare function validateConstraint(input: unknown, options?: ConstraintValidateOptions): ValidationResult;
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
import { typeOfId, isCanonicalIdOfType } from '../typed-id.js';
|
|
4
|
+
import { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
|
|
5
|
+
const REQUIRED_FIELDS = [
|
|
6
|
+
'notation',
|
|
7
|
+
'name',
|
|
8
|
+
'statement',
|
|
9
|
+
'status',
|
|
10
|
+
'zone',
|
|
11
|
+
'admitted_at',
|
|
12
|
+
'admitted_by',
|
|
13
|
+
'gate_checks',
|
|
14
|
+
'valid_from',
|
|
15
|
+
];
|
|
16
|
+
export function validateConstraint(input, options = {}) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const warnings = [];
|
|
19
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
20
|
+
errors.push({ code: 'CONST-001', message: 'Constraint must be a YAML mapping.' });
|
|
21
|
+
return { valid: false, errors, warnings };
|
|
22
|
+
}
|
|
23
|
+
const c = input;
|
|
24
|
+
if (!isCanonicalIdOfType(c.id, 'CONSTRAINT')) {
|
|
25
|
+
errors.push({
|
|
26
|
+
code: 'CONST-001',
|
|
27
|
+
message: `id "${String(c.id)}" must match CONSTRAINT-[<middle>-]<INTEGER>.`,
|
|
28
|
+
path: 'id',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (c.notation !== 'constraint') {
|
|
32
|
+
errors.push({ code: 'CONST-001', message: 'notation must be the fixed value "constraint".', path: 'notation' });
|
|
33
|
+
}
|
|
34
|
+
for (const f of REQUIRED_FIELDS) {
|
|
35
|
+
if (f === 'notation')
|
|
36
|
+
continue;
|
|
37
|
+
if (f === 'gate_checks') {
|
|
38
|
+
if (c.gate_checks === null || typeof c.gate_checks !== 'object') {
|
|
39
|
+
errors.push({ code: 'CONST-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const v = c[f];
|
|
44
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
45
|
+
errors.push({ code: 'CONST-001', message: `${f} is required.`, path: f });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (c.zone !== 'canon') {
|
|
49
|
+
errors.push({ code: 'CONST-001', message: 'zone must be "canon" for a CONSTRAINT.', path: 'zone' });
|
|
50
|
+
}
|
|
51
|
+
if (!('valid_to' in c) || !(typeof c.valid_to === 'string' || c.valid_to === null)) {
|
|
52
|
+
errors.push({ code: 'CONST-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
|
|
53
|
+
}
|
|
54
|
+
const status = typeof c.status === 'string' ? c.status.trim() : '';
|
|
55
|
+
if (status && !CONSTRAINT_STATUSES.includes(status)) {
|
|
56
|
+
errors.push({
|
|
57
|
+
code: 'CONST-002',
|
|
58
|
+
message: `status must be one of ${CONSTRAINT_STATUSES.join(', ')}.`,
|
|
59
|
+
path: 'status',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (c.severity !== undefined) {
|
|
63
|
+
const severity = typeof c.severity === 'string' ? c.severity.trim() : '';
|
|
64
|
+
if (!severity || !CONSTRAINT_SEVERITIES.includes(severity)) {
|
|
65
|
+
errors.push({
|
|
66
|
+
code: 'CONST-003',
|
|
67
|
+
message: `severity must be one of ${CONSTRAINT_SEVERITIES.join(', ')}.`,
|
|
68
|
+
path: 'severity',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (c.applies_to !== undefined) {
|
|
73
|
+
if (!Array.isArray(c.applies_to)) {
|
|
74
|
+
errors.push({ code: 'CONST-001', message: 'applies_to must be a list of typed IDs.', path: 'applies_to' });
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
c.applies_to.forEach((ref, i) => {
|
|
78
|
+
const type = typeOfId(ref);
|
|
79
|
+
if (!type) {
|
|
80
|
+
errors.push({
|
|
81
|
+
code: 'CONST-004',
|
|
82
|
+
message: `applies_to[${i}] "${String(ref)}" is not a resolvable typed ID.`,
|
|
83
|
+
path: `applies_to[${i}]`,
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (options.catalog && options.catalog.typeOf(ref) === undefined) {
|
|
88
|
+
errors.push({
|
|
89
|
+
code: 'CONST-004',
|
|
90
|
+
message: `applies_to[${i}] "${String(ref)}" does not resolve to an admitted artefact.`,
|
|
91
|
+
path: `applies_to[${i}]`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (c.owner_role !== undefined) {
|
|
98
|
+
const role = c.owner_role;
|
|
99
|
+
if (typeof role !== 'string' || !isCanonicalIdOfType(role, 'ROLE')) {
|
|
100
|
+
errors.push({
|
|
101
|
+
code: 'CONST-005',
|
|
102
|
+
message: `owner_role "${String(role)}" must be a typed ROLE id.`,
|
|
103
|
+
path: 'owner_role',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else if (options.catalog && options.catalog.typeOf(role) === undefined) {
|
|
107
|
+
errors.push({
|
|
108
|
+
code: 'CONST-005',
|
|
109
|
+
message: `owner_role "${role}" does not resolve to an admitted artefact.`,
|
|
110
|
+
path: 'owner_role',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
115
|
+
}
|
|
@@ -32,6 +32,29 @@ describe('parseCanonicalFGCA', () => {
|
|
|
32
32
|
expect(r.parsed?.changes).toHaveLength(1);
|
|
33
33
|
expect(r.parsed?.activities).toHaveLength(1);
|
|
34
34
|
});
|
|
35
|
+
it('accepts ACTION-* ids (methodology 1.0) alongside legacy ACTIVITY-*', () => {
|
|
36
|
+
const r = parseCanonicalFGCA({
|
|
37
|
+
...VALID,
|
|
38
|
+
actions: [
|
|
39
|
+
{ id: 'ACTION-CRM-EU-1', name: 'CRM rollout', changes: ['CHANGE-1'] },
|
|
40
|
+
{ id: 'ACTIVITY-2', name: 'Legacy workstream', changes: ['CHANGE-1'] },
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
44
|
+
expect(r.parsed?.activities.map((a) => a.id)).toEqual(['ACTION-CRM-EU-1', 'ACTIVITY-2']);
|
|
45
|
+
});
|
|
46
|
+
it('defaults changes to [] when view_config.layers.changes is off (DGA-in-dgca)', () => {
|
|
47
|
+
const r = parseCanonicalFGCA({
|
|
48
|
+
...VALID,
|
|
49
|
+
changes: undefined,
|
|
50
|
+
view_config: { layers: { changes: 'off' } },
|
|
51
|
+
actions: [
|
|
52
|
+
{ id: 'ACTION-DISCOVERY-1', name: 'Gap assessment', goals: ['GOAL-1'] },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
56
|
+
expect(r.parsed?.changes).toEqual([]);
|
|
57
|
+
});
|
|
35
58
|
it('FGCA-001: rejects non-object input', () => {
|
|
36
59
|
expect(parseCanonicalFGCA(null).errors[0].code).toBe('FGCA-001');
|
|
37
60
|
expect(parseCanonicalFGCA('string').errors[0].code).toBe('FGCA-001');
|
|
@@ -178,6 +201,14 @@ describe('parseCanonicalFGA', () => {
|
|
|
178
201
|
expect(r.parsed?.goals).toHaveLength(1);
|
|
179
202
|
expect(r.parsed?.activities).toHaveLength(1);
|
|
180
203
|
});
|
|
204
|
+
it('accepts ACTION-* ids in FGA mode', () => {
|
|
205
|
+
const r = parseCanonicalFGA({
|
|
206
|
+
...VALID_FGA,
|
|
207
|
+
actions: [{ id: 'ACTION-GDPR-DSR-WORKFLOW-1', name: 'DSR workflow', goals: ['GOAL-1'] }],
|
|
208
|
+
});
|
|
209
|
+
expect(r.valid, JSON.stringify(r.errors)).toBe(true);
|
|
210
|
+
expect(r.parsed?.activities[0].id).toBe('ACTION-GDPR-DSR-WORKFLOW-1');
|
|
211
|
+
});
|
|
181
212
|
it('populates activity.goal_id from activity.goals[] (the FGA edge-driving field)', () => {
|
|
182
213
|
// The "FGA nodes render, no edges" bug: activities had no resolvable
|
|
183
214
|
// 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`.
|
|
@@ -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`.
|
|
@@ -31,7 +31,8 @@ const DRIVER_ID_RE = /^DRIVER(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
|
31
31
|
const FACTOR_OR_DRIVER_ID_RE = /^(FACTOR|DRIVER)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
32
32
|
const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
33
33
|
const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
34
|
-
|
|
34
|
+
// ACTION is canonical since methodology 1.0; ACTIVITY accepted for legacy docs.
|
|
35
|
+
const ACTION_OR_ACTIVITY_ID_RE = /^(ACTION|ACTIVITY)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
35
36
|
const FGCA_DOC_ID_RE = /^(FGCA|DGCA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
36
37
|
const CONSTRAINT_ID_RE = /^CONSTRAINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
|
|
37
38
|
function isNonEmptyString(v) {
|
|
@@ -42,6 +43,16 @@ function asObjectArray(v) {
|
|
|
42
43
|
return null;
|
|
43
44
|
return v.map((el) => (el && typeof el === 'object' ? el : null));
|
|
44
45
|
}
|
|
46
|
+
/** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
|
|
47
|
+
function isChangesLayerOff(raw) {
|
|
48
|
+
const vc = raw['view_config'];
|
|
49
|
+
if (!vc || typeof vc !== 'object')
|
|
50
|
+
return false;
|
|
51
|
+
const layers = vc['layers'];
|
|
52
|
+
if (!layers || typeof layers !== 'object')
|
|
53
|
+
return false;
|
|
54
|
+
return layers['changes'] === 'off';
|
|
55
|
+
}
|
|
45
56
|
/**
|
|
46
57
|
* Validate canonical FGCA YAML and, on success, return an internal-form
|
|
47
58
|
* `FGCADoc` ready for `buildFGCALayout`.
|
|
@@ -87,7 +98,10 @@ export function parseCanonicalFGCA(input) {
|
|
|
87
98
|
}
|
|
88
99
|
const factorsRaw = asObjectArray(raw['factors']);
|
|
89
100
|
const goalsRaw = asObjectArray(raw['goals']);
|
|
90
|
-
|
|
101
|
+
let changesRaw = asObjectArray(raw['changes']);
|
|
102
|
+
if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
|
|
103
|
+
changesRaw = [];
|
|
104
|
+
}
|
|
91
105
|
const activitiesRaw = asObjectArray(raw['actions']);
|
|
92
106
|
if (activitiesRaw === null && 'activities' in raw) {
|
|
93
107
|
errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
|
|
@@ -145,7 +159,7 @@ export function parseCanonicalFGCA(input) {
|
|
|
145
159
|
factors.forEach((el, i) => checkLayerElement(el, `factors[${i}]`, FACTOR_OR_DRIVER_ID_RE, factorIds));
|
|
146
160
|
goals.forEach((el, i) => checkLayerElement(el, `goals[${i}]`, GOAL_ID_RE, goalIds));
|
|
147
161
|
changes.forEach((el, i) => checkLayerElement(el, `changes[${i}]`, CHANGE_ID_RE, changeIds));
|
|
148
|
-
activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`,
|
|
162
|
+
activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
|
|
149
163
|
if (errors.length > 0)
|
|
150
164
|
return { valid: false, errors, warnings };
|
|
151
165
|
// Cross-refs.
|
package/dist/goals/GoalNode.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { Goal } from './types.js';
|
|
2
|
+
import type { Factor, Goal } from './types.js';
|
|
3
3
|
import type { ThemeTokens } from './theme.js';
|
|
4
4
|
export interface GoalNodeData {
|
|
5
5
|
goal: Goal;
|
|
@@ -11,6 +11,7 @@ export interface GoalNodeData {
|
|
|
11
11
|
onAddChild: (parentId: number, parentLevel: number) => void;
|
|
12
12
|
onDelete: (id: number) => void;
|
|
13
13
|
onToggleCollapse: (id: number) => void;
|
|
14
|
+
onFactorClick?: (factor: Factor) => void;
|
|
14
15
|
}
|
|
15
16
|
declare const GoalNode: React.MemoExoticComponent<({ data }: {
|
|
16
17
|
data: GoalNodeData;
|
package/dist/goals/GoalNode.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useState } from 'react';
|
|
3
3
|
import { Handle, Position } from 'reactflow';
|
|
4
|
+
import { displayGoalId } from './parse-canonical.js';
|
|
4
5
|
const NODE_WIDTH = 250;
|
|
5
6
|
const NODE_HEIGHT = 80;
|
|
6
7
|
function impactSign(f) {
|
|
@@ -44,7 +45,7 @@ const GoalNode = memo(({ data }) => {
|
|
|
44
45
|
WebkitLineClamp: 2,
|
|
45
46
|
WebkitBoxOrient: 'vertical',
|
|
46
47
|
wordBreak: 'break-word',
|
|
47
|
-
}, children: goal.name }), _jsxs("div", { style: { fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }, children: ["ID: ", goal
|
|
48
|
+
}, children: goal.name }), _jsxs("div", { style: { fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }, children: ["ID: ", displayGoalId(goal), " | Lvl: ", goal.level, goal.tag ? ` | #${goal.tag}` : ''] })] }), _jsx("div", { style: { flexShrink: 0, display: 'flex', alignItems: 'center', width: 16, justifyContent: 'flex-end' }, children: negative.length > 0 && (_jsxs("span", { role: "button", "aria-label": `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, title: `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, onClick: (e) => {
|
|
48
49
|
e.stopPropagation();
|
|
49
50
|
setOpenPanel(openPanel === 'negative' ? null : 'negative');
|
|
50
51
|
}, style: { cursor: 'pointer', color: '#dc2626', fontSize: 14, lineHeight: 1, userSelect: 'none' }, children: ["\u25BC", negative.length > 1 && _jsx("span", { style: { fontSize: 9, fontWeight: 700, marginLeft: 2 }, children: negative.length })] })) }), openPanel && (_jsx("div", { style: {
|
|
@@ -56,7 +57,10 @@ const GoalNode = memo(({ data }) => {
|
|
|
56
57
|
flexDirection: 'column',
|
|
57
58
|
gap: 4,
|
|
58
59
|
...(openPanel === 'positive' ? { bottom: 'calc(100% + 8px)' } : { top: 'calc(100% + 8px)' }),
|
|
59
|
-
}, children: (openPanel === 'positive' ? positive : negative).map((f) => (_jsxs("div", {
|
|
60
|
+
}, children: (openPanel === 'positive' ? positive : negative).map((f) => (_jsxs("div", { onClick: (e) => {
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
data.onFactorClick?.(f);
|
|
63
|
+
}, style: {
|
|
60
64
|
background: openPanel === 'positive' ? '#f0fdf4' : '#fef2f2',
|
|
61
65
|
border: `1px solid ${openPanel === 'positive' ? '#bbf7d0' : '#fecaca'}`,
|
|
62
66
|
color: openPanel === 'positive' ? '#166534' : '#991b1b',
|
|
@@ -64,6 +68,7 @@ const GoalNode = memo(({ data }) => {
|
|
|
64
68
|
padding: '4px 6px',
|
|
65
69
|
fontSize: 10,
|
|
66
70
|
textAlign: 'left',
|
|
71
|
+
cursor: data.onFactorClick ? 'pointer' : 'default',
|
|
67
72
|
}, children: [_jsx("div", { style: { fontWeight: 700 }, children: f.name }), f.description && _jsx("div", { style: { opacity: 0.85 }, children: f.description })] }, f.id))) })), !readOnly && (_jsx("button", { type: "button", "aria-label": "Add child goal", title: "Add child goal", onClick: (e) => {
|
|
68
73
|
e.stopPropagation();
|
|
69
74
|
data.onAddChild(goal.id, goal.level);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import 'reactflow/dist/style.css';
|
|
3
|
-
import type { Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
|
|
3
|
+
import type { Factor, Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
|
|
4
4
|
import type { ThemeTokens } from './theme.js';
|
|
5
5
|
export type { ThemeTokens } from './theme.js';
|
|
6
6
|
export interface GoalTreeViewProps {
|
|
@@ -10,9 +10,11 @@ export interface GoalTreeViewProps {
|
|
|
10
10
|
theme?: ThemeTokens;
|
|
11
11
|
readOnly?: boolean;
|
|
12
12
|
showBacklog?: boolean;
|
|
13
|
+
defaultBacklogOpen?: boolean;
|
|
13
14
|
showMiniMap?: boolean;
|
|
14
15
|
onChange?: (event: GoalTreeChange) => void;
|
|
15
16
|
onEditRequest?: (goal: Goal) => void;
|
|
17
|
+
onFactorClick?: (factor: Factor) => void;
|
|
16
18
|
}
|
|
17
19
|
export type GoalTreeChange = {
|
|
18
20
|
kind: 'reparent';
|
|
@@ -61,10 +61,11 @@ function partitionCanvasAndBacklog(goals) {
|
|
|
61
61
|
}
|
|
62
62
|
return { canvas, backlog };
|
|
63
63
|
}
|
|
64
|
-
function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: themeProp, readOnly = false, showBacklog = false, showMiniMap = false, onChange, onEditRequest, }) {
|
|
64
|
+
function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: themeProp, readOnly = false, showBacklog = false, defaultBacklogOpen = true, showMiniMap = false, onChange, onEditRequest, onFactorClick, }) {
|
|
65
65
|
const theme = useMemo(() => resolveTheme(themeProp), [themeProp]);
|
|
66
66
|
const [collapsedIds, setCollapsedIds] = useState(new Set());
|
|
67
67
|
const [hoveredId, setHoveredId] = useState(null);
|
|
68
|
+
const [isBacklogOpen, setIsBacklogOpen] = useState(defaultBacklogOpen);
|
|
68
69
|
const { canvas: canvasGoals, backlog: backlogGoals } = useMemo(() => partitionCanvasAndBacklog(tree.goals), [tree.goals]);
|
|
69
70
|
const layout = useMemo(() => {
|
|
70
71
|
if (layoutProp)
|
|
@@ -126,9 +127,10 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
|
|
|
126
127
|
onAddChild,
|
|
127
128
|
onDelete: onDeleteNode,
|
|
128
129
|
onToggleCollapse,
|
|
130
|
+
onFactorClick,
|
|
129
131
|
},
|
|
130
132
|
}));
|
|
131
|
-
}, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse]);
|
|
133
|
+
}, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse, onFactorClick]);
|
|
132
134
|
const buildEdges = useCallback(() => {
|
|
133
135
|
return layout.edges.map((e) => ({
|
|
134
136
|
id: `e${e.source}-${e.target}`,
|
|
@@ -206,22 +208,37 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
|
|
|
206
208
|
onRestoreFromBacklog(goalId, Number(targetIdAttr));
|
|
207
209
|
}, [readOnly, onRestoreFromBacklog]);
|
|
208
210
|
const showBacklogPanel = showBacklog && backlogGoals.length > 0;
|
|
209
|
-
return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%' }, children: [showBacklogPanel && (_jsxs("div", {
|
|
210
|
-
width: 220,
|
|
211
|
+
return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%' }, children: [showBacklogPanel && (_jsxs("div", { ...(isBacklogOpen ? { 'data-goaltree-backlog-zone': true } : {}), style: {
|
|
212
|
+
width: isBacklogOpen ? 220 : 28,
|
|
211
213
|
flexShrink: 0,
|
|
212
214
|
borderRight: '1px solid #e2e8f0',
|
|
213
|
-
padding: 10,
|
|
214
|
-
overflowY: 'auto',
|
|
215
215
|
background: '#f8fafc',
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
display: 'flex',
|
|
217
|
+
flexDirection: 'column',
|
|
218
|
+
position: 'relative',
|
|
219
|
+
}, children: [_jsx("button", { type: "button", "aria-label": isBacklogOpen ? 'Collapse backlog' : 'Expand backlog', title: isBacklogOpen ? 'Collapse backlog' : 'Expand backlog', onClick: () => setIsBacklogOpen((v) => !v), style: {
|
|
220
|
+
alignSelf: 'flex-end',
|
|
221
|
+
margin: '6px 6px 0 6px',
|
|
222
|
+
width: 16,
|
|
223
|
+
height: 16,
|
|
220
224
|
border: '1px solid #cbd5e1',
|
|
221
|
-
borderRadius:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
+
borderRadius: 4,
|
|
226
|
+
background: '#ffffff',
|
|
227
|
+
color: '#64748b',
|
|
228
|
+
fontSize: 10,
|
|
229
|
+
lineHeight: '14px',
|
|
230
|
+
padding: 0,
|
|
231
|
+
cursor: 'pointer',
|
|
232
|
+
flexShrink: 0,
|
|
233
|
+
}, children: isBacklogOpen ? '◀' : '▶' }), isBacklogOpen && (_jsxs("div", { style: { padding: '4px 10px 10px', overflowY: 'auto', flex: 1 }, children: [_jsx("div", { style: { fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }, children: "Backlog" }), backlogGoals.map((goal) => (_jsxs("div", { draggable: !readOnly, onDragStart: (e) => onBacklogItemDragStart(e, goal.id), onDoubleClick: () => onEditRequest?.(goal), title: "Drag onto a canvas card to set its parent", style: {
|
|
234
|
+
padding: 8,
|
|
235
|
+
marginBottom: 6,
|
|
236
|
+
background: '#ffffff',
|
|
237
|
+
border: '1px solid #cbd5e1',
|
|
238
|
+
borderRadius: 6,
|
|
239
|
+
fontSize: 11,
|
|
240
|
+
cursor: readOnly ? 'default' : 'grab',
|
|
241
|
+
}, children: [_jsx("div", { style: { fontWeight: 600 }, children: goal.name }), _jsxs("div", { style: { color: '#64748b' }, children: ["Level: ", goal.level] })] }, goal.id)))] }))] })), _jsx("div", { style: { flex: 1, position: 'relative' }, onDragOver: onCanvasDragOver, onDrop: onCanvasDrop, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, onNodesChange: readOnly ? undefined : onNodesChange, onEdgesChange: readOnly ? undefined : onEdgesChange, nodeTypes: nodeTypes, nodesDraggable: !readOnly, nodesConnectable: false, onNodeDrag: onNodeDrag, onNodeDragStop: onNodeDragStop, onNodeDoubleClick: onNodeDoubleClick, connectionLineType: ConnectionLineType.Bezier, fitView: true, children: [_jsx(Background, {}), _jsx(Controls, {}), showMiniMap && _jsx(MiniMap, { pannable: true, zoomable: true })] }) })] }));
|
|
225
242
|
}
|
|
226
243
|
export function GoalTreeView(props) {
|
|
227
244
|
return _jsx(GoalTreeViewInner, { ...props });
|
|
@@ -281,6 +281,47 @@ describe('GoalTreeView', () => {
|
|
|
281
281
|
expect(computeDragOutcome(deeper, 3, { kind: 'node', id: 4 })).toBeNull();
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
it('fires onFactorClick with the clicked factor when a popover item is clicked', async () => {
|
|
285
|
+
const onFactorClick = vi.fn();
|
|
286
|
+
render(_jsx(GoalTreeView, { tree: makeTree(), onFactorClick: onFactorClick }));
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(screen.getByText('Cut churn')).toBeTruthy();
|
|
289
|
+
});
|
|
290
|
+
// Open the negative-factor panel on "Cut churn" (id=3, has risk factor "Support backlog")
|
|
291
|
+
const negativeBadge = screen.getByLabelText('1 negative factor');
|
|
292
|
+
negativeBadge.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
293
|
+
// The popover item for the risk factor should now be in the DOM
|
|
294
|
+
const factorItem = await waitFor(() => screen.getByText('Support backlog'));
|
|
295
|
+
factorItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
296
|
+
await waitFor(() => expect(onFactorClick).toHaveBeenCalledTimes(1));
|
|
297
|
+
expect(onFactorClick.mock.calls[0][0]).toMatchObject({ id: 10, name: 'Support backlog', impact_type: 'risk' });
|
|
298
|
+
});
|
|
299
|
+
it('backlog panel is collapsible — toggle hides and restores content', async () => {
|
|
300
|
+
const tree = {
|
|
301
|
+
goal_types: [
|
|
302
|
+
{ name: 'Strategy', level: 0 },
|
|
303
|
+
{ name: 'Business Goal', level: 1 },
|
|
304
|
+
],
|
|
305
|
+
goals: [
|
|
306
|
+
{ id: 1, name: 'Root goal', type: 'Strategy', level: 0, parent_id: 0 },
|
|
307
|
+
{ id: 2, name: 'Orphaned goal', type: 'Business Goal', level: 1, parent_id: 999 },
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
render(_jsx(GoalTreeView, { tree: tree, showBacklog: true }));
|
|
311
|
+
await waitFor(() => {
|
|
312
|
+
expect(screen.getByText('Backlog')).toBeTruthy();
|
|
313
|
+
});
|
|
314
|
+
// Collapse
|
|
315
|
+
screen.getByLabelText('Collapse backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
316
|
+
await waitFor(() => {
|
|
317
|
+
expect(screen.queryByText('Backlog')).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
// Expand again
|
|
320
|
+
screen.getByLabelText('Expand backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(screen.getByText('Backlog')).toBeTruthy();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
284
325
|
it('fires onEditRequest on double-click', async () => {
|
|
285
326
|
const onEditRequest = vi.fn();
|
|
286
327
|
render(_jsx(GoalTreeView, { tree: makeTree(), onEditRequest: onEditRequest }));
|
|
@@ -93,3 +93,42 @@ describe('restoreFromBacklog', () => {
|
|
|
93
93
|
expect(r.result.goals.find(g => g.id === 3).parent_id).toBe(1);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
describe('reparent — descendant type relabeling and level cap', () => {
|
|
97
|
+
const DEEP = {
|
|
98
|
+
goal_types: [
|
|
99
|
+
{ name: 'Strategy', level: 0 },
|
|
100
|
+
{ name: 'Business Goal', level: 1 },
|
|
101
|
+
{ name: 'Project', level: 2 },
|
|
102
|
+
{ name: 'Task', level: 3 },
|
|
103
|
+
],
|
|
104
|
+
goals: [
|
|
105
|
+
{ id: 1, name: 'Root', type: 'Strategy', level: 0, parent_id: 0 },
|
|
106
|
+
{ id: 2, name: 'BG1', type: 'Business Goal', level: 1, parent_id: 1 },
|
|
107
|
+
{ id: 3, name: 'Proj1', type: 'Project', level: 2, parent_id: 2 },
|
|
108
|
+
{ id: 4, name: 'Task1', type: 'Task', level: 3, parent_id: 3 },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
it('relabels descendant types via getTypeForLevel when reparenting shifts levels', () => {
|
|
112
|
+
// Move Proj1 (id=3, level=2) directly under Root (id=1, level=0); delta = -1
|
|
113
|
+
// Task1 (child of Proj1, level=3): new level = 2, type should be 'Project'
|
|
114
|
+
const r = reparent(DEEP, 3, 1);
|
|
115
|
+
expect(r.ok).toBe(true);
|
|
116
|
+
const proj = r.result.goals.find(g => g.id === 3);
|
|
117
|
+
const task = r.result.goals.find(g => g.id === 4);
|
|
118
|
+
expect(proj.level).toBe(1);
|
|
119
|
+
expect(proj.type).toBe('Business Goal');
|
|
120
|
+
expect(task.level).toBe(2);
|
|
121
|
+
expect(task.type).toBe('Project');
|
|
122
|
+
});
|
|
123
|
+
it('caps descendant level at maxLevel and refuses when that violates GOALS-012', () => {
|
|
124
|
+
// Move Root (id=1, level=0) under Root2 (id=5, level=0); delta = +1
|
|
125
|
+
// BG1: 1→2, Proj1: 2→3 (maxLevel), Task1: 3→4, capped at 3
|
|
126
|
+
// Task1 (capped level=3) under Proj1 (level=3) violates GOALS-012 → refused
|
|
127
|
+
const tree = {
|
|
128
|
+
...DEEP,
|
|
129
|
+
goals: [...DEEP.goals, { id: 5, name: 'Root2', type: 'Strategy', level: 0, parent_id: 0 }],
|
|
130
|
+
};
|
|
131
|
+
const r = reparent(tree, 1, 5);
|
|
132
|
+
expect(r.ok).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -107,8 +107,10 @@ describe('parseCanonicalGoals', () => {
|
|
|
107
107
|
it('maps canonical IDs to internal numeric ids; parent_id is the parent\'s mapped id', () => {
|
|
108
108
|
const r = parseCanonicalGoals(VALID);
|
|
109
109
|
expect(r.parsed?.goals[0].id).toBe(1);
|
|
110
|
+
expect(r.parsed?.goals[0].canonical_id).toBe('GOAL-1');
|
|
110
111
|
expect(r.parsed?.goals[0].parent_id).toBe(0);
|
|
111
112
|
expect(r.parsed?.goals[1].id).toBe(2);
|
|
113
|
+
expect(r.parsed?.goals[1].canonical_id).toBe('GOAL-2');
|
|
112
114
|
expect(r.parsed?.goals[1].parent_id).toBe(1);
|
|
113
115
|
});
|
|
114
116
|
});
|
package/dist/goals/layout.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { displayGoalId } from './parse-canonical.js';
|
|
1
2
|
const DEFAULT_NODE_WIDTH = 250;
|
|
2
3
|
const DEFAULT_NODE_HEIGHT = 80;
|
|
3
4
|
const DEFAULT_RANK_SEP = 80;
|
|
@@ -21,7 +22,7 @@ export function selectScopedGoals(goals, scope) {
|
|
|
21
22
|
children.set(g.parent_id, []);
|
|
22
23
|
children.get(g.parent_id).push(g.id);
|
|
23
24
|
}
|
|
24
|
-
const root = goals.find(g =>
|
|
25
|
+
const root = goals.find(g => displayGoalId(g) === scope.rootGoalId);
|
|
25
26
|
if (!root)
|
|
26
27
|
return [];
|
|
27
28
|
const keep = new Set();
|
package/dist/goals/mutations.js
CHANGED
|
@@ -16,12 +16,12 @@ function getTypeForLevel(tree, level) {
|
|
|
16
16
|
const gt = tree.goal_types.find(t => t.level === level);
|
|
17
17
|
return gt?.name ?? '';
|
|
18
18
|
}
|
|
19
|
-
function updateDescendantLevels(
|
|
20
|
-
for (const g of goals) {
|
|
19
|
+
function updateDescendantLevels(clone, id, levelDelta, maxLevel) {
|
|
20
|
+
for (const g of clone.goals) {
|
|
21
21
|
if (g.parent_id === id) {
|
|
22
|
-
g.level
|
|
23
|
-
g.type =
|
|
24
|
-
updateDescendantLevels(
|
|
22
|
+
g.level = Math.min(g.level + levelDelta, maxLevel);
|
|
23
|
+
g.type = getTypeForLevel(clone, g.level);
|
|
24
|
+
updateDescendantLevels(clone, g.id, levelDelta, maxLevel);
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -67,7 +67,7 @@ export function reparent(tree, sourceId, targetId) {
|
|
|
67
67
|
source.level = newLevel;
|
|
68
68
|
source.type = getTypeForLevel(clone, newLevel);
|
|
69
69
|
if (levelDelta !== 0)
|
|
70
|
-
updateDescendantLevels(clone
|
|
70
|
+
updateDescendantLevels(clone, sourceId, levelDelta, maxLevel);
|
|
71
71
|
const v = validateGoalTree(clone);
|
|
72
72
|
if (!v.valid)
|
|
73
73
|
return refusalError(v.errors[0]?.message ?? 'Validation failed after reparent');
|