@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.
- package/README.md +22 -6
- package/REFERENCE.md +123 -2
- package/dist/renderengine-workspace.umd.js +3015 -1221
- package/dist/renderengine.umd.js +1220 -176
- 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/DataModelChecker.js +1 -5
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/datamodel/LevelSchema.js +23 -0
- package/src/charting/datamodel/SqlDdl.js +18 -13
- 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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { Part } from '../Part.js';
|
|
2
|
+
import { el } from '../dom.js';
|
|
3
|
+
import { ModelHosted } from './ModelHosted.js';
|
|
4
|
+
import { EditorPart } from './EditorPart.js';
|
|
5
|
+
import { MenuBar } from '../../charting/MenuBar.js';
|
|
6
|
+
import { ToolbarMenu, ToolbarSelect, ToolbarText } from '../../charting/toolbar/index.js';
|
|
7
|
+
import { FormDialog } from '../../charting/FormDialog.js';
|
|
8
|
+
import { ModelCheckDialog } from '../../charting/ModelCheckDialog.js';
|
|
9
|
+
import { PopupMenu } from '../../charting/PopupMenu.js';
|
|
10
|
+
import {
|
|
11
|
+
RequirementsModel, RequirementsFileFormat, TrackerExportFileFormat, StoryboardFileFormat,
|
|
12
|
+
} from '../../charting/requirements/index.js';
|
|
13
|
+
|
|
14
|
+
/** The ways the model is shown: [id, label]. */
|
|
15
|
+
const VIEWS = Object.freeze([['map', 'Story map'], ['backlog', 'Backlog']]);
|
|
16
|
+
|
|
17
|
+
/** How a story card and the map's headers look, from the workspace theme. */
|
|
18
|
+
const CARD = {
|
|
19
|
+
background: 'var(--ws-panel-bg, #fff)', color: 'var(--ws-fg, #222)', border: '1px solid var(--ws-border, #ccc)',
|
|
20
|
+
borderLeft: '4px solid #f0c040', borderRadius: '4px', padding: '6px 8px', margin: '0 0 6px', fontSize: '12px', cursor: 'pointer',
|
|
21
|
+
};
|
|
22
|
+
const HEAD = { padding: '6px 8px', fontWeight: '600', fontSize: '12px', textAlign: 'left', verticalAlign: 'top', cursor: 'pointer' };
|
|
23
|
+
const CELL = { padding: '6px', verticalAlign: 'top', minWidth: '180px', borderTop: '1px solid var(--ws-border, #ddd)', borderLeft: '1px solid var(--ws-border, #ddd)' };
|
|
24
|
+
const BADGE = { display: 'inline-block', marginRight: '6px', color: 'var(--ws-muted, #777)', fontSize: '11px' };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* RequirementsPart — a requirements model (RequirementsModel) in a pane, as an
|
|
28
|
+
* agile team works with it: a **story map** (epics across, releases down,
|
|
29
|
+
* each story a card — dragged to another epic or release, or among its
|
|
30
|
+
* neighbours) or the **backlog** (every story in order, a row each). A card
|
|
31
|
+
* or a row opened edits the story: its narrative ("As a … I want … so that
|
|
32
|
+
* …"), acceptance criteria, status, priority, points, labels and traces to
|
|
33
|
+
* what realises it in the other models. Model ▸ adds epics, releases and
|
|
34
|
+
* stories, brings stories in from a tracker's export or a storyboard, and
|
|
35
|
+
* checks the model (RequirementsChecker); the toolbar shows how complete it
|
|
36
|
+
* is (coverage: criteria, traces).
|
|
37
|
+
*
|
|
38
|
+
* A model of a ModelsPart (`editorType: 'requirements'`) as the other
|
|
39
|
+
* modellers' are (ModelHosted): saved as `.requirements.json`, opened from a
|
|
40
|
+
* tracker's export or a storyboard too, kept, compared and merged, a proposal
|
|
41
|
+
* reviewed.
|
|
42
|
+
*
|
|
43
|
+
* A host extends it by subclass: `traceTargets()` — what a story can be traced
|
|
44
|
+
* to (the elements of its other models); `resolveTrace(trace)` — whether a
|
|
45
|
+
* trace's target is there (for the check); `storyStatus(story)` — a live state
|
|
46
|
+
* shown on the card (a work item's), `{label, color?, title?}` or null;
|
|
47
|
+
* `searchByMeaning(query)` — the stories nearest a question (a vector
|
|
48
|
+
* store's), which puts Model ▸ Find by meaning… in the menu.
|
|
49
|
+
*
|
|
50
|
+
* Spec: `{ id, type: 'requirements', title?, view?: 'map'|'backlog', ...the
|
|
51
|
+
* model as RequirementsModel takes it }`.
|
|
52
|
+
*/
|
|
53
|
+
export class RequirementsPart extends ModelHosted(Part) {
|
|
54
|
+
static type = 'requirements';
|
|
55
|
+
|
|
56
|
+
static VIEWS = VIEWS;
|
|
57
|
+
|
|
58
|
+
constructor(spec = {}) {
|
|
59
|
+
super(spec);
|
|
60
|
+
this._adoptModel(new RequirementsModel(spec));
|
|
61
|
+
/** The view shown: 'map' or 'backlog'. */
|
|
62
|
+
this.view = VIEWS.some(([id]) => id === spec.view) ? spec.view : 'map';
|
|
63
|
+
this.menuBar = null;
|
|
64
|
+
this.body = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ===== A model saved as a file, kept, backed up =====
|
|
68
|
+
|
|
69
|
+
/** Its own format first (what it saves as), then what other tools wrote (import only). */
|
|
70
|
+
fileFormats() {
|
|
71
|
+
return [
|
|
72
|
+
RequirementsFileFormat.instance ??= new RequirementsFileFormat(),
|
|
73
|
+
TrackerExportFileFormat.instance ??= new TrackerExportFileFormat(),
|
|
74
|
+
StoryboardFileFormat.instance ??= new StoryboardFileFormat(),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Edit another model (one opened, imported or kept). */
|
|
79
|
+
replaceModel(model) {
|
|
80
|
+
this._adoptModel(model);
|
|
81
|
+
this.afterModelEdit();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
afterModelEdit() {
|
|
85
|
+
if (this.menuBar) { this.menuBar.items = this.menuBarItems(); this.menuBar.render?.(); }
|
|
86
|
+
this._draw();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ===== What a host adds =====
|
|
90
|
+
|
|
91
|
+
/** What a story can be traced to: `[{kind, ref, name, model?}]`. None here; a host gives its models' elements. */
|
|
92
|
+
traceTargets() {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a trace's target is there: true, false, or undefined (cannot tell). */
|
|
97
|
+
resolveTrace(trace) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A live state shown on a story's card: `{label, color?, title?}`, or null. */
|
|
102
|
+
storyStatus(story) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The stories nearest a question by meaning, best first: a promise of
|
|
108
|
+
* `[{id, score}]` (score 0..1). None here; a host with a vector store gives
|
|
109
|
+
* them, and Model ▸ Find by meaning… is offered.
|
|
110
|
+
*/
|
|
111
|
+
searchByMeaning(query) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether stories can be found by meaning here (a host gives searchByMeaning). */
|
|
116
|
+
get searchable() {
|
|
117
|
+
return this.searchByMeaning !== RequirementsPart.prototype.searchByMeaning;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ===== Shown =====
|
|
121
|
+
|
|
122
|
+
render() {
|
|
123
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
|
|
124
|
+
const toolbar = this.container.appendChild(el({}, this.document));
|
|
125
|
+
this.body = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto', padding: '8px', background: 'var(--ws-bg, #fafafa)' } }, this.document));
|
|
126
|
+
this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.document }).render();
|
|
127
|
+
this._draw();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Draw the view shown. @private */
|
|
131
|
+
_draw() {
|
|
132
|
+
if (!this.body) return;
|
|
133
|
+
this.body.replaceChildren(el(this.view === 'backlog' ? this.backlogSpec() : this.storyMapSpec(), this.document));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Show the story map or the backlog. */
|
|
137
|
+
setView(view) {
|
|
138
|
+
if (!VIEWS.some(([id]) => id === view)) return;
|
|
139
|
+
this.view = view;
|
|
140
|
+
this._draw();
|
|
141
|
+
this.menuBar?.refresh?.();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
menuBarItems() {
|
|
145
|
+
const host = this.modelsHost;
|
|
146
|
+
return [
|
|
147
|
+
new ToolbarMenu('File', () => this.fileItems()),
|
|
148
|
+
new ToolbarMenu('Edit', () => this.undoItems()),
|
|
149
|
+
...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
|
|
150
|
+
new ToolbarMenu('Model', () => this.modelItems()),
|
|
151
|
+
new ToolbarSelect('View', () => VIEWS.map(([id, label]) => [id, label]), () => this.view, (v) => this.setView(v)),
|
|
152
|
+
new ToolbarText(() => this.coverageText()),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** How complete it is, in a line: stories, with criteria, traced. */
|
|
157
|
+
coverageText() {
|
|
158
|
+
const c = this.model.coverage();
|
|
159
|
+
if (!c.stories) return 'No stories yet';
|
|
160
|
+
const pct = (share) => `${Math.round(share * 100)}%`;
|
|
161
|
+
return `${c.stories} stor${c.stories === 1 ? 'y' : 'ies'} · ${pct(c.criteriaShare)} with acceptance criteria · ${pct(c.traceShare)} traced`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Model ▸: new epic, release and story; stories brought in; the check. */
|
|
165
|
+
modelItems() {
|
|
166
|
+
return [
|
|
167
|
+
{ label: 'New story…', action: () => this.editStory(null) },
|
|
168
|
+
{ label: 'New epic…', action: () => this.editEpic(null) },
|
|
169
|
+
{ label: 'New release…', action: () => this.editRelease(null) },
|
|
170
|
+
{ separator: true },
|
|
171
|
+
...EditorPart.menuGroup('Bring in stories', [
|
|
172
|
+
{ label: 'From a tracker\'s export (Jira, Azure DevOps, GitLab)…', action: () => this.bringFrom(this.fileFormats()[1]) },
|
|
173
|
+
{ label: 'From a storyboard (Markdown)…', action: () => this.bringFrom(this.fileFormats()[2]) },
|
|
174
|
+
]),
|
|
175
|
+
{ separator: true },
|
|
176
|
+
...(this.searchable ? [{ label: 'Find by meaning…', action: () => this.findByMeaning() }] : []),
|
|
177
|
+
{ label: 'Check model…', action: () => this.checkModel() },
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Ask what to look for, then list the stories nearest it by meaning; one chosen is opened. */
|
|
182
|
+
findByMeaning() {
|
|
183
|
+
return new FormDialog({
|
|
184
|
+
title: 'Find stories by meaning', document: this.document, submitLabel: 'Find',
|
|
185
|
+
description: 'Describe what you are looking for in your own words: stories that mean the same are found, whatever their words.',
|
|
186
|
+
fields: [{ key: 'query', label: 'Looking for', value: '' }],
|
|
187
|
+
validate: (v) => (String(v.query ?? '').trim() ? null : 'Say what to look for'),
|
|
188
|
+
onApply: ({ query }) => this.showFound(query),
|
|
189
|
+
}).open();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The stories nearest `query` (searchByMeaning), listed to open; a failure said. */
|
|
193
|
+
async showFound(query) {
|
|
194
|
+
let results;
|
|
195
|
+
try {
|
|
196
|
+
results = (await this.searchByMeaning(query)) ?? [];
|
|
197
|
+
} catch (err) {
|
|
198
|
+
(this.document.defaultView ?? globalThis).alert?.(`Could not search: ${err.message}`);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const found = results.filter(r => this.model.story(r.id)).map(r => ({
|
|
202
|
+
code: 'found', severity: 'info', kind: 'story', ids: [r.id],
|
|
203
|
+
message: `${Math.round((r.score ?? 0) * 100)}% — ${this.model.story(r.id).name}`,
|
|
204
|
+
}));
|
|
205
|
+
new ModelCheckDialog({
|
|
206
|
+
findings: found, title: `Stories like "${query}"`, empty: 'No story found: index the model for search, or ask another way.',
|
|
207
|
+
onReveal: (f) => this.revealFinding(f), document: this.document,
|
|
208
|
+
}).open();
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Bring a file's epics, releases and stories into this model (an import
|
|
214
|
+
* format's spec), as one undo step: one with the id of one here replaces it.
|
|
215
|
+
*/
|
|
216
|
+
bringFrom(format, options = {}) {
|
|
217
|
+
return this.readInto({
|
|
218
|
+
...options, accept: format.accept, what: 'the file',
|
|
219
|
+
command: (bytes, name) => {
|
|
220
|
+
const spec = format.spec(new TextDecoder().decode(bytes));
|
|
221
|
+
if (!spec.stories.length && !spec.epics.length) return null;
|
|
222
|
+
return this.model.bringCommand(spec, `Bring in "${name}"`);
|
|
223
|
+
},
|
|
224
|
+
nothing: (name) => `"${name}" has no epics or stories.`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The findings (RequirementsChecker, traces resolved by the host); one opened edits its story or epic. */
|
|
229
|
+
checkModel() {
|
|
230
|
+
const findings = this.model.checker({ resolve: (trace) => this.resolveTrace(trace) }).run();
|
|
231
|
+
new ModelCheckDialog({ findings, onReveal: (f) => this.revealFinding(f), document: this.document }).open();
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Open what a finding is about. */
|
|
236
|
+
revealFinding(finding) {
|
|
237
|
+
const [id] = finding.ids ?? [];
|
|
238
|
+
if (finding.kind === 'epic') return this.editEpic(id);
|
|
239
|
+
if (finding.kind === 'story') return this.editStory(id);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ===== The views, as element specs =====
|
|
244
|
+
|
|
245
|
+
/** A story as a card: its name, its sentence, and badges (criteria, traces, points, status). */
|
|
246
|
+
cardSpec(story) {
|
|
247
|
+
const live = this.storyStatus(story);
|
|
248
|
+
const badges = [
|
|
249
|
+
story.criteria.length ? `✓ ${story.criteria.length}` : '✓ none',
|
|
250
|
+
story.traces.length ? `→ ${story.traces.length}` : null,
|
|
251
|
+
story.points != null ? `${story.points} pt` : null,
|
|
252
|
+
story.status || null,
|
|
253
|
+
].filter(Boolean);
|
|
254
|
+
return {
|
|
255
|
+
cls: 'rq-card', dataset: { storyId: story.id }, attrs: { draggable: 'true', title: RequirementsModel.narrative(story) || story.description || story.name },
|
|
256
|
+
style: { ...CARD, ...(story.criteria.length ? {} : { borderLeftColor: '#e08a3c' }) },
|
|
257
|
+
on: {
|
|
258
|
+
click: () => this.editStory(story.id),
|
|
259
|
+
contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(story.id), this.document); },
|
|
260
|
+
dragstart: (e) => { e.dataTransfer?.setData('text/plain', story.id); },
|
|
261
|
+
dragover: (e) => e.preventDefault(),
|
|
262
|
+
drop: (e) => { e.preventDefault(); e.stopPropagation(); this.dropStory(e.dataTransfer?.getData('text/plain'), { before: story.id, epic: story.epic, release: story.release }); },
|
|
263
|
+
},
|
|
264
|
+
children: [
|
|
265
|
+
{ text: story.name, style: { fontWeight: '600' } },
|
|
266
|
+
...(story.want ? [{ text: RequirementsModel.narrative(story), style: { color: 'var(--ws-muted, #666)', margin: '2px 0' } }] : []),
|
|
267
|
+
{
|
|
268
|
+
style: { marginTop: '4px' },
|
|
269
|
+
children: [
|
|
270
|
+
...badges.map(text => ({ tag: 'span', text, style: BADGE })),
|
|
271
|
+
...(live ? [{ tag: 'span', text: live.label, attrs: { title: live.title ?? '' }, style: { ...BADGE, color: '#fff', background: live.color ?? '#557', borderRadius: '8px', padding: '0 6px' } }] : []),
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The story map: a table, epics across, releases down; a cell takes a card dropped on it, and + adds one there. */
|
|
279
|
+
storyMapSpec() {
|
|
280
|
+
const map = this.model.storyMap();
|
|
281
|
+
const drop = (epic, release) => ({
|
|
282
|
+
dragover: (e) => e.preventDefault(),
|
|
283
|
+
drop: (e) => { e.preventDefault(); this.dropStory(e.dataTransfer?.getData('text/plain'), { epic, release }); },
|
|
284
|
+
});
|
|
285
|
+
return {
|
|
286
|
+
tag: 'table', cls: 'rq-map', style: { borderCollapse: 'collapse', color: 'var(--ws-fg, #222)' },
|
|
287
|
+
children: [
|
|
288
|
+
{
|
|
289
|
+
tag: 'thead', children: [{
|
|
290
|
+
tag: 'tr', children: [
|
|
291
|
+
{ tag: 'th', style: HEAD },
|
|
292
|
+
...map.columns.map(c => ({
|
|
293
|
+
tag: 'th', text: c.name, dataset: { epicId: c.id }, style: { ...HEAD, background: 'var(--ws-panel-bg, #fff)', borderBottom: '3px solid #6b8fd6' },
|
|
294
|
+
attrs: { title: c.epic?.description ?? '' },
|
|
295
|
+
on: c.epic ? { click: () => this.editEpic(c.id) } : {},
|
|
296
|
+
})),
|
|
297
|
+
],
|
|
298
|
+
}],
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
tag: 'tbody', children: map.rows.map(r => ({
|
|
302
|
+
tag: 'tr', dataset: { releaseId: r.id }, children: [
|
|
303
|
+
{ tag: 'th', text: r.name, style: { ...HEAD, color: 'var(--ws-muted, #666)' }, on: r.release ? { click: () => this.editRelease(r.id) } : {} },
|
|
304
|
+
...map.columns.map(c => ({
|
|
305
|
+
tag: 'td', cls: 'rq-cell', dataset: { epicId: c.id, releaseId: r.id }, style: CELL, on: drop(c.id, r.id),
|
|
306
|
+
children: [
|
|
307
|
+
...map.cell(c.id, r.id).map(s => this.cardSpec(s)),
|
|
308
|
+
{ tag: 'button', text: '+', attrs: { title: 'New story here' }, style: { border: 'none', background: 'none', color: 'var(--ws-muted, #888)', cursor: 'pointer' }, on: { click: () => this.editStory(null, { epic: c.id, release: r.id }) } },
|
|
309
|
+
],
|
|
310
|
+
})),
|
|
311
|
+
],
|
|
312
|
+
})),
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The backlog: every story in order, a row each. */
|
|
319
|
+
backlogSpec() {
|
|
320
|
+
const epicName = (id) => this.model.epic(id)?.name ?? '';
|
|
321
|
+
const releaseName = (id) => this.model.release(id)?.name ?? '';
|
|
322
|
+
const columns = ['Story', 'Epic', 'Release', 'Status', 'Priority', 'Points', 'Criteria', 'Traces'];
|
|
323
|
+
return {
|
|
324
|
+
tag: 'table', cls: 'rq-backlog', style: { borderCollapse: 'collapse', width: '100%', fontSize: '12px', color: 'var(--ws-fg, #222)' },
|
|
325
|
+
children: [
|
|
326
|
+
{ tag: 'thead', children: [{ tag: 'tr', children: columns.map(c => ({ tag: 'th', text: c, style: { ...HEAD, borderBottom: '1px solid var(--ws-border, #ccc)' } })) }] },
|
|
327
|
+
{
|
|
328
|
+
tag: 'tbody', children: this.model.stories.map(s => ({
|
|
329
|
+
tag: 'tr', dataset: { storyId: s.id }, style: { cursor: 'pointer' },
|
|
330
|
+
on: { click: () => this.editStory(s.id), contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(s.id), this.document); } },
|
|
331
|
+
children: [s.name, epicName(s.epic), releaseName(s.release), s.status, s.priority, s.points ?? '', s.criteria.length, s.traces.length]
|
|
332
|
+
.map(text => ({ tag: 'td', text, style: { padding: '4px 8px', borderBottom: '1px solid var(--ws-border, #eee)' } })),
|
|
333
|
+
})),
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A card dropped: the story moved there (before the card it fell on). */
|
|
340
|
+
dropStory(id, { epic, release, before = null }) {
|
|
341
|
+
if (!id || !this.model.story(id) || id === before) return null;
|
|
342
|
+
return this.runModelEdit(this.model.moveStoryCommand(id, { epic, release, before }));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A story's right-click menu. */
|
|
346
|
+
storyMenuItems(id) {
|
|
347
|
+
return [
|
|
348
|
+
{ label: 'Edit story…', action: () => this.editStory(id) },
|
|
349
|
+
{ label: 'Trace to…', action: () => this.editTraces(id) },
|
|
350
|
+
{ separator: true },
|
|
351
|
+
{ label: 'Remove story', action: () => this.runModelEdit(this.model.removeStoryCommand(id)) },
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ===== Dialogs =====
|
|
356
|
+
|
|
357
|
+
/** The fields a story is edited in (one definition: the dialog shows them, saveStory reads them). */
|
|
358
|
+
storyFields(story = {}) {
|
|
359
|
+
const choices = (list, none) => [['', none], ...list.map(x => [x.id, x.name])];
|
|
360
|
+
const statuses = [...new Set([...RequirementsModel.STATUSES, ...(story.status ? [story.status] : [])])];
|
|
361
|
+
return [
|
|
362
|
+
{ key: 'name', label: 'Story', value: story.name ?? '' },
|
|
363
|
+
{ key: 'epic', label: 'Epic', type: 'select', value: story.epic ?? '', choices: choices(this.model.epics, 'No epic') },
|
|
364
|
+
{ key: 'release', label: 'Release', type: 'select', value: story.release ?? '', choices: choices(this.model.releases, 'Not planned') },
|
|
365
|
+
{ type: 'heading', label: 'As a … I want … so that …' },
|
|
366
|
+
{ key: 'as', label: 'As a', value: story.as ?? '' },
|
|
367
|
+
{ key: 'want', label: 'I want', value: story.want ?? '' },
|
|
368
|
+
{ key: 'soThat', label: 'So that', value: story.soThat ?? '' },
|
|
369
|
+
{ key: 'description', label: 'Description', type: 'textarea', rows: 3, value: story.description ?? '' },
|
|
370
|
+
{ key: 'criteria', label: 'Acceptance criteria (one a line)', type: 'textarea', rows: 4, value: (story.criteria ?? []).map(c => c.text).join('\n') },
|
|
371
|
+
{ key: 'status', label: 'Status', type: 'select', value: story.status ?? '', choices: [['', '—'], ...statuses.map(s => [s, s])] },
|
|
372
|
+
{ key: 'priority', label: 'Priority', value: story.priority ?? '' },
|
|
373
|
+
{ key: 'points', label: 'Points', type: 'number', value: story.points ?? '' },
|
|
374
|
+
{ key: 'labels', label: 'Labels (comma between)', value: (story.labels ?? []).join(', ') },
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A story's values from its dialog, as RequirementsModel takes them; a
|
|
380
|
+
* criterion whose text is unchanged keeps its id.
|
|
381
|
+
*/
|
|
382
|
+
storyFromValues(values, story = null) {
|
|
383
|
+
const old = new Map((story?.criteria ?? []).map(c => [c.text, c.id]));
|
|
384
|
+
return {
|
|
385
|
+
name: String(values.name ?? '').trim(), epic: values.epic ?? '', release: values.release ?? '',
|
|
386
|
+
as: values.as ?? '', want: values.want ?? '', soThat: values.soThat ?? '', description: values.description ?? '',
|
|
387
|
+
criteria: String(values.criteria ?? '').split('\n').map(t => t.trim()).filter(Boolean).map(text => (old.has(text) ? { id: old.get(text), text } : text)),
|
|
388
|
+
status: values.status ?? '', priority: values.priority ?? '',
|
|
389
|
+
points: values.points === '' || values.points == null || Number.isNaN(Number(values.points)) ? null : Number(values.points),
|
|
390
|
+
labels: String(values.labels ?? '').split(',').map(l => l.trim()).filter(Boolean),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Edit a story (`id` null: a new one, where `at` says). */
|
|
395
|
+
editStory(id, at = {}) {
|
|
396
|
+
const story = id ? this.model.story(id) : null;
|
|
397
|
+
if (id && !story) return null;
|
|
398
|
+
return new FormDialog({
|
|
399
|
+
title: story ? `Story: ${story.name}` : 'New story', document: this.document, submitLabel: story ? 'Save' : 'Add',
|
|
400
|
+
fields: this.storyFields(story ?? { epic: at.epic ?? '', release: at.release ?? '' }),
|
|
401
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'A story needs a name'),
|
|
402
|
+
onApply: (values) => this.runModelEdit(this.model.saveStoryCommand(id, this.storyFromValues(values, story))),
|
|
403
|
+
}).open();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Edit a story's traces: each a row (kind, what, its name), what the host offers suggested. */
|
|
407
|
+
editTraces(id) {
|
|
408
|
+
const story = this.model.story(id);
|
|
409
|
+
if (!story) return null;
|
|
410
|
+
const targets = this.traceTargets();
|
|
411
|
+
return new FormDialog({
|
|
412
|
+
title: `Traces: ${story.name}`, document: this.document,
|
|
413
|
+
description: 'What realises this story: an ArchiMate requirement or work package, an element, a data concept, entity or table.',
|
|
414
|
+
fields: [{
|
|
415
|
+
key: 'traces', label: '', type: 'rows', addLabel: 'Add trace', value: story.traces,
|
|
416
|
+
columns: [
|
|
417
|
+
{ key: 'kind', label: 'Kind', type: 'select', choices: RequirementsModel.TRACE_KINDS },
|
|
418
|
+
{ key: 'ref', label: 'What (its id)', suggestions: targets.map(t => t.ref) },
|
|
419
|
+
{ key: 'name', label: 'Name', suggestions: targets.map(t => t.name).filter(Boolean) },
|
|
420
|
+
],
|
|
421
|
+
}],
|
|
422
|
+
onApply: ({ traces = [] }) => {
|
|
423
|
+
// A ref the host knows names itself, and says which model it is in.
|
|
424
|
+
const known = new Map(targets.map(t => [t.ref, t]));
|
|
425
|
+
const rows = traces.filter(t => t.ref).map(t => ({ ...known.get(t.ref), ...t, name: t.name || known.get(t.ref)?.name || '' }));
|
|
426
|
+
return this.runModelEdit(this.model.saveStoryCommand(id, { traces: rows }));
|
|
427
|
+
},
|
|
428
|
+
}).open();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
editEpic(id) {
|
|
432
|
+
const epic = id ? this.model.epic(id) : null;
|
|
433
|
+
if (id && !epic) return null;
|
|
434
|
+
return new FormDialog({
|
|
435
|
+
title: epic ? `Epic: ${epic.name}` : 'New epic', document: this.document, submitLabel: epic ? 'Save' : 'Add',
|
|
436
|
+
fields: [
|
|
437
|
+
{ key: 'name', label: 'Epic', value: epic?.name ?? '' },
|
|
438
|
+
{ key: 'description', label: 'Outcome', type: 'textarea', rows: 3, value: epic?.description ?? '' },
|
|
439
|
+
],
|
|
440
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'An epic needs a name'),
|
|
441
|
+
onApply: (v) => this.runModelEdit(this.model.saveEpicCommand(id, { name: v.name.trim(), description: v.description ?? '' })),
|
|
442
|
+
}).open();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
editRelease(id) {
|
|
446
|
+
const release = id ? this.model.release(id) : null;
|
|
447
|
+
if (id && !release) return null;
|
|
448
|
+
return new FormDialog({
|
|
449
|
+
title: release ? `Release: ${release.name}` : 'New release', document: this.document, submitLabel: release ? 'Save' : 'Add',
|
|
450
|
+
fields: [{ key: 'name', label: 'Release', value: release?.name ?? '' }],
|
|
451
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'A release needs a name'),
|
|
452
|
+
onApply: (v) => this.runModelEdit(this.model.saveReleaseCommand(id, { name: v.name.trim() })),
|
|
453
|
+
}).open();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
dispose() {
|
|
457
|
+
this.menuBar?.dispose?.();
|
|
458
|
+
super.dispose();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** The spec again: the model, and the view shown. */
|
|
462
|
+
serialize() {
|
|
463
|
+
return { ...super.serialize(), ...this.model.toJSON(), view: this.view };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -11,9 +11,15 @@ export class ModelLibrary {
|
|
|
11
11
|
/** The key prefix of a kept model. */
|
|
12
12
|
static PREFIX = 'model:';
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* @param {KeyValueStore} store
|
|
16
|
+
* @param {Object} [options]
|
|
17
|
+
* @param {string} [options.label] - Where it keeps models, as its menu names it
|
|
18
|
+
*/
|
|
19
|
+
constructor(store, { label = 'This browser' } = {}) {
|
|
16
20
|
this.store = store;
|
|
21
|
+
/** Where it keeps models, as the File menu names it ('This browser'). */
|
|
22
|
+
this.label = label;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** A new id for a model to keep. */
|