@peter.prib/renderengine 0.4.0 → 0.7.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 +4 -0
- package/REFERENCE.md +267 -17
- package/dist/renderengine-workspace.umd.js +5289 -2895
- package/dist/renderengine.umd.js +4243 -2120
- package/examples/archimate.html +8 -28
- package/examples/data-model.html +45 -556
- package/package.json +70 -70
- package/src/archimateArchiFormat.js +11 -3
- package/src/archimateExchangeFormat.js +17 -9
- package/src/charting/ArchimateDiagramEditor.js +1 -1
- package/src/charting/Axis.js +20 -0
- package/src/charting/DiagramEditor.js +35 -3
- package/src/charting/Dialog.js +195 -0
- package/src/charting/DuplicatesDialog.js +108 -0
- package/src/charting/FormDialog.js +28 -97
- package/src/charting/InteractionManager.js +3 -0
- package/src/charting/Legend.js +45 -2
- package/src/charting/MergeCompareDialog.js +81 -0
- package/src/charting/PopupMenu.js +12 -3
- package/src/charting/SettingsDialog.js +6 -2
- package/src/charting/SettingsManager.js +14 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
- package/src/charting/archimate/ArchimatePalette.js +1 -1
- package/src/charting/charts/AreaChart.js +5 -2
- package/src/charting/charts/BarChart.js +5 -3
- package/src/charting/charts/BarChart3DOrbit.js +5 -7
- package/src/charting/charts/BubbleChart.js +5 -7
- package/src/charting/charts/BubbleChart3D.js +5 -7
- package/src/charting/charts/CandlestickChart.js +3 -1
- package/src/charting/charts/DensityChart.js +4 -6
- package/src/charting/charts/GroupedBarChart.js +2 -2
- package/src/charting/charts/LineChart.js +5 -2
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/PipeChart.js +4 -6
- package/src/charting/charts/RadarChart.js +5 -6
- package/src/charting/charts/ScatterChart.js +5 -7
- package/src/charting/charts/StackedBarChart.js +4 -2
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
- package/src/charting/charts/StreamGraphChart.js +5 -2
- package/src/charting/charts/WaterfallChart.js +2 -1
- package/src/charting/content/ContentSource.js +129 -0
- package/src/charting/content/ContentSources.js +77 -0
- package/src/charting/datamodel/DataModel.js +116 -0
- package/src/charting/diagrams/ArchimateModel.js +38 -3
- package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
- package/src/charting/diagrams/MergeElementsCommand.js +124 -101
- package/src/charting/diagrams/SvgZoom.js +20 -1
- package/src/charting/diagrams/duplicates.js +95 -13
- package/src/charting/duplicateSearchForm.js +58 -0
- package/src/charting/modelTree/ModelTree.js +239 -202
- package/src/charting/modelTree/ModelTreePanel.js +955 -919
- package/src/charting/modelTree/TreeBuilder.js +93 -45
- package/src/charting/modelTree/TreeViewMode.js +9 -6
- package/src/charting/modelTree/viewFolders.js +15 -6
- package/src/charting/requirements/AzureDevOpsApi.js +55 -0
- package/src/charting/requirements/GitLabApi.js +43 -0
- package/src/charting/requirements/JiraApi.js +69 -0
- package/src/charting/requirements/TrackerApi.js +142 -0
- package/src/charting/requirements/TrackerExport.js +3 -2
- package/src/charting/requirements/index.js +5 -0
- package/src/charting/requirements/trackerApis.js +21 -0
- package/src/icons/IconSet.js +86 -0
- package/src/icons/index.js +59 -0
- package/src/index.js +67 -10
- package/src/workspace/dom.js +56 -42
- package/src/workspace/index.js +1 -1
- package/src/workspace/nav/NavEntry.js +42 -0
- package/src/workspace/nav/NavGroup.js +43 -0
- package/src/workspace/nav/NavItem.js +53 -0
- package/src/workspace/nav/NavMenu.js +143 -0
- package/src/workspace/nav/NavSection.js +17 -0
- package/src/workspace/nav/index.js +5 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
- package/src/workspace/parts/DataModelPart.js +91 -0
- package/src/workspace/parts/EditorPart.js +8 -0
- package/src/workspace/parts/ModelHosted.js +1 -0
- package/src/workspace/parts/ModelsPart.js +765 -622
- package/src/workspace/styles.js +28 -1
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/** A row of the tree, branch or leaf: one line, compact; a label too long ends in an ellipsis. */
|
|
2
|
+
const ROW_STYLE = `
|
|
3
|
+
cursor: pointer;
|
|
4
|
+
padding: 1px 4px;
|
|
5
|
+
margin: 0;
|
|
6
|
+
border-radius: 3px;
|
|
7
|
+
user-select: none;
|
|
8
|
+
border-left: 3px solid transparent;
|
|
9
|
+
transition: background-color 0.1s;
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 4px;
|
|
13
|
+
line-height: 1.35;
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
`;
|
|
17
|
+
|
|
1
18
|
/**
|
|
2
19
|
* Generic tree component builder for hierarchical node rendering.
|
|
3
20
|
* Handles expand/collapse, selection, context menus, and recursive rendering.
|
|
@@ -19,8 +36,14 @@ export class TreeBuilder {
|
|
|
19
36
|
* @param {Map<string, boolean>} [options.openState] - Which folders are open, by node id: kept
|
|
20
37
|
* by the caller so a rebuilt tree opens them as they were. A folder not in it is open unless
|
|
21
38
|
* its node says `collapsed: true`; opening or closing one records it here.
|
|
39
|
+
* @param {(node) => boolean} [options.filter] - Which leaves are shown: a folder is shown when a
|
|
40
|
+
* leaf under it is, and open while filtering. None: every node.
|
|
41
|
+
* @param {number} [options.leafLimit] - The leaves a folder shows; the rest wait behind a
|
|
42
|
+
* "▸ N more" row, which shows them. The folders inside it always show.
|
|
43
|
+
* @param {Set<string>} [options.shownAll] - The folders (by node id) showing every leaf, kept by
|
|
44
|
+
* the caller as openState is; asking for "more" records the folder here.
|
|
22
45
|
*/
|
|
23
|
-
constructor(doc, { container, rootNodes = [], onNodeClick = null, onNodeContext = null, getNodeChildren = null, openState = new Map(), iconFor = null } = {}) {
|
|
46
|
+
constructor(doc, { container, rootNodes = [], onNodeClick = null, onNodeContext = null, getNodeChildren = null, openState = new Map(), iconFor = null, filter = null, leafLimit = Infinity, shownAll = new Set() } = {}) {
|
|
24
47
|
this.doc = doc;
|
|
25
48
|
this.container = container;
|
|
26
49
|
this.rootNodes = rootNodes;
|
|
@@ -29,7 +52,12 @@ export class TreeBuilder {
|
|
|
29
52
|
this.getNodeChildren = getNodeChildren;
|
|
30
53
|
this.openState = openState;
|
|
31
54
|
this.iconFor = iconFor;
|
|
55
|
+
this.filter = filter;
|
|
56
|
+
this.leafLimit = leafLimit;
|
|
57
|
+
this.shownAll = shownAll;
|
|
32
58
|
this.selectedNode = null;
|
|
59
|
+
/** Each node's shown children, asked for once per render (getNodeChildren makes new nodes). */
|
|
60
|
+
this._children = new WeakMap();
|
|
33
61
|
}
|
|
34
62
|
|
|
35
63
|
/**
|
|
@@ -40,10 +68,25 @@ export class TreeBuilder {
|
|
|
40
68
|
const container = this.container;
|
|
41
69
|
if (!container) return;
|
|
42
70
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
this._children = new WeakMap();
|
|
72
|
+
container.replaceChildren(...nodes.filter(node => this._shown(node)).map(node => this._renderNode(node)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A node's children that are shown: with a filter, the leaves it keeps and the folders holding one. @private */
|
|
76
|
+
_childrenOf(node) {
|
|
77
|
+
if (!this._children.has(node)) {
|
|
78
|
+
const all = this.getNodeChildren?.(node) || [];
|
|
79
|
+
this._children.set(node, this.filter ? all.filter(child => this._shown(child)) : all);
|
|
46
80
|
}
|
|
81
|
+
return this._children.get(node);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a node is shown: always without a filter; else a leaf the filter keeps, or a folder with a shown child. @private */
|
|
85
|
+
_shown(node) {
|
|
86
|
+
if (!this.filter) return true;
|
|
87
|
+
if (this._childrenOf(node).length) return true;
|
|
88
|
+
// A folder whose children are all filtered out is not a leaf to test.
|
|
89
|
+
return !(this.getNodeChildren?.(node) || []).length && !!this.filter(node);
|
|
47
90
|
}
|
|
48
91
|
|
|
49
92
|
/**
|
|
@@ -51,14 +94,8 @@ export class TreeBuilder {
|
|
|
51
94
|
* @private
|
|
52
95
|
*/
|
|
53
96
|
_renderNode(node) {
|
|
54
|
-
const children = this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (hasChildren) {
|
|
58
|
-
return this._renderExpandable(node, children);
|
|
59
|
-
} else {
|
|
60
|
-
return this._renderLeaf(node);
|
|
61
|
-
}
|
|
97
|
+
const children = this._childrenOf(node);
|
|
98
|
+
return children.length ? this._renderExpandable(node, children) : this._renderLeaf(node);
|
|
62
99
|
}
|
|
63
100
|
|
|
64
101
|
/**
|
|
@@ -67,23 +104,13 @@ export class TreeBuilder {
|
|
|
67
104
|
*/
|
|
68
105
|
_renderExpandable(node, children) {
|
|
69
106
|
const details = this.doc.createElement('details');
|
|
70
|
-
|
|
107
|
+
// Open while filtering, to show what was found (not recorded); else as it was.
|
|
108
|
+
details.open = this.filter ? true : this.openState.get(node.id) ?? !node.collapsed;
|
|
71
109
|
details.setAttribute('data-node-id', node.id);
|
|
72
|
-
details.addEventListener('toggle', () => this.openState.set(node.id, details.open));
|
|
110
|
+
details.addEventListener('toggle', () => { if (!this.filter) this.openState.set(node.id, details.open); });
|
|
73
111
|
|
|
74
112
|
const summary = this.doc.createElement('summary');
|
|
75
|
-
summary.style.cssText =
|
|
76
|
-
cursor: pointer;
|
|
77
|
-
padding: 4px 6px;
|
|
78
|
-
margin: 2px 0;
|
|
79
|
-
border-radius: 3px;
|
|
80
|
-
user-select: none;
|
|
81
|
-
border-left: 3px solid transparent;
|
|
82
|
-
transition: background-color 0.1s;
|
|
83
|
-
display: flex;
|
|
84
|
-
align-items: center;
|
|
85
|
-
gap: 6px;
|
|
86
|
-
`;
|
|
113
|
+
summary.style.cssText = ROW_STYLE;
|
|
87
114
|
summary.setAttribute('data-node-id', node.id);
|
|
88
115
|
|
|
89
116
|
this._applyNodeStyling(summary, node);
|
|
@@ -95,15 +122,45 @@ export class TreeBuilder {
|
|
|
95
122
|
details.appendChild(summary);
|
|
96
123
|
|
|
97
124
|
const childContainer = this.doc.createElement('div');
|
|
98
|
-
childContainer.style.cssText = 'padding-left:
|
|
99
|
-
|
|
100
|
-
childContainer.appendChild(this._renderNode(child));
|
|
101
|
-
}
|
|
125
|
+
childContainer.style.cssText = 'padding-left: 12px;';
|
|
126
|
+
this._fillChildren(childContainer, node, children);
|
|
102
127
|
details.appendChild(childContainer);
|
|
103
128
|
|
|
104
129
|
return details;
|
|
105
130
|
}
|
|
106
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A folder's children: its folders, and its leaves up to leafLimit, then a
|
|
134
|
+
* "▸ N more" row that shows the rest (unless the folder shows them all already).
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
_fillChildren(container, node, children) {
|
|
138
|
+
const limit = this.shownAll.has(node.id) ? Infinity : this.leafLimit;
|
|
139
|
+
let leaves = 0;
|
|
140
|
+
let waiting = 0;
|
|
141
|
+
const rows = [];
|
|
142
|
+
for (const child of children) {
|
|
143
|
+
if (!this._childrenOf(child).length && leaves++ >= limit) { waiting++; continue; }
|
|
144
|
+
rows.push(this._renderNode(child));
|
|
145
|
+
}
|
|
146
|
+
if (waiting) rows.push(this._moreRow(waiting, () => {
|
|
147
|
+
this.shownAll.add(node.id);
|
|
148
|
+
this._fillChildren(container, node, children);
|
|
149
|
+
}));
|
|
150
|
+
container.replaceChildren(...rows);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The row standing for `count` leaves not shown; choosing it calls `showAll`. @private */
|
|
154
|
+
_moreRow(count, showAll) {
|
|
155
|
+
const row = this.doc.createElement('div');
|
|
156
|
+
row.setAttribute('data-tree-more', String(count));
|
|
157
|
+
row.style.cssText = `${ROW_STYLE} color: #666; font-style: italic;`;
|
|
158
|
+
row.textContent = `▸ ${count} more`;
|
|
159
|
+
row.title = 'Show them all';
|
|
160
|
+
row.addEventListener('click', (e) => { e.stopPropagation(); showAll(); });
|
|
161
|
+
return row;
|
|
162
|
+
}
|
|
163
|
+
|
|
107
164
|
/**
|
|
108
165
|
* Render a leaf node without children.
|
|
109
166
|
* @private
|
|
@@ -111,23 +168,12 @@ export class TreeBuilder {
|
|
|
111
168
|
_renderLeaf(node) {
|
|
112
169
|
const leaf = this.doc.createElement('div');
|
|
113
170
|
leaf.setAttribute('data-node-id', node.id);
|
|
114
|
-
// Element leaves are
|
|
171
|
+
// Element leaves are drag sources; mark them here so a rebuilt tree keeps that.
|
|
115
172
|
if (node.type === 'element' && node._element) {
|
|
116
173
|
leaf.setAttribute('data-element-id', node._element.id);
|
|
117
174
|
leaf.draggable = true;
|
|
118
175
|
}
|
|
119
|
-
leaf.style.cssText =
|
|
120
|
-
padding: 4px 6px;
|
|
121
|
-
margin: 2px 0;
|
|
122
|
-
border-radius: 3px;
|
|
123
|
-
cursor: pointer;
|
|
124
|
-
user-select: none;
|
|
125
|
-
border-left: 3px solid transparent;
|
|
126
|
-
transition: background-color 0.1s;
|
|
127
|
-
display: flex;
|
|
128
|
-
align-items: center;
|
|
129
|
-
gap: 6px;
|
|
130
|
-
`;
|
|
176
|
+
leaf.style.cssText = ROW_STYLE;
|
|
131
177
|
|
|
132
178
|
this._applyNodeStyling(leaf, node);
|
|
133
179
|
leaf.addEventListener('click', (e) => this._handleNodeClick(e, leaf, node));
|
|
@@ -153,13 +199,15 @@ export class TreeBuilder {
|
|
|
153
199
|
} else if (node.icon) {
|
|
154
200
|
const icon = this.doc.createElement('span');
|
|
155
201
|
icon.textContent = node.icon;
|
|
156
|
-
icon.style.cssText = 'flex-shrink: 0; font-size:
|
|
202
|
+
icon.style.cssText = 'flex-shrink: 0; font-size: 11px;';
|
|
157
203
|
element.appendChild(icon);
|
|
158
204
|
}
|
|
159
205
|
|
|
160
|
-
// Label
|
|
206
|
+
// Label: one line, cut with an ellipsis; the whole of it on hover.
|
|
161
207
|
const label = this.doc.createElement('span');
|
|
162
208
|
label.textContent = node.label || 'Untitled';
|
|
209
|
+
label.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;';
|
|
210
|
+
element.title = label.textContent;
|
|
163
211
|
element.appendChild(label);
|
|
164
212
|
}
|
|
165
213
|
|
|
@@ -7,6 +7,13 @@ export class TreeViewMode {
|
|
|
7
7
|
static key = null; // registry key; subclasses override
|
|
8
8
|
static label = ''; // human-readable label; subclasses override
|
|
9
9
|
|
|
10
|
+
/** Whether `text` is in any of `fields` (case ignored); no text matches everything. */
|
|
11
|
+
static textMatches(text, fields) {
|
|
12
|
+
if (!text) return true;
|
|
13
|
+
const lower = text.toLowerCase();
|
|
14
|
+
return fields.some(f => String(f ?? '').toLowerCase().includes(lower));
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
constructor() {
|
|
11
18
|
if (new.target === TreeViewMode) {
|
|
12
19
|
throw new TypeError('TreeViewMode is abstract — subclass it');
|
|
@@ -59,12 +66,8 @@ export class TreeViewMode {
|
|
|
59
66
|
*/
|
|
60
67
|
_matchesFilter(item, { text = '', typeIds = null, specializationIds = null, propertyIds = null, inDescriptions = false } = {}) {
|
|
61
68
|
// Text filter (label or type; the description too when asked)
|
|
62
|
-
if (text) {
|
|
63
|
-
|
|
64
|
-
const fields = [item.label, item.type, ...(inDescriptions ? [item.description] : [])];
|
|
65
|
-
if (!fields.some(f => String(f ?? '').toLowerCase().includes(textLower))) {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
69
|
+
if (!TreeViewMode.textMatches(text, [item.label, item.type, ...(inDescriptions ? [item.description] : [])])) {
|
|
70
|
+
return false;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
// Type filter (elements only; 'type:business-actor' format)
|
|
@@ -6,23 +6,32 @@ const VIEW_ICONS = Object.freeze({ canvas: '🎨' });
|
|
|
6
6
|
* keeps them: each view remembers its sub-folder path (`archiFolder`, folder
|
|
7
7
|
* steps {id, name}). A folder node per sub-folder at this depth (holding the
|
|
8
8
|
* views under it, opened a level deeper), then the views that sit here, each
|
|
9
|
-
* in the model's order.
|
|
9
|
+
* in the model's order. A sub-folder that holds no view is there too, from
|
|
10
|
+
* its path (the model's `emptyViewFolders`).
|
|
10
11
|
* @param {Array<Object>} views
|
|
11
12
|
* @param {number} [depth]
|
|
13
|
+
* @param {Array<Array<{id, name}>>} [emptyFolders] - The paths of the folders with no view in them
|
|
12
14
|
* @returns {Array<Object>} TreeBuilder nodes: {id, label, icon, type: 'view-folder'|'view', ...}
|
|
13
15
|
*/
|
|
14
|
-
export function viewFolderNodes(views, depth = 0) {
|
|
16
|
+
export function viewFolderNodes(views, depth = 0, emptyFolders = []) {
|
|
15
17
|
const folders = new Map();
|
|
18
|
+
const folderOf = (step) => {
|
|
19
|
+
if (!folders.has(step.id)) folders.set(step.id, { step, views: [], empty: [] });
|
|
20
|
+
return folders.get(step.id);
|
|
21
|
+
};
|
|
16
22
|
const here = [];
|
|
17
23
|
for (const view of views) {
|
|
18
24
|
const step = view.archiFolder?.[depth];
|
|
19
25
|
if (!step) { here.push(view); continue; }
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
folderOf(step).views.push(view);
|
|
27
|
+
}
|
|
28
|
+
for (const path of emptyFolders) {
|
|
29
|
+
if (path.length > depth) folderOf(path[depth]).empty.push(path);
|
|
22
30
|
}
|
|
23
31
|
return [
|
|
24
|
-
...[...folders.values()].map(({ step, views: inside }) => ({
|
|
25
|
-
id: `view-folder-${step.id}`, label: step.name || 'Untitled Folder', icon: '📁', type: 'view-folder',
|
|
32
|
+
...[...folders.values()].map(({ step, views: inside, empty }) => ({
|
|
33
|
+
id: `view-folder-${step.id}`, label: step.name || 'Untitled Folder', icon: '📁', type: 'view-folder',
|
|
34
|
+
_views: inside, _emptyFolders: empty, _depth: depth + 1,
|
|
26
35
|
})),
|
|
27
36
|
...here.map(view => ({
|
|
28
37
|
id: view.id, label: view.name || view.title || 'Untitled View', icon: VIEW_ICONS[view.constructor.kind] ?? '📄', type: 'view', _view: view,
|
|
@@ -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,142 @@
|
|
|
1
|
+
import { TrackerExport, TRACKERS } from './TrackerExport.js';
|
|
2
|
+
|
|
3
|
+
/** Text as base64, whatever its characters (btoa takes only Latin-1). */
|
|
4
|
+
function base64(text) {
|
|
5
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
6
|
+
let binary = '';
|
|
7
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
8
|
+
return btoa(binary);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* TrackerApi — a tracker read through its API rather than from an export: the
|
|
13
|
+
* issues a connection names, page by page, as the JSON `TrackerExport` reads
|
|
14
|
+
* (so an API's answer and a file saved from it make the same model). A
|
|
15
|
+
* subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
|
|
16
|
+
* its issues are, how it pages and how it is signed in to; what a connection
|
|
17
|
+
* asks for is `fields`, which a host builds its form from.
|
|
18
|
+
*
|
|
19
|
+
* `fetch` is given (a server's, or a test's): a tracker's token belongs where
|
|
20
|
+
* the request is made, which is seldom a browser. Nothing here keeps or logs
|
|
21
|
+
* the token; an error says what the tracker answered, not what was sent.
|
|
22
|
+
*/
|
|
23
|
+
export class TrackerApi {
|
|
24
|
+
/** The tracker's key in TRACKERS ('jira'). */
|
|
25
|
+
static tracker = null;
|
|
26
|
+
|
|
27
|
+
/** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
|
|
28
|
+
static MAX_ISSUES = 5000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a connection asks for, in order: `{key, label, type?, choices?,
|
|
32
|
+
* required?, secret?, placeholder?, hint?}` — `secret` written, never
|
|
33
|
+
* shown back. The tracker's own between its address and its token.
|
|
34
|
+
*/
|
|
35
|
+
static get fields() {
|
|
36
|
+
return [
|
|
37
|
+
{ key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
|
|
38
|
+
{ key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
|
|
39
|
+
...this.ownFields,
|
|
40
|
+
{ key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @protected */
|
|
45
|
+
static get ownFields() { return []; }
|
|
46
|
+
|
|
47
|
+
/** @protected */
|
|
48
|
+
static baseUrlPlaceholder = 'https://…';
|
|
49
|
+
|
|
50
|
+
/** @protected */
|
|
51
|
+
static tokenHint = 'An access token that may read the issues';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {Function} [options.fetch] - The fetch to read with (default: the global one)
|
|
57
|
+
* @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
|
|
58
|
+
*/
|
|
59
|
+
constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
|
|
60
|
+
this.connection = connection ?? {};
|
|
61
|
+
this.fetch = fetch;
|
|
62
|
+
this.maxIssues = maxIssues;
|
|
63
|
+
/** Whether the last read stopped at maxIssues. */
|
|
64
|
+
this.truncated = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get tracker() { return this.constructor.tracker; }
|
|
68
|
+
|
|
69
|
+
/** What the tracker is called ('Jira'). */
|
|
70
|
+
get label() { return TrackerExport.label(this.tracker); }
|
|
71
|
+
|
|
72
|
+
/** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
|
|
73
|
+
problems() {
|
|
74
|
+
const c = this.connection;
|
|
75
|
+
const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
|
|
76
|
+
if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
|
|
81
|
+
async read() {
|
|
82
|
+
const problems = this.problems();
|
|
83
|
+
if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
|
|
84
|
+
this.truncated = false;
|
|
85
|
+
return this._read();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The issues as a requirements model's spec, named as the connection is. */
|
|
89
|
+
async model() {
|
|
90
|
+
const json = await this.read();
|
|
91
|
+
const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
|
|
92
|
+
return { name: this.connection.name || `${this.label} issues`, ...spec };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @protected @abstract */
|
|
96
|
+
async _read() {
|
|
97
|
+
throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The headers that sign in. @protected @abstract */
|
|
101
|
+
headers() {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A path on the tracker's address. @protected */
|
|
106
|
+
url(path) {
|
|
107
|
+
return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A request to the tracker, its answer's JSON and headers; an answer
|
|
112
|
+
* other than OK an error saying what the tracker said. @protected
|
|
113
|
+
*/
|
|
114
|
+
async request(url, { method = 'GET', body } = {}) {
|
|
115
|
+
if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
|
|
116
|
+
const res = await this.fetch(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
|
|
119
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const text = await res.text().catch(() => '');
|
|
123
|
+
throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
124
|
+
}
|
|
125
|
+
return { json: await res.json(), headers: res.headers };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Issues kept up to maxIssues; true once it is reached. @protected */
|
|
129
|
+
keep(into, more) {
|
|
130
|
+
for (const issue of more) {
|
|
131
|
+
if (into.length >= this.maxIssues) { this.truncated = true; return true; }
|
|
132
|
+
into.push(issue);
|
|
133
|
+
}
|
|
134
|
+
return into.length >= this.maxIssues;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @protected */
|
|
138
|
+
static base64 = base64;
|
|
139
|
+
|
|
140
|
+
/** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
|
|
141
|
+
get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
|
|
142
|
+
}
|
|
@@ -167,15 +167,16 @@ export class TrackerExport {
|
|
|
167
167
|
return found?.[0] ?? null;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/** @private */
|
|
170
|
+
/** @private — an API's JSON, given as text or already parsed. */
|
|
171
171
|
static _json(text) {
|
|
172
|
+
if (text && typeof text === 'object') return text;
|
|
172
173
|
const s = String(text ?? '').trim();
|
|
173
174
|
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
174
175
|
try { return JSON.parse(s); } catch { return null; }
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
/**
|
|
178
|
-
* The export as a requirements model's spec.
|
|
179
|
+
* The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
|
|
179
180
|
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
180
181
|
*/
|
|
181
182
|
read(text) {
|
|
@@ -5,3 +5,8 @@ export { RequirementsFileFormat } from './RequirementsFileFormat.js';
|
|
|
5
5
|
export { TrackerExportFileFormat, StoryboardFileFormat } from './RequirementsImportFormats.js';
|
|
6
6
|
export { TrackerExport, TRACKERS, criteriaFrom, narrativeFrom, withoutNarrative } from './TrackerExport.js';
|
|
7
7
|
export { Storyboard } from './Storyboard.js';
|
|
8
|
+
export { TrackerApi } from './TrackerApi.js';
|
|
9
|
+
export { JiraApi } from './JiraApi.js';
|
|
10
|
+
export { AzureDevOpsApi } from './AzureDevOpsApi.js';
|
|
11
|
+
export { GitLabApi } from './GitLabApi.js';
|
|
12
|
+
export { TRACKER_APIS, trackerApi, trackerConnectionFields } from './trackerApis.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JiraApi } from './JiraApi.js';
|
|
2
|
+
import { AzureDevOpsApi } from './AzureDevOpsApi.js';
|
|
3
|
+
import { GitLabApi } from './GitLabApi.js';
|
|
4
|
+
|
|
5
|
+
/** Each tracker's API, by its key in TRACKERS. */
|
|
6
|
+
export const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The API reader for a connection (`connection.tracker`: 'jira', 'azure',
|
|
10
|
+
* 'gitlab'); `options` as TrackerApi takes them.
|
|
11
|
+
*/
|
|
12
|
+
export function trackerApi(connection, options) {
|
|
13
|
+
const Api = TRACKER_APIS[connection?.tracker];
|
|
14
|
+
if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
|
|
15
|
+
return new Api(connection, options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
|
|
19
|
+
export function trackerConnectionFields() {
|
|
20
|
+
return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
|
|
21
|
+
}
|