@peter.prib/renderengine 0.2.11 → 0.5.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 (41) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +132 -2
  3. package/dist/renderengine-workspace.umd.js +3346 -1245
  4. package/dist/renderengine.umd.js +1509 -158
  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/DataModelCompared.js +1 -31
  14. package/src/charting/diagramModel/ListCompared.js +35 -0
  15. package/src/charting/model/Benchmark.js +59 -0
  16. package/src/charting/model/EditableModel.js +104 -0
  17. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  18. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  19. package/src/charting/requirements/GitLabApi.js +43 -0
  20. package/src/charting/requirements/JiraApi.js +69 -0
  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/TrackerApi.js +142 -0
  28. package/src/charting/requirements/TrackerExport.js +274 -0
  29. package/src/charting/requirements/index.js +12 -0
  30. package/src/charting/requirements/trackerApis.js +21 -0
  31. package/src/index.js +7 -0
  32. package/src/workspace/Workspace.js +329 -321
  33. package/src/workspace/index.js +2 -0
  34. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  35. package/src/workspace/parts/DataModelPart.js +668 -735
  36. package/src/workspace/parts/EditorPart.js +1 -1
  37. package/src/workspace/parts/ModelFile.js +3 -1
  38. package/src/workspace/parts/ModelHosted.js +176 -0
  39. package/src/workspace/parts/ModelsPart.js +146 -7
  40. package/src/workspace/parts/RequirementsPart.js +465 -0
  41. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -1,47 +1,47 @@
1
- import { Command } from '../CommandHistory.js';
2
-
3
- /**
4
- * DataModelEditCommand — one edit of a DataModel (an entity, an attribute, a
5
- * relationship, a delete) as one undo step: run, it makes the change and keeps
6
- * the model as it was before and after (DataModel#toJSON); undone or redone, it
7
- * puts the model back as it was (DataModel#restore). Any change to a model is
8
- * undoable this way, whatever it touches.
9
- */
10
- export class DataModelEditCommand extends Command {
11
- /** A model edit is no edit on one view: undoing it shows no other. */
12
- viewId = null;
13
-
14
- /**
15
- * @param {DataModel} model
16
- * @param {string} label - What the undo menu calls it ('Edit Customer')
17
- * @param {(model: DataModel) => *} 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 = DataModelEditCommand.snapshot(this.model);
32
- this.result = this.change(this.model);
33
- this.after = DataModelEditCommand.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 shares some arrays). */
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,55 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * Azure DevOps (Boards) through its REST API: a WIQL query for a project's
5
+ * work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
6
+ * in with a personal access token. Each item's link is its page in Boards.
7
+ */
8
+ export class AzureDevOpsApi extends TrackerApi {
9
+ static tracker = 'azure';
10
+
11
+ /** Most ids one work-items request takes. */
12
+ static BATCH = 200;
13
+
14
+ static API_VERSION = '7.1';
15
+
16
+ static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
17
+
18
+ static tokenHint = 'A personal access token with Work Items (Read)';
19
+
20
+ static get ownFields() {
21
+ return [
22
+ { key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
23
+ { key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
24
+ ];
25
+ }
26
+
27
+ headers() {
28
+ return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
29
+ }
30
+
31
+ /** The WIQL: the project's work items, not its tasks, and the condition given. */
32
+ get wiql() {
33
+ const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
34
+ const condition = String(this.connection.query ?? '').trim();
35
+ return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
36
+ + (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
37
+ + (condition ? ` AND (${condition})` : '')
38
+ + ' ORDER BY [System.Id]';
39
+ }
40
+
41
+ async _read() {
42
+ const project = encodeURIComponent(String(this.connection.project).trim());
43
+ const version = `api-version=${this.constructor.API_VERSION}`;
44
+ const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
45
+ const ids = (json.workItems ?? []).map(w => w.id);
46
+ const value = [];
47
+ for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
48
+ const batch = ids.slice(i, i + this.constructor.BATCH);
49
+ const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
50
+ const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
51
+ if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
52
+ }
53
+ return { value };
54
+ }
55
+ }
@@ -0,0 +1,43 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * GitLab through its REST API: a project's issues, open and closed, 100 a
5
+ * page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
6
+ * in with a personal, project or group access token. An issue's epic
7
+ * (GitLab Premium) is its epic.
8
+ */
9
+ export class GitLabApi extends TrackerApi {
10
+ static tracker = 'gitlab';
11
+
12
+ static PAGE = 100;
13
+
14
+ static baseUrlPlaceholder = 'https://gitlab.com';
15
+
16
+ static tokenHint = 'An access token with read_api';
17
+
18
+ static get ownFields() {
19
+ return [
20
+ { key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
21
+ { key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
22
+ ];
23
+ }
24
+
25
+ headers() {
26
+ return { 'PRIVATE-TOKEN': String(this.connection.token) };
27
+ }
28
+
29
+ async _read() {
30
+ const project = encodeURIComponent(String(this.connection.project).trim());
31
+ const issues = [];
32
+ for (let page = 1; page;) {
33
+ const params = new URLSearchParams(String(this.connection.query ?? '').trim());
34
+ for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
35
+ const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
36
+ const got = Array.isArray(json) ? json : [];
37
+ const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
38
+ if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
39
+ page = got.length ? next : 0;
40
+ }
41
+ return issues;
42
+ }
43
+ }
@@ -0,0 +1,69 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * Jira through its REST API: the issues a JQL query finds (a project's, by
5
+ * default), every field (custom ones too: story points, acceptance criteria).
6
+ * Jira Cloud signs in with an email and an API token and pages by token
7
+ * (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
8
+ * access token (no email), paging by offset (`/rest/api/2/search`).
9
+ */
10
+ export class JiraApi extends TrackerApi {
11
+ static tracker = 'jira';
12
+
13
+ static PAGE = 100;
14
+
15
+ static baseUrlPlaceholder = 'https://your-site.atlassian.net';
16
+
17
+ static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
18
+
19
+ static get ownFields() {
20
+ return [
21
+ { key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
22
+ { key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
23
+ { key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
24
+ { key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
25
+ ];
26
+ }
27
+
28
+ problems() {
29
+ const out = super.problems();
30
+ if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
31
+ return out;
32
+ }
33
+
34
+ /** The JQL: the one given, else the project's issues in key order. */
35
+ get jql() {
36
+ const { query, project } = this.connection;
37
+ if (String(query ?? '').trim()) return String(query).trim();
38
+ return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
39
+ }
40
+
41
+ headers() {
42
+ const { user, token } = this.connection;
43
+ return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
44
+ }
45
+
46
+ async _read() {
47
+ const issues = [];
48
+ const page = this.constructor.PAGE;
49
+ const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
50
+ if (this.connection.edition === 'server') {
51
+ for (let startAt = 0; ; startAt += page) {
52
+ const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
53
+ const got = json.issues ?? [];
54
+ const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
55
+ if (this.keep(issues, got)) { this.truncated ||= more; break; }
56
+ if (!more) break;
57
+ }
58
+ } else {
59
+ for (let token = null; ;) {
60
+ const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
61
+ const got = json.issues ?? [];
62
+ token = json.isLast ? null : json.nextPageToken ?? null;
63
+ if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
64
+ if (!token || !got.length) break;
65
+ }
66
+ }
67
+ return { issues };
68
+ }
69
+ }
@@ -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
+ }