@peter.prib/renderengine 0.2.11 → 0.4.0
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 +22 -6
- package/REFERENCE.md +117 -2
- package/dist/renderengine-workspace.umd.js +2973 -1203
- package/dist/renderengine.umd.js +1178 -158
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerExport.js +273 -0
- package/src/charting/requirements/index.js +7 -0
- package/src/index.js +6 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
import { Command } from '../CommandHistory.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the model as it was before and after (
|
|
7
|
-
* puts the model back as it was (
|
|
8
|
-
* undoable this way, whatever it touches.
|
|
9
|
-
*/
|
|
10
|
-
export class
|
|
11
|
-
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
12
|
-
viewId = null;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* @param {
|
|
16
|
-
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
17
|
-
* @param {(model:
|
|
18
|
-
*/
|
|
19
|
-
constructor(model, label, change) {
|
|
20
|
-
super(label);
|
|
21
|
-
this.model = model;
|
|
22
|
-
this.change = change;
|
|
23
|
-
this.before = null;
|
|
24
|
-
this.after = null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
execute() {
|
|
28
|
-
if (this.after) {
|
|
29
|
-
this.model.restore(this.after);
|
|
30
|
-
} else {
|
|
31
|
-
this.before =
|
|
32
|
-
this.result = this.change(this.model);
|
|
33
|
-
this.after =
|
|
34
|
-
}
|
|
35
|
-
this.model.edited();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
undo() {
|
|
39
|
-
this.model.restore(this.before);
|
|
40
|
-
this.model.edited();
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The model as it is, a copy nothing later changes (its toJSON
|
|
44
|
-
static snapshot(model) {
|
|
45
|
-
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
import { Command } from '../CommandHistory.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ModelEditCommand — one edit of an EditableModel (a data model's entity, a
|
|
5
|
+
* requirements model's story, a delete) as one undo step: run, it makes the
|
|
6
|
+
* change and keeps the model as it was before and after (its toJSON); undone
|
|
7
|
+
* or redone, it puts the model back as it was (its restore). Any change to a
|
|
8
|
+
* model is undoable this way, whatever it touches.
|
|
9
|
+
*/
|
|
10
|
+
export class ModelEditCommand extends Command {
|
|
11
|
+
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
12
|
+
viewId = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {EditableModel} model
|
|
16
|
+
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
17
|
+
* @param {(model: EditableModel) => *} change - Makes the change
|
|
18
|
+
*/
|
|
19
|
+
constructor(model, label, change) {
|
|
20
|
+
super(label);
|
|
21
|
+
this.model = model;
|
|
22
|
+
this.change = change;
|
|
23
|
+
this.before = null;
|
|
24
|
+
this.after = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
execute() {
|
|
28
|
+
if (this.after) {
|
|
29
|
+
this.model.restore(this.after);
|
|
30
|
+
} else {
|
|
31
|
+
this.before = ModelEditCommand.snapshot(this.model);
|
|
32
|
+
this.result = this.change(this.model);
|
|
33
|
+
this.after = ModelEditCommand.snapshot(this.model);
|
|
34
|
+
}
|
|
35
|
+
this.model.edited();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
undo() {
|
|
39
|
+
this.model.restore(this.before);
|
|
40
|
+
this.model.edited();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
|
|
44
|
+
static snapshot(model) {
|
|
45
|
+
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ModelChecker } from '../ModelChecker.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RequirementsChecker — what a requirements model is missing, as a team
|
|
5
|
+
* reviewing a backlog would say it: a story with no acceptance criteria
|
|
6
|
+
* (nothing to say when it is done), one that says neither who wants it nor
|
|
7
|
+
* what (no "As a … I want …"), one that traces to nothing (nothing realises
|
|
8
|
+
* it), one under no epic, an epic with no stories, two stories of one name.
|
|
9
|
+
*
|
|
10
|
+
* With `resolve(trace)` — the host's way to find what a trace names in its
|
|
11
|
+
* other models (true, false, or undefined when it cannot tell) — a trace to
|
|
12
|
+
* something that is not there is found too.
|
|
13
|
+
*
|
|
14
|
+
* Findings name a story or an epic (`kind`), as ModelChecker's do.
|
|
15
|
+
*/
|
|
16
|
+
export class RequirementsChecker extends ModelChecker {
|
|
17
|
+
/**
|
|
18
|
+
* @param {RequirementsModel} model
|
|
19
|
+
* @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
|
|
20
|
+
*/
|
|
21
|
+
constructor(model, { resolve = null } = {}) {
|
|
22
|
+
super(model);
|
|
23
|
+
this.resolve = resolve;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
checks() {
|
|
27
|
+
return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @private */
|
|
31
|
+
_story(story, code, severity, message) {
|
|
32
|
+
return { code, severity, kind: 'story', ids: [story.id], message };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
noCriteria() {
|
|
36
|
+
return this.model.stories.filter(s => !s.criteria.length)
|
|
37
|
+
.map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
noNarrative() {
|
|
41
|
+
return this.model.stories.filter(s => !s.want && !s.description)
|
|
42
|
+
.map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
untraced() {
|
|
46
|
+
return this.model.stories.filter(s => !s.traces.length)
|
|
47
|
+
.map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
brokenTraces() {
|
|
51
|
+
if (!this.resolve) return [];
|
|
52
|
+
return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
|
|
53
|
+
.map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
noEpic() {
|
|
57
|
+
if (!this.model.epics.length) return [];
|
|
58
|
+
return this.model.stories.filter(s => !this.model.epic(s.epic))
|
|
59
|
+
.map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emptyEpics() {
|
|
63
|
+
return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
|
|
64
|
+
.map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sameNames() {
|
|
68
|
+
const byName = new Map();
|
|
69
|
+
for (const s of this.model.stories) {
|
|
70
|
+
const key = s.name.trim().toLowerCase();
|
|
71
|
+
if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
|
|
72
|
+
}
|
|
73
|
+
return [...byName.values()].filter(list => list.length > 1)
|
|
74
|
+
.map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ListCompared } from '../diagramModel/ListCompared.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The kinds two RequirementsModels are compared on and merged by
|
|
5
|
+
* (ModelComparison): epics, releases, then stories, in the order a merge
|
|
6
|
+
* brings them — a story needs its epic and its release.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class EpicsCompared extends ListCompared {
|
|
10
|
+
get key() { return 'epic'; }
|
|
11
|
+
get label() { return 'Epics'; }
|
|
12
|
+
list(model) { return model.epics; }
|
|
13
|
+
content(epic) { return { ...epic }; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ReleasesCompared extends ListCompared {
|
|
17
|
+
get key() { return 'release'; }
|
|
18
|
+
get label() { return 'Releases'; }
|
|
19
|
+
list(model) { return model.releases; }
|
|
20
|
+
content(release) { return { ...release }; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Stories: compared on everything, where they sit in the list aside. */
|
|
24
|
+
export class StoriesCompared extends ListCompared {
|
|
25
|
+
get key() { return 'story'; }
|
|
26
|
+
get label() { return 'Stories'; }
|
|
27
|
+
list(model) { return model.stories; }
|
|
28
|
+
content(story) { return JSON.parse(JSON.stringify(story)); }
|
|
29
|
+
|
|
30
|
+
requires(story) {
|
|
31
|
+
return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ModelFileFormat } from '../ModelFileFormat.js';
|
|
2
|
+
import { RequirementsModel } from './RequirementsModel.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RequirementsFileFormat — a requirements model as a file
|
|
6
|
+
* (`.requirements.json`): its epics, releases and stories
|
|
7
|
+
* (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
|
|
8
|
+
*/
|
|
9
|
+
export class RequirementsFileFormat extends ModelFileFormat {
|
|
10
|
+
get label() { return '.requirements.json'; }
|
|
11
|
+
get accept() { return '.requirements.json,.json'; }
|
|
12
|
+
get fileName() { return 'model.requirements.json'; }
|
|
13
|
+
get featureName() { return 'Requirements'; }
|
|
14
|
+
|
|
15
|
+
/** JSON with a requirements model's shape: stories, or epics. */
|
|
16
|
+
recognizes(text) {
|
|
17
|
+
try {
|
|
18
|
+
const json = JSON.parse(text);
|
|
19
|
+
return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export(model) {
|
|
26
|
+
return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
import(text) {
|
|
30
|
+
return new RequirementsModel(JSON.parse(text));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ModelFileFormat } from '../ModelFileFormat.js';
|
|
2
|
+
import { RequirementsModel } from './RequirementsModel.js';
|
|
3
|
+
import { TrackerExport } from './TrackerExport.js';
|
|
4
|
+
import { Storyboard } from './Storyboard.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The files a requirements model is read from that another tool wrote — they
|
|
8
|
+
* import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
|
|
9
|
+
* GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
|
|
10
|
+
* the file as a new model; `spec(text)` is what it holds, for a part that
|
|
11
|
+
* brings it into the model open (RequirementsModel#bringCommand).
|
|
12
|
+
*/
|
|
13
|
+
class RequirementsImportFormat extends ModelFileFormat {
|
|
14
|
+
get exports() { return false; }
|
|
15
|
+
|
|
16
|
+
/** What the file holds, as a RequirementsModel spec. @abstract */
|
|
17
|
+
spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
|
|
18
|
+
|
|
19
|
+
import(text) {
|
|
20
|
+
return new RequirementsModel(this.spec(text));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class TrackerExportFileFormat extends RequirementsImportFormat {
|
|
25
|
+
get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
|
|
26
|
+
get accept() { return '.csv,.json'; }
|
|
27
|
+
get featureName() { return 'TrackerExport'; }
|
|
28
|
+
|
|
29
|
+
/** A CSV or JSON export one of the trackers wrote. */
|
|
30
|
+
recognizes(text) {
|
|
31
|
+
return !!TrackerExport.detect(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
spec(text) {
|
|
35
|
+
const { tracker, ...spec } = new TrackerExport().read(text);
|
|
36
|
+
return { name: `${TrackerExport.label(tracker)} export`, ...spec };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class StoryboardFileFormat extends RequirementsImportFormat {
|
|
41
|
+
get label() { return 'Storyboard (Markdown)'; }
|
|
42
|
+
get accept() { return '.md,.markdown,.txt'; }
|
|
43
|
+
get featureName() { return 'Storyboard'; }
|
|
44
|
+
|
|
45
|
+
spec(text) {
|
|
46
|
+
return new Storyboard().read(text);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { EditableModel } from '../model/EditableModel.js';
|
|
2
|
+
import { EpicsCompared, ReleasesCompared, StoriesCompared } from './RequirementsCompared.js';
|
|
3
|
+
import { RequirementsChecker } from './RequirementsChecker.js';
|
|
4
|
+
import { Benchmark } from '../model/Benchmark.js';
|
|
5
|
+
|
|
6
|
+
/** A plain copy of JSON-shaped data. */
|
|
7
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a story can trace to, and what its trace is called: ArchiMate's
|
|
11
|
+
* motivation and implementation elements, any other element of an
|
|
12
|
+
* architecture, and a data model's concepts, entities and tables — so a trace
|
|
13
|
+
* runs epic → story → requirement → component → entity → table. A trace's
|
|
14
|
+
* `kind` is any string; these are the ones offered.
|
|
15
|
+
*/
|
|
16
|
+
const TRACE_KINDS = Object.freeze([
|
|
17
|
+
['requirement', 'Requirement (ArchiMate)'],
|
|
18
|
+
['work-package', 'Work package (ArchiMate)'],
|
|
19
|
+
['element', 'Architecture element'],
|
|
20
|
+
['concept', 'Data concept'],
|
|
21
|
+
['entity', 'Data entity'],
|
|
22
|
+
['table', 'Table'],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Where a story is in its life, in order; a status is any string, these are offered. */
|
|
26
|
+
const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RequirementsModel — what a system is to do, as an agile team writes it:
|
|
30
|
+
* **epics** (large outcomes), the **stories** that deliver them ("As a … I
|
|
31
|
+
* want … so that …"), each with its **acceptance criteria**, sliced into
|
|
32
|
+
* **releases**; and **traces** from a story to what realises it in the other
|
|
33
|
+
* models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
|
|
34
|
+
* a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
|
|
35
|
+
* model's id).
|
|
36
|
+
*
|
|
37
|
+
* Read as a **story map** (`storyMap()`): epics across in order, releases
|
|
38
|
+
* down, each cell the stories of that epic in that release; stories with no
|
|
39
|
+
* epic or no release have a column and a row of their own. `coverage()` and
|
|
40
|
+
* the checker (RequirementsChecker) say what is missing — a story with no
|
|
41
|
+
* acceptance criteria, nothing realising it — the numbers a bench measures.
|
|
42
|
+
*
|
|
43
|
+
* Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
|
|
44
|
+
* [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
|
|
45
|
+
* description?, criteria: [{id, text}], status?, priority?, points?, labels?,
|
|
46
|
+
* source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
|
|
47
|
+
* Ids are made from names when not given. Every edit is a command (one undo
|
|
48
|
+
* step); compare and merge, and changed-since-saved, are EditableModel's.
|
|
49
|
+
*/
|
|
50
|
+
export class RequirementsModel extends EditableModel {
|
|
51
|
+
static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
|
|
52
|
+
|
|
53
|
+
static TRACE_KINDS = TRACE_KINDS;
|
|
54
|
+
|
|
55
|
+
static STATUSES = STATUSES;
|
|
56
|
+
|
|
57
|
+
/** A story map's column for stories with no epic, and its row for those in no release. */
|
|
58
|
+
static NO_EPIC = '';
|
|
59
|
+
static NO_RELEASE = '';
|
|
60
|
+
|
|
61
|
+
constructor(spec = {}) {
|
|
62
|
+
super(spec);
|
|
63
|
+
this.epics = [];
|
|
64
|
+
this.releases = [];
|
|
65
|
+
this.stories = [];
|
|
66
|
+
for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
|
|
67
|
+
for (const r of spec.releases ?? []) this.releases.push(this._release(r));
|
|
68
|
+
for (const s of spec.stories ?? []) this.stories.push(this._story(s));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ===== Reading =====
|
|
72
|
+
|
|
73
|
+
epic(id) { return this.epics.find(e => e.id === id) ?? null; }
|
|
74
|
+
release(id) { return this.releases.find(r => r.id === id) ?? null; }
|
|
75
|
+
story(id) { return this.stories.find(s => s.id === id) ?? null; }
|
|
76
|
+
|
|
77
|
+
/** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
|
|
78
|
+
storiesOf(epicId, release) {
|
|
79
|
+
return this.stories.filter(s => (s.epic || '') === (epicId || '')
|
|
80
|
+
&& (release === undefined || (s.release || '') === (release || '')));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The story's sentence: "As a … I want … so that …", from what it has. */
|
|
84
|
+
static narrative(story) {
|
|
85
|
+
return [story.as && `As ${/^(a|an|the)\b/i.test(story.as) ? '' : 'a '}${story.as}`, story.want && `I want ${story.want}`, story.soThat && `so that ${story.soThat}`]
|
|
86
|
+
.filter(Boolean).join(', ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The story map: `columns` the epics in order (then NO_EPIC, if any story
|
|
91
|
+
* has none), `rows` the releases in order (then NO_RELEASE, if any story is
|
|
92
|
+
* in none, or there are no releases), and `cell(epicId, releaseId)` the
|
|
93
|
+
* stories there, in order.
|
|
94
|
+
*/
|
|
95
|
+
storyMap() {
|
|
96
|
+
const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
|
|
97
|
+
if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
|
|
98
|
+
const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
|
|
99
|
+
if (!rows.length || this.stories.some(s => !this.release(s.release))) rows.push({ id: RequirementsModel.NO_RELEASE, name: this.releases.length ? 'Not planned' : 'Stories', release: null });
|
|
100
|
+
const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
|
|
101
|
+
const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
|
|
102
|
+
return {
|
|
103
|
+
columns, rows,
|
|
104
|
+
cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
|
|
109
|
+
tracingTo(ref, kind) {
|
|
110
|
+
return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How complete it is, as numbers to measure by: stories, how many have
|
|
115
|
+
* acceptance criteria, how many trace to something, epics with no stories,
|
|
116
|
+
* and each as a share (0..1; 1 with nothing to count).
|
|
117
|
+
*/
|
|
118
|
+
coverage() {
|
|
119
|
+
const stories = this.stories.length;
|
|
120
|
+
const withCriteria = this.stories.filter(s => s.criteria.length).length;
|
|
121
|
+
const traced = this.stories.filter(s => s.traces.length).length;
|
|
122
|
+
const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
|
|
123
|
+
const share = (n, of) => (of ? n / of : 1);
|
|
124
|
+
return {
|
|
125
|
+
stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
|
|
126
|
+
criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
|
|
131
|
+
benchmark() {
|
|
132
|
+
const c = this.coverage();
|
|
133
|
+
return [
|
|
134
|
+
...Benchmark.findings(this),
|
|
135
|
+
{ key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
|
|
136
|
+
{ key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Its checks (RequirementsChecker). */
|
|
141
|
+
checker(options) {
|
|
142
|
+
return new RequirementsChecker(this, options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
comparedKinds() {
|
|
146
|
+
return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ===== Editing (each an editCommand: one undo step) =====
|
|
150
|
+
|
|
151
|
+
/** Add an epic (`id` null) or change one. The command's result is the epic. */
|
|
152
|
+
saveEpicCommand(id, spec) {
|
|
153
|
+
return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
|
|
154
|
+
if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
|
|
155
|
+
const epic = model.epic(id);
|
|
156
|
+
if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
|
|
157
|
+
return epic;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Remove an epic; its stories stay, with no epic. */
|
|
162
|
+
removeEpicCommand(id) {
|
|
163
|
+
return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
|
|
164
|
+
model.epics = model.epics.filter(e => e.id !== id);
|
|
165
|
+
for (const s of model.stories) if (s.epic === id) s.epic = '';
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Add a release (`id` null) or rename one. */
|
|
170
|
+
saveReleaseCommand(id, spec) {
|
|
171
|
+
return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
|
|
172
|
+
if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
|
|
173
|
+
const release = model.release(id);
|
|
174
|
+
if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
|
|
175
|
+
return release;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remove a release; its stories stay, planned in none. */
|
|
180
|
+
removeReleaseCommand(id) {
|
|
181
|
+
return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
|
|
182
|
+
model.releases = model.releases.filter(r => r.id !== id);
|
|
183
|
+
for (const s of model.stories) if (s.release === id) s.release = '';
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
|
|
188
|
+
saveStoryCommand(id, spec) {
|
|
189
|
+
return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
|
|
190
|
+
if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
|
|
191
|
+
const at = model.stories.findIndex(s => s.id === id);
|
|
192
|
+
if (at < 0) return null;
|
|
193
|
+
model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
|
|
194
|
+
return model.stories[at];
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
removeStoryCommand(id) {
|
|
199
|
+
return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
|
|
200
|
+
model.stories = model.stories.filter(s => s.id !== id);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
|
|
206
|
+
* none), before another story there (`before`; none: last).
|
|
207
|
+
*/
|
|
208
|
+
moveStoryCommand(id, { epic, release, before = null }) {
|
|
209
|
+
return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
|
|
210
|
+
const story = model.story(id);
|
|
211
|
+
if (!story || before === id) return;
|
|
212
|
+
if (epic !== undefined) story.epic = epic || '';
|
|
213
|
+
if (release !== undefined) story.release = release || '';
|
|
214
|
+
model.stories.splice(model.stories.indexOf(story), 1);
|
|
215
|
+
const at = before ? model.stories.findIndex(s => s.id === before) : -1;
|
|
216
|
+
model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
|
|
221
|
+
traceCommand(storyId, trace) {
|
|
222
|
+
return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
|
|
223
|
+
const story = model.story(storyId);
|
|
224
|
+
if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
|
|
225
|
+
story.traces.push(RequirementsModel._trace(trace));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Remove a story's trace to `ref` (of `kind`, if given). */
|
|
230
|
+
untraceCommand(storyId, ref, kind) {
|
|
231
|
+
return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
|
|
232
|
+
const story = model.story(storyId);
|
|
233
|
+
if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Bring epics, releases and stories in (an import): one undo step. One with
|
|
239
|
+
* the id of one there replaces it; the rest are added.
|
|
240
|
+
*/
|
|
241
|
+
bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
|
|
242
|
+
return this.editCommand(label, (model) => {
|
|
243
|
+
const put = (list, item) => {
|
|
244
|
+
const at = list.findIndex(x => x.id === item.id);
|
|
245
|
+
if (at < 0) list.push(item); else list[at] = item;
|
|
246
|
+
};
|
|
247
|
+
for (const e of epics) put(model.epics, model._epic(e));
|
|
248
|
+
for (const r of releases) put(model.releases, model._release(r));
|
|
249
|
+
for (const s of stories) put(model.stories, model._story(s));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ===== Its parts, read from a spec =====
|
|
254
|
+
|
|
255
|
+
/** @private */
|
|
256
|
+
_epic({ id, name = '', description = '', source } = {}) {
|
|
257
|
+
return {
|
|
258
|
+
id: id ?? this._freshId(name || 'epic', this.epics), name, description,
|
|
259
|
+
...(source && { source: copy(source) }),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @private */
|
|
264
|
+
_release({ id, name = '' } = {}) {
|
|
265
|
+
return { id: id ?? this._freshId(name || 'release', this.releases), name };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @private */
|
|
269
|
+
_story(spec = {}) {
|
|
270
|
+
const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
|
|
271
|
+
const story = {
|
|
272
|
+
id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
|
|
273
|
+
as, want, soThat, description, status, priority,
|
|
274
|
+
points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
|
|
275
|
+
labels: [...labels],
|
|
276
|
+
criteria: [],
|
|
277
|
+
traces: traces.map(t => RequirementsModel._trace(t)),
|
|
278
|
+
...(source && { source: copy(source) }),
|
|
279
|
+
};
|
|
280
|
+
// A new criterion is numbered past every id the story's criteria have, so none is given twice.
|
|
281
|
+
const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
|
|
282
|
+
let n = 0;
|
|
283
|
+
const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
|
|
284
|
+
for (const c of criteria) {
|
|
285
|
+
const text = typeof c === 'string' ? c : c?.text;
|
|
286
|
+
if (!String(text ?? '').trim()) continue;
|
|
287
|
+
story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
|
|
288
|
+
}
|
|
289
|
+
return story;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @private */
|
|
293
|
+
static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
|
|
294
|
+
return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
toJSON() {
|
|
298
|
+
return {
|
|
299
|
+
name: this.name,
|
|
300
|
+
epics: copy(this.epics),
|
|
301
|
+
releases: copy(this.releases),
|
|
302
|
+
stories: copy(this.stories),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|