@peter.prib/renderengine 0.2.10 → 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.
Files changed (39) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +123 -2
  3. package/dist/renderengine-workspace.umd.js +3015 -1221
  4. package/dist/renderengine.umd.js +1220 -176
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelChecker.js +1 -5
  14. package/src/charting/datamodel/DataModelCompared.js +1 -31
  15. package/src/charting/datamodel/LevelSchema.js +23 -0
  16. package/src/charting/datamodel/SqlDdl.js +18 -13
  17. package/src/charting/diagramModel/ListCompared.js +35 -0
  18. package/src/charting/model/Benchmark.js +59 -0
  19. package/src/charting/model/EditableModel.js +104 -0
  20. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerExport.js +273 -0
  28. package/src/charting/requirements/index.js +7 -0
  29. package/src/index.js +6 -0
  30. package/src/workspace/Workspace.js +329 -321
  31. package/src/workspace/index.js +2 -0
  32. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  33. package/src/workspace/parts/DataModelPart.js +668 -735
  34. package/src/workspace/parts/EditorPart.js +1 -1
  35. package/src/workspace/parts/ModelFile.js +3 -1
  36. package/src/workspace/parts/ModelHosted.js +176 -0
  37. package/src/workspace/parts/ModelsPart.js +146 -7
  38. package/src/workspace/parts/RequirementsPart.js +465 -0
  39. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -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
+ }
@@ -0,0 +1,94 @@
1
+ import { criteriaFrom, narrativeFrom, withoutNarrative } from './TrackerExport.js';
2
+
3
+ /**
4
+ * Storyboard — epics and stories from a storyboard or requirements document
5
+ * written in Markdown (or plain text with Markdown headings), as teams keep
6
+ * them beside or before a tracker:
7
+ *
8
+ * - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
9
+ * "Story" (`### Story: Pay by card`) a story; otherwise the document's one
10
+ * title (`#`) is its name, the next level down are epics and the level
11
+ * below that stories;
12
+ * - under an epic, a list item that reads "As a … I want …" is a story too;
13
+ * - under a story, "As a … I want … so that …" is its narrative, the list
14
+ * under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
15
+ * and the rest its description — a picture (`![caption](file)`) kept there
16
+ * by its caption.
17
+ *
18
+ * `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
19
+ * are made from names (prefix: `prefix`, so two storyboards read into one
20
+ * model stay apart).
21
+ */
22
+ export class Storyboard {
23
+ /** @param {{prefix?: string}} [options] */
24
+ constructor({ prefix = '' } = {}) {
25
+ this.prefix = prefix;
26
+ }
27
+
28
+ read(text) {
29
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
30
+ const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
31
+ .map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
32
+ const kindOf = (h) => (/^epic\b/i.test(h.text) ? 'epic' : /^(user\s+)?story\b/i.test(h.text) || /\bas an?\s+\S/i.test(h.text) ? 'story' : null);
33
+ const top = Math.min(...headings.map(h => h.level));
34
+ const tops = headings.filter(h => h.level === top);
35
+ // One top heading, a title (#), names the document; its children are the epics.
36
+ const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
37
+ const epicLevel = named ? top + 1 : top;
38
+ const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
39
+
40
+ const epics = [], stories = [];
41
+ const ids = new Set();
42
+ const idFor = (name, kind) => {
43
+ const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
44
+ let id = base;
45
+ for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
46
+ ids.add(id);
47
+ return id;
48
+ };
49
+ const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
50
+ let epic = null;
51
+ headings.forEach((h, i) => {
52
+ if (named && h === tops[0]) return;
53
+ const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
54
+ const body = bodyOf(h, i);
55
+ if (kind === 'epic') {
56
+ epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
57
+ epics.push(epic);
58
+ const listed = this._listedStories(body);
59
+ epic.description = listed.rest;
60
+ for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
61
+ } else if (kind === 'story') {
62
+ stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
63
+ }
64
+ });
65
+ if (!headings.length || (!epics.length && !stories.length)) {
66
+ for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
67
+ }
68
+ return { name: named ? tops[0].text : '', epics, stories };
69
+ }
70
+
71
+ /** A story from its heading and what is under it. @private */
72
+ _story(name, body) {
73
+ const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
74
+ const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
75
+ const said = narrativeFrom(text);
76
+ const { as, want, soThat } = said.want ? said : narrativeFrom(name);
77
+ const { criteria, rest } = criteriaFrom(text);
78
+ const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
79
+ return { name, as, want, soThat, description, criteria };
80
+ }
81
+
82
+ /** "As a … I want …" list items: stories; the rest of the text, kept. @private */
83
+ _listedStories(body) {
84
+ const stories = [];
85
+ const rest = [];
86
+ for (const line of body.split('\n')) {
87
+ const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
88
+ const said = item && narrativeFrom(item[1]);
89
+ if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
90
+ else rest.push(line);
91
+ }
92
+ return { stories, rest: rest.join('\n').trim() };
93
+ }
94
+ }