@peter.prib/renderengine 0.2.11 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -6
- package/REFERENCE.md +117 -2
- package/dist/renderengine-workspace.umd.js +2973 -1203
- package/dist/renderengine.umd.js +1178 -158
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerExport.js +273 -0
- package/src/charting/requirements/index.js +7 -0
- package/src/index.js +6 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
|
@@ -1626,319 +1626,327 @@
|
|
|
1626
1626
|
*/
|
|
1627
1627
|
const SYSTEM_THEME = 'system';
|
|
1628
1628
|
|
|
1629
|
-
/** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
|
|
1630
|
-
const WORKSPACE_SCHEMA_VERSION = 1;
|
|
1631
|
-
|
|
1632
|
-
/**
|
|
1633
|
-
* Workspace — the `<ws-workspace>` custom element and the shell's public API.
|
|
1634
|
-
*
|
|
1635
|
-
* Renders into **light DOM**: parts routinely carry their own stylesheets, and a
|
|
1636
|
-
* consumer stylesheet cannot cross a shadow boundary. All shell markup is
|
|
1637
|
-
* `ws-`-prefixed and themed through CSS custom properties instead.
|
|
1638
|
-
*
|
|
1639
|
-
* Typical wiring:
|
|
1640
|
-
*
|
|
1641
|
-
* const ws = document.querySelector('ws-workspace');
|
|
1642
|
-
* ws.registerParts([ListPart, DetailPart, ChartPart]);
|
|
1643
|
-
* ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
|
|
1644
|
-
* ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
|
|
1645
|
-
* ws.activatePerspective('default');
|
|
1646
|
-
*/
|
|
1647
|
-
class Workspace extends HTMLElement {
|
|
1648
|
-
constructor() {
|
|
1649
|
-
super();
|
|
1650
|
-
this.bus = new ContextBus();
|
|
1651
|
-
this.registry = new PartRegistry();
|
|
1652
|
-
/** @type {Map<string, object>} name → layout JSON */
|
|
1653
|
-
this.perspectives = new Map();
|
|
1654
|
-
this.activePerspective = null;
|
|
1655
|
-
/** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
|
|
1656
|
-
this.tree = null;
|
|
1657
|
-
/** @type {Map<string, object>} theme name → CSS custom-property map */
|
|
1658
|
-
this.themes = new Map(Object.entries(BUILTIN_THEMES));
|
|
1659
|
-
/** The *selection* — may be `system`, which resolves at apply time. */
|
|
1660
|
-
this.theme = 'light';
|
|
1661
|
-
/** Custom properties currently written onto the host, so they can be
|
|
1662
|
-
* cleared before a different theme is applied. */
|
|
1663
|
-
this._appliedVars = [];
|
|
1664
|
-
this._built = false;
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
/* ── Element lifecycle ──────────────────────────────────────────────── */
|
|
1668
|
-
|
|
1669
|
-
connectedCallback() {
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
this.
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
this.
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
this.
|
|
1744
|
-
|
|
1745
|
-
this.
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
this.
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
this
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
this.
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
if (this.
|
|
1864
|
-
this.
|
|
1865
|
-
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1629
|
+
/** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
|
|
1630
|
+
const WORKSPACE_SCHEMA_VERSION = 1;
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Workspace — the `<ws-workspace>` custom element and the shell's public API.
|
|
1634
|
+
*
|
|
1635
|
+
* Renders into **light DOM**: parts routinely carry their own stylesheets, and a
|
|
1636
|
+
* consumer stylesheet cannot cross a shadow boundary. All shell markup is
|
|
1637
|
+
* `ws-`-prefixed and themed through CSS custom properties instead.
|
|
1638
|
+
*
|
|
1639
|
+
* Typical wiring:
|
|
1640
|
+
*
|
|
1641
|
+
* const ws = document.querySelector('ws-workspace');
|
|
1642
|
+
* ws.registerParts([ListPart, DetailPart, ChartPart]);
|
|
1643
|
+
* ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
|
|
1644
|
+
* ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
|
|
1645
|
+
* ws.activatePerspective('default');
|
|
1646
|
+
*/
|
|
1647
|
+
class Workspace extends HTMLElement {
|
|
1648
|
+
constructor() {
|
|
1649
|
+
super();
|
|
1650
|
+
this.bus = new ContextBus();
|
|
1651
|
+
this.registry = new PartRegistry();
|
|
1652
|
+
/** @type {Map<string, object>} name → layout JSON */
|
|
1653
|
+
this.perspectives = new Map();
|
|
1654
|
+
this.activePerspective = null;
|
|
1655
|
+
/** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
|
|
1656
|
+
this.tree = null;
|
|
1657
|
+
/** @type {Map<string, object>} theme name → CSS custom-property map */
|
|
1658
|
+
this.themes = new Map(Object.entries(BUILTIN_THEMES));
|
|
1659
|
+
/** The *selection* — may be `system`, which resolves at apply time. */
|
|
1660
|
+
this.theme = 'light';
|
|
1661
|
+
/** Custom properties currently written onto the host, so they can be
|
|
1662
|
+
* cleared before a different theme is applied. */
|
|
1663
|
+
this._appliedVars = [];
|
|
1664
|
+
this._built = false;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/* ── Element lifecycle ──────────────────────────────────────────────── */
|
|
1668
|
+
|
|
1669
|
+
connectedCallback() {
|
|
1670
|
+
// Moved, not new (a host re-parents it: a tab dragged to another
|
|
1671
|
+
// group): its layout was disposed when it left (disconnectedCallback),
|
|
1672
|
+
// so it is drawn again — the parts are still in the registry, as they
|
|
1673
|
+
// were. Without this a workspace moved in the page came back empty.
|
|
1674
|
+
if (this._built) {
|
|
1675
|
+
if (!this.tree && this.activePerspective) this._renderTree();
|
|
1676
|
+
this._watchSystemTheme(this.theme === SYSTEM_THEME);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
this._built = true;
|
|
1680
|
+
ensureStyles(this.ownerDocument ?? document);
|
|
1681
|
+
this.classList.add('ws-workspace');
|
|
1682
|
+
|
|
1683
|
+
// Chrome is built from element specs rather than an HTML string, and
|
|
1684
|
+
// `ref` captures the nodes directly — no querySelector round-trip.
|
|
1685
|
+
// `label`, not `title` — `title` is a global HTML attribute and would
|
|
1686
|
+
// also render a browser tooltip over the whole workspace.
|
|
1687
|
+
render(this, [
|
|
1688
|
+
{ cls: 'ws-toolbar', children: [
|
|
1689
|
+
{ tag: 'span', cls: 'ws-toolbar-title', text: this.getAttribute('label') ?? '' },
|
|
1690
|
+
{ tag: 'span', cls: 'ws-toolbar-spacer' },
|
|
1691
|
+
{ tag: 'span', cls: 'ws-theme-picker', ref: n => { this._themePickerEl = n; } },
|
|
1692
|
+
{ tag: 'span', cls: 'ws-perspectives', ref: n => { this._perspectivesEl = n; } },
|
|
1693
|
+
]},
|
|
1694
|
+
{ cls: 'ws-root', ref: n => { this._rootEl = n; } },
|
|
1695
|
+
]);
|
|
1696
|
+
|
|
1697
|
+
this.applyTheme(this.getAttribute('theme') ?? 'light');
|
|
1698
|
+
this._renderThemePicker();
|
|
1699
|
+
|
|
1700
|
+
// A perspective registered before the element was connected renders now.
|
|
1701
|
+
if (this.activePerspective) this._renderTree();
|
|
1702
|
+
else if (this.perspectives.size) this.activatePerspective([...this.perspectives.keys()][0]);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
disconnectedCallback() {
|
|
1706
|
+
this.tree?.dispose();
|
|
1707
|
+
this.tree = null;
|
|
1708
|
+
this._watchSystemTheme(false); // a live matchMedia listener would outlive the element
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/* ── Parts ──────────────────────────────────────────────────────────── */
|
|
1712
|
+
|
|
1713
|
+
registerPart(PartClass) { this.registry.register(PartClass); return this; }
|
|
1714
|
+
registerParts(classes) { this.registry.registerAll(classes); return this; }
|
|
1715
|
+
|
|
1716
|
+
/** Create a pooled part instance from a spec. */
|
|
1717
|
+
addPart(spec) { return this.registry.add(spec); }
|
|
1718
|
+
addParts(specs) { return specs.map(s => this.addPart(s)); }
|
|
1719
|
+
getPart(id) { return this.registry.get(id); }
|
|
1720
|
+
removePart(id) { return this.registry.remove(id); }
|
|
1721
|
+
|
|
1722
|
+
/* ── Perspectives ───────────────────────────────────────────────────── */
|
|
1723
|
+
|
|
1724
|
+
registerPerspective(name, layoutJson) {
|
|
1725
|
+
this.perspectives.set(name, layoutJson);
|
|
1726
|
+
this._renderPerspectiveButtons();
|
|
1727
|
+
return this;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Switch to a named perspective.
|
|
1732
|
+
*
|
|
1733
|
+
* Parts are pooled by id and live for the workspace's lifetime, so a part
|
|
1734
|
+
* the incoming perspective also references is re-parented rather than
|
|
1735
|
+
* rebuilt — it keeps its scroll position, unsaved input and subscriptions.
|
|
1736
|
+
* Parts the incoming perspective doesn't reference are detached, not
|
|
1737
|
+
* destroyed, so switching back restores them intact.
|
|
1738
|
+
*
|
|
1739
|
+
* There is deliberately no legal-transition table: a framework cannot know
|
|
1740
|
+
* a consumer's perspectives. Pass `guard` to veto a switch if you need one.
|
|
1741
|
+
*/
|
|
1742
|
+
activatePerspective(name) {
|
|
1743
|
+
const layout = this.perspectives.get(name);
|
|
1744
|
+
if (!layout) throw new Error(`Unknown perspective "${name}"`);
|
|
1745
|
+
const from = this.activePerspective;
|
|
1746
|
+
if (from === name) return true;
|
|
1747
|
+
if (this.guard && this.guard(from, name) === false) return false;
|
|
1748
|
+
|
|
1749
|
+
this.tree?.dispose();
|
|
1750
|
+
this.activePerspective = name;
|
|
1751
|
+
this._renderTree();
|
|
1752
|
+
|
|
1753
|
+
this.dispatchEvent(new CustomEvent('ws-perspective-change', {
|
|
1754
|
+
detail: { from, to: name }, bubbles: true,
|
|
1755
|
+
}));
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
_renderTree() {
|
|
1760
|
+
if (!this._rootEl) return;
|
|
1761
|
+
const layout = this.perspectives.get(this.activePerspective);
|
|
1762
|
+
this._rootEl.textContent = '';
|
|
1763
|
+
this.tree = createLayout(layout);
|
|
1764
|
+
this.tree.render(this._rootEl, this._ctx());
|
|
1765
|
+
this.tree.activate?.();
|
|
1766
|
+
// Parts the new tree doesn't reference are deliberately NOT disposed —
|
|
1767
|
+
// they are merely detached (PartNode.dispose), keeping their DOM and
|
|
1768
|
+
// state. Disposing them would delete them from the pool, so switching
|
|
1769
|
+
// back to the perspective that used them would find them gone. Part
|
|
1770
|
+
// lifetime ends only at removePart()/deserialize().
|
|
1771
|
+
this._renderPerspectiveButtons();
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
_ctx() {
|
|
1775
|
+
return {
|
|
1776
|
+
bus: this.bus,
|
|
1777
|
+
registry: this.registry,
|
|
1778
|
+
workspace: this,
|
|
1779
|
+
onLayoutChange: () => this._onLayoutChange(),
|
|
1780
|
+
...(this.services ?? {}),
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
_onLayoutChange() {
|
|
1785
|
+
if (this.activePerspective) {
|
|
1786
|
+
this.perspectives.set(this.activePerspective, this.tree.serialize());
|
|
1787
|
+
}
|
|
1788
|
+
this.dispatchEvent(new CustomEvent('ws-layout-change', {
|
|
1789
|
+
detail: { perspective: this.activePerspective }, bubbles: true,
|
|
1790
|
+
}));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
_renderPerspectiveButtons() {
|
|
1794
|
+
if (!this._perspectivesEl) return;
|
|
1795
|
+
render(this._perspectivesEl, [...this.perspectives.keys()].map(name => ({
|
|
1796
|
+
tag: 'button',
|
|
1797
|
+
cls: ['ws-perspective-btn', name === this.activePerspective && 'ws-active'],
|
|
1798
|
+
text: name,
|
|
1799
|
+
attrs: { type: 'button' },
|
|
1800
|
+
on: { click: () => this.activatePerspective(name) },
|
|
1801
|
+
})));
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/* ── Theming ────────────────────────────────────────────────────────── */
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Register a custom-property set under a name. It appears in the toolbar's
|
|
1808
|
+
* theme picker immediately — the picker is built from the registry, so a
|
|
1809
|
+
* consumer theme needs no CSS and no picker change.
|
|
1810
|
+
*/
|
|
1811
|
+
registerTheme(name, vars) {
|
|
1812
|
+
this.themes.set(name, vars);
|
|
1813
|
+
this._renderThemePicker();
|
|
1814
|
+
return this;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/** Registered theme names, plus the reserved `system` option. */
|
|
1818
|
+
themeNames() { return [SYSTEM_THEME, ...this.themes.keys()]; }
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* Apply a theme by name, or `system` to follow the OS setting.
|
|
1822
|
+
*
|
|
1823
|
+
* The previous theme's properties are removed before the new ones are set:
|
|
1824
|
+
* a custom theme may define only some of the vars, and leftovers from the
|
|
1825
|
+
* outgoing theme would otherwise show through as a mismatched palette.
|
|
1826
|
+
*/
|
|
1827
|
+
applyTheme(name) {
|
|
1828
|
+
const resolved = name === SYSTEM_THEME ? this._systemTheme() : name;
|
|
1829
|
+
const vars = this.themes.get(resolved);
|
|
1830
|
+
if (!vars && name !== SYSTEM_THEME) return this;
|
|
1831
|
+
|
|
1832
|
+
for (const key of this._appliedVars) this.style.removeProperty(key);
|
|
1833
|
+
this._appliedVars = [];
|
|
1834
|
+
for (const [k, v] of Object.entries(vars ?? {})) {
|
|
1835
|
+
this.style.setProperty(k, v);
|
|
1836
|
+
this._appliedVars.push(k);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const previous = this.theme;
|
|
1840
|
+
this.theme = name; // the selection ('system' stays 'system')
|
|
1841
|
+
this.setAttribute('data-theme', resolved); // the resolved name, for consumer CSS hooks
|
|
1842
|
+
this._watchSystemTheme(name === SYSTEM_THEME);
|
|
1843
|
+
if (this._themeSelect) this._themeSelect.value = name;
|
|
1844
|
+
|
|
1845
|
+
if (previous !== name) {
|
|
1846
|
+
this.dispatchEvent(new CustomEvent('ws-theme-change', {
|
|
1847
|
+
detail: { theme: name, resolved }, bubbles: true,
|
|
1848
|
+
}));
|
|
1849
|
+
}
|
|
1850
|
+
return this;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
_systemTheme() {
|
|
1854
|
+
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/** Keep `system` live: re-apply when the OS flips between light and dark. */
|
|
1858
|
+
_watchSystemTheme(enabled) {
|
|
1859
|
+
if (enabled && !this._systemThemeQuery) {
|
|
1860
|
+
this._systemThemeQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
|
|
1861
|
+
this._onSystemThemeChange = () => { if (this.theme === SYSTEM_THEME) this.applyTheme(SYSTEM_THEME); };
|
|
1862
|
+
this._systemThemeQuery?.addEventListener('change', this._onSystemThemeChange);
|
|
1863
|
+
} else if (!enabled && this._systemThemeQuery) {
|
|
1864
|
+
this._systemThemeQuery.removeEventListener('change', this._onSystemThemeChange);
|
|
1865
|
+
this._systemThemeQuery = null;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
_renderThemePicker() {
|
|
1870
|
+
if (!this._themePickerEl) return;
|
|
1871
|
+
if (this.getAttribute('theme-picker') === 'hidden') {
|
|
1872
|
+
this._themePickerEl.textContent = '';
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
render(this._themePickerEl, [
|
|
1876
|
+
{ tag: 'label', text: 'Theme', attrs: { for: 'ws-theme-select' } },
|
|
1877
|
+
{
|
|
1878
|
+
tag: 'select',
|
|
1879
|
+
cls: 'ws-theme-select',
|
|
1880
|
+
attrs: { id: 'ws-theme-select', title: 'Colour theme' },
|
|
1881
|
+
on: { change: e => this.applyTheme(e.target.value) },
|
|
1882
|
+
ref: n => { this._themeSelect = n; n.value = this.theme; },
|
|
1883
|
+
children: this.themeNames().map(name => ({
|
|
1884
|
+
tag: 'option',
|
|
1885
|
+
text: name === SYSTEM_THEME ? 'System' : name[0].toUpperCase() + name.slice(1),
|
|
1886
|
+
attrs: { value: name, selected: name === this.theme },
|
|
1887
|
+
})),
|
|
1888
|
+
},
|
|
1889
|
+
]);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/* ── Persistence ────────────────────────────────────────────────────── */
|
|
1893
|
+
|
|
1894
|
+
serialize() {
|
|
1895
|
+
if (this.tree && this.activePerspective) {
|
|
1896
|
+
this.perspectives.set(this.activePerspective, this.tree.serialize());
|
|
1897
|
+
}
|
|
1898
|
+
return {
|
|
1899
|
+
version: WORKSPACE_SCHEMA_VERSION,
|
|
1900
|
+
theme: this.theme,
|
|
1901
|
+
activePerspective: this.activePerspective,
|
|
1902
|
+
perspectives: Object.fromEntries(this.perspectives),
|
|
1903
|
+
parts: this.registry.serialize(),
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Restore a serialized workspace. Part *types* must already be registered —
|
|
1909
|
+
* a saved document carries specs, not code.
|
|
1910
|
+
*
|
|
1911
|
+
* `version` is honoured from day one on purpose: layout schemas change, and
|
|
1912
|
+
* sqlengine's dashboard already had to retrofit exactly this migration once.
|
|
1913
|
+
*/
|
|
1914
|
+
deserialize(json, { migrate } = {}) {
|
|
1915
|
+
const doc = migrate ? migrate(json) : json;
|
|
1916
|
+
if (doc.version > WORKSPACE_SCHEMA_VERSION) {
|
|
1917
|
+
throw new Error(`Workspace document version ${doc.version} is newer than this build (${WORKSPACE_SCHEMA_VERSION})`);
|
|
1918
|
+
}
|
|
1919
|
+
this.registry.disposeAll();
|
|
1920
|
+
for (const spec of doc.parts ?? []) {
|
|
1921
|
+
if (spec.adapter) continue; // AdapterPart's mount fn cannot round-trip — re-add it in code
|
|
1922
|
+
this.addPart(spec);
|
|
1923
|
+
}
|
|
1924
|
+
// Only a theme this build actually knows — a document naming one the
|
|
1925
|
+
// consumer no longer registers must not leave the shell unstyled.
|
|
1926
|
+
if (doc.theme && (doc.theme === SYSTEM_THEME || this.themes.has(doc.theme))) {
|
|
1927
|
+
this.applyTheme(doc.theme);
|
|
1928
|
+
}
|
|
1929
|
+
this.perspectives = new Map(Object.entries(doc.perspectives ?? {}));
|
|
1930
|
+
this.activePerspective = null;
|
|
1931
|
+
if (doc.activePerspective && this.perspectives.has(doc.activePerspective)) {
|
|
1932
|
+
this.activatePerspective(doc.activePerspective);
|
|
1933
|
+
}
|
|
1934
|
+
return this;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const WORKSPACE_TAG = 'ws-workspace';
|
|
1939
|
+
|
|
1940
|
+
/**
|
|
1941
|
+
* Register the element. Custom element names must contain a hyphen, so
|
|
1942
|
+
* `workspace` is not a legal name — `define()` throws SyntaxError on it.
|
|
1943
|
+
* Guarded so importing this module twice does not throw on re-definition.
|
|
1944
|
+
*/
|
|
1945
|
+
function defineWorkspace(tag = WORKSPACE_TAG) {
|
|
1946
|
+
if (typeof customElements === 'undefined') return false;
|
|
1947
|
+
if (customElements.get(tag)) return false;
|
|
1948
|
+
customElements.define(tag, Workspace);
|
|
1949
|
+
return true;
|
|
1942
1950
|
}
|
|
1943
1951
|
|
|
1944
1952
|
/**
|
|
@@ -3393,16 +3401,23 @@
|
|
|
3393
3401
|
* @param {string} options.mineName - What the model compared is called
|
|
3394
3402
|
* @param {string} options.theirsName - What the other is called
|
|
3395
3403
|
* @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
|
|
3404
|
+
* @param {string} [options.note] - Said above the differences: why the other is offered (a proposal's rationale)
|
|
3396
3405
|
* @param {Document} [options.document]
|
|
3397
3406
|
*/
|
|
3398
|
-
constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
|
|
3407
|
+
constructor({ comparison, mineName, theirsName, onMerge = null, note = '', document: doc = globalThis.document }) {
|
|
3399
3408
|
this.comparison = comparison;
|
|
3400
3409
|
this.mineName = mineName;
|
|
3401
3410
|
this.theirsName = theirsName;
|
|
3402
3411
|
this.onMerge = onMerge;
|
|
3412
|
+
this.note = note;
|
|
3403
3413
|
this.document = doc;
|
|
3404
3414
|
}
|
|
3405
3415
|
|
|
3416
|
+
/** The dialog's description: the note, then `text`. */
|
|
3417
|
+
described(text) {
|
|
3418
|
+
return this.note ? `${this.note}\n\n${text}` : text;
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3406
3421
|
get title() {
|
|
3407
3422
|
return `Compare "${this.mineName}" with "${this.theirsName}"`;
|
|
3408
3423
|
}
|
|
@@ -3441,7 +3456,7 @@
|
|
|
3441
3456
|
const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
|
|
3442
3457
|
return new FormDialog({
|
|
3443
3458
|
title: this.title,
|
|
3444
|
-
description: this.summaryText(),
|
|
3459
|
+
description: this.described(this.summaryText()),
|
|
3445
3460
|
fields: this.fields(),
|
|
3446
3461
|
submitLabel: canMerge ? 'Merge…' : 'Close',
|
|
3447
3462
|
cancelLabel: 'Close',
|
|
@@ -3474,7 +3489,7 @@
|
|
|
3474
3489
|
const nothing = !this.comparison.mergeable().length;
|
|
3475
3490
|
return new FormDialog({
|
|
3476
3491
|
title: this.title,
|
|
3477
|
-
description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too
|
|
3492
|
+
description: this.described(nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`),
|
|
3478
3493
|
fields: this.fields(),
|
|
3479
3494
|
submitLabel: nothing ? 'Close' : 'Merge',
|
|
3480
3495
|
idPrefix: 'model-merge-',
|
|
@@ -3968,9 +3983,15 @@
|
|
|
3968
3983
|
/** The key prefix of a kept model. */
|
|
3969
3984
|
static PREFIX = 'model:';
|
|
3970
3985
|
|
|
3971
|
-
/**
|
|
3972
|
-
|
|
3986
|
+
/**
|
|
3987
|
+
* @param {KeyValueStore} store
|
|
3988
|
+
* @param {Object} [options]
|
|
3989
|
+
* @param {string} [options.label] - Where it keeps models, as its menu names it
|
|
3990
|
+
*/
|
|
3991
|
+
constructor(store, { label = 'This browser' } = {}) {
|
|
3973
3992
|
this.store = store;
|
|
3993
|
+
/** Where it keeps models, as the File menu names it ('This browser'). */
|
|
3994
|
+
this.label = label;
|
|
3974
3995
|
}
|
|
3975
3996
|
|
|
3976
3997
|
/** A new id for a model to keep. */
|
|
@@ -4167,6 +4188,66 @@
|
|
|
4167
4188
|
}
|
|
4168
4189
|
}
|
|
4169
4190
|
|
|
4191
|
+
/**
|
|
4192
|
+
* Benchmark — what a model is measured by, so a change to it (a proposal, a
|
|
4193
|
+
* merge) says whether it made the model better. Each measure:
|
|
4194
|
+
* `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
|
|
4195
|
+
* (a share is 0..1; a value of null is not measured yet).
|
|
4196
|
+
*
|
|
4197
|
+
* Every model with a checker is measured by its findings — warnings counted
|
|
4198
|
+
* apart (`Benchmark.findings`); a model with more to say gives its own
|
|
4199
|
+
* `benchmark()` (a data model its Scorecard, a requirements model how many
|
|
4200
|
+
* stories say when they are done and trace to something), starting from those.
|
|
4201
|
+
*/
|
|
4202
|
+
class Benchmark {
|
|
4203
|
+
/** A model's measures: its own benchmark(), else its checker's findings, else none. */
|
|
4204
|
+
static of(model) {
|
|
4205
|
+
if (typeof model?.benchmark === 'function') return model.benchmark();
|
|
4206
|
+
return Benchmark.findings(model);
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
/** The check's warnings and all its findings, each fewer the better. */
|
|
4210
|
+
static findings(model) {
|
|
4211
|
+
if (typeof model?.checker !== 'function') return [];
|
|
4212
|
+
const found = model.checker().run();
|
|
4213
|
+
return [
|
|
4214
|
+
{ key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
|
|
4215
|
+
{ key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
|
|
4216
|
+
];
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
/**
|
|
4220
|
+
* Two models' measures side by side, matched by key: `{key, label, format,
|
|
4221
|
+
* before, after, change}` — change 'better', 'worse', 'same', or null when
|
|
4222
|
+
* either is not measured.
|
|
4223
|
+
*/
|
|
4224
|
+
static compare(before, after) {
|
|
4225
|
+
const then = new Map(before.map(m => [m.key, m]));
|
|
4226
|
+
return after.filter(m => then.has(m.key)).map(m => {
|
|
4227
|
+
const was = then.get(m.key).value, now = m.value;
|
|
4228
|
+
const change = was == null || now == null ? null
|
|
4229
|
+
: was === now ? 'same'
|
|
4230
|
+
: (now < was) === (m.better === 'lower') ? 'better' : 'worse';
|
|
4231
|
+
return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
|
|
4232
|
+
});
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
/** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
|
|
4236
|
+
static format(value, format = 'count') {
|
|
4237
|
+
if (value == null) return '—';
|
|
4238
|
+
if (format === 'share') return `${Math.round(value * 100)}%`;
|
|
4239
|
+
if (format === 'score') return `${value} of 100`;
|
|
4240
|
+
return String(value);
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
/** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
|
|
4244
|
+
static text(rows) {
|
|
4245
|
+
const moved = rows.filter(r => r.change !== 'same');
|
|
4246
|
+
const shown = moved.length ? moved : rows;
|
|
4247
|
+
return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4170
4251
|
/**
|
|
4171
4252
|
* ModelsPart — several models open at once (as Archi's model tree holds
|
|
4172
4253
|
* several): one editor part per model (any EditorPart type, `editorType`),
|
|
@@ -4199,6 +4280,23 @@
|
|
|
4199
4280
|
* closed open again next time. File ▸ This browser opens a kept model, stops
|
|
4200
4281
|
* keeping one, backs them all up (BackupFolder: a folder chosen once, such as
|
|
4201
4282
|
* one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
|
|
4283
|
+
*
|
|
4284
|
+
* Where models are kept is the host's to say: a `modelLibrary` among the
|
|
4285
|
+
* workspace's services (`workspace.services`, reaching this part as
|
|
4286
|
+
* `ctx.modelLibrary`) is used in place of the browser's — anything with
|
|
4287
|
+
* ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
|
|
4288
|
+
* `label` for the menu ('The engine'). An entry is the model in its editor's
|
|
4289
|
+
* file format, so a host stores it as it likes: a database row, a file, a
|
|
4290
|
+
* graph built from it. The backup folder stays in the browser.
|
|
4291
|
+
*
|
|
4292
|
+
* Proposals: a model offered as a change to the one in front — by an agent,
|
|
4293
|
+
* a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
|
|
4294
|
+
* each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
|
|
4295
|
+
* is taken is chosen and is one undo step. Taking or dismissing one removes it.
|
|
4296
|
+
*
|
|
4297
|
+
* A host adds tools of its own for the model in front — an agent to run on
|
|
4298
|
+
* it, say — as `modelTools(part, models)` among the workspace's services: its
|
|
4299
|
+
* items end the Tools menu.
|
|
4202
4300
|
*/
|
|
4203
4301
|
class ModelsPart extends Part {
|
|
4204
4302
|
static type = 'models';
|
|
@@ -4216,6 +4314,9 @@
|
|
|
4216
4314
|
this.backups = null;
|
|
4217
4315
|
/** Edits waiting to be kept, by part id. @private */
|
|
4218
4316
|
this._pending = new Map();
|
|
4317
|
+
/** Changes offered, not yet taken or dismissed (propose). */
|
|
4318
|
+
this.proposals = [];
|
|
4319
|
+
this._proposalSeq = 0;
|
|
4219
4320
|
}
|
|
4220
4321
|
|
|
4221
4322
|
/** What a copy's name ends with (copyName). */
|
|
@@ -4262,8 +4363,8 @@
|
|
|
4262
4363
|
* @param {Array<EditorPart>} starting - The models the spec opened
|
|
4263
4364
|
*/
|
|
4264
4365
|
async startKeeping(starting = []) {
|
|
4265
|
-
const store = await this.openStore();
|
|
4266
|
-
this.library = new ModelLibrary(store);
|
|
4366
|
+
const store = await this.openStore(); // the backup folder's, whoever keeps the models
|
|
4367
|
+
this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
|
|
4267
4368
|
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
4268
4369
|
this.backups = new BackupFolder({
|
|
4269
4370
|
store, window: win,
|
|
@@ -4353,7 +4454,7 @@
|
|
|
4353
4454
|
async askOpenKept() {
|
|
4354
4455
|
const open = new Set(this.models().map(p => p.keepId));
|
|
4355
4456
|
const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
|
|
4356
|
-
if (!entries.length) { this.tell(
|
|
4457
|
+
if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
|
|
4357
4458
|
return new FormDialog({
|
|
4358
4459
|
title: 'Open a kept model',
|
|
4359
4460
|
fields: [{
|
|
@@ -4392,7 +4493,7 @@
|
|
|
4392
4493
|
async backUpAndTell() {
|
|
4393
4494
|
for (const part of this.models()) await this.keep(part);
|
|
4394
4495
|
const files = await this.backupFiles();
|
|
4395
|
-
if (!files.length) { this.tell(
|
|
4496
|
+
if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
|
|
4396
4497
|
const { location, names } = await this.backups.backUp(files);
|
|
4397
4498
|
this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
|
|
4398
4499
|
return { location, names };
|
|
@@ -4405,6 +4506,83 @@
|
|
|
4405
4506
|
return dir;
|
|
4406
4507
|
}
|
|
4407
4508
|
|
|
4509
|
+
/** Where models are kept, as the File menu names it: the library's label. */
|
|
4510
|
+
get keptWhere() {
|
|
4511
|
+
return this.library?.label ?? 'This browser';
|
|
4512
|
+
}
|
|
4513
|
+
|
|
4514
|
+
// ===== Proposals =====
|
|
4515
|
+
|
|
4516
|
+
/**
|
|
4517
|
+
* Offer a change: the model proposed — `spec`, a model as this workbench's
|
|
4518
|
+
* editor takes it (as a base is given), or `entry`, a model in one of the
|
|
4519
|
+
* editor's file formats (`{format, data}`, as a kept one is) — with a
|
|
4520
|
+
* `title` and why (`rationale`), from `source` (who or what proposed it).
|
|
4521
|
+
* `onSettle(outcome, {bench})` hears whether it was 'taken' or
|
|
4522
|
+
* 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
|
|
4523
|
+
* @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
|
|
4524
|
+
*/
|
|
4525
|
+
propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
|
|
4526
|
+
if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
|
|
4527
|
+
const id = `${this.id}-proposal-${++this._proposalSeq}`;
|
|
4528
|
+
const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
|
|
4529
|
+
if (entry) {
|
|
4530
|
+
const format = part.fileFormats().find(f => f.label === entry.format);
|
|
4531
|
+
if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
|
|
4532
|
+
part.replaceModel(format.import(entry.data));
|
|
4533
|
+
}
|
|
4534
|
+
const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
|
|
4535
|
+
this.proposals.push(proposal);
|
|
4536
|
+
return proposal;
|
|
4537
|
+
}
|
|
4538
|
+
|
|
4539
|
+
/**
|
|
4540
|
+
* How the proposal would move the model's measures (Benchmark), were it
|
|
4541
|
+
* taken whole: `[{label, before, after, change, …}]`.
|
|
4542
|
+
*/
|
|
4543
|
+
proposalBench(part, proposal) {
|
|
4544
|
+
return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
/** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
|
|
4548
|
+
dismissProposal(proposal, outcome = 'dismissed', note = {}) {
|
|
4549
|
+
if (!this.proposals.includes(proposal)) return;
|
|
4550
|
+
this.proposals = this.proposals.filter(p => p !== proposal);
|
|
4551
|
+
proposal.onSettle?.(outcome, note);
|
|
4552
|
+
proposal.part?.dispose?.();
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
/**
|
|
4556
|
+
* Review a proposal against `part`'s model: its differences, chosen as a
|
|
4557
|
+
* merge (ModelMergeDialog), its rationale above them. Merging takes it.
|
|
4558
|
+
*/
|
|
4559
|
+
reviewProposal(part, proposal) {
|
|
4560
|
+
const bench = this.proposalBench(part, proposal);
|
|
4561
|
+
return new ModelMergeDialog({
|
|
4562
|
+
...this._comparing(part, proposal.part),
|
|
4563
|
+
theirsName: proposal.title,
|
|
4564
|
+
note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
|
|
4565
|
+
onMerge: (command) => {
|
|
4566
|
+
// What was taken (the parts chosen), measured: the model before the merge and after.
|
|
4567
|
+
const before = Benchmark.of(part.model);
|
|
4568
|
+
part.runModelEdit(command);
|
|
4569
|
+
this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
|
|
4570
|
+
},
|
|
4571
|
+
}).open();
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
/** Tools ▸ Proposals: each, to review or dismiss. */
|
|
4575
|
+
proposalItems(part) {
|
|
4576
|
+
if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
|
|
4577
|
+
return this.proposals.map(p => ({
|
|
4578
|
+
label: p.source ? `${p.title} — ${p.source}` : p.title,
|
|
4579
|
+
items: [
|
|
4580
|
+
{ label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
|
|
4581
|
+
{ label: 'Dismiss', action: () => this.dismissProposal(p) },
|
|
4582
|
+
],
|
|
4583
|
+
}));
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4408
4586
|
/** Tell the user (the page's alert). @protected */
|
|
4409
4587
|
tell(message) {
|
|
4410
4588
|
const win = this.container?.ownerDocument?.defaultView ?? globalThis;
|
|
@@ -4459,6 +4637,19 @@
|
|
|
4459
4637
|
this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
|
|
4460
4638
|
}
|
|
4461
4639
|
|
|
4640
|
+
/**
|
|
4641
|
+
* Replace the bases: a host whose reference models change (a live
|
|
4642
|
+
* database after its schema changed) gives them afresh. The parts made
|
|
4643
|
+
* for the old ones are disposed; nothing open is touched.
|
|
4644
|
+
* @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
|
|
4645
|
+
*/
|
|
4646
|
+
setBases(specs = []) {
|
|
4647
|
+
for (const base of this._bases ?? []) base.dispose?.();
|
|
4648
|
+
this._bases = null;
|
|
4649
|
+
this.spec.bases = specs;
|
|
4650
|
+
return this.bases();
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4462
4653
|
/** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
|
|
4463
4654
|
static copyName(name) {
|
|
4464
4655
|
const plain = String(name || 'Untitled');
|
|
@@ -4485,9 +4676,33 @@
|
|
|
4485
4676
|
return part;
|
|
4486
4677
|
}
|
|
4487
4678
|
|
|
4488
|
-
/**
|
|
4679
|
+
/**
|
|
4680
|
+
* Open a base to look at (by its place in `bases`): a tab of its own copy,
|
|
4681
|
+
* named as the base is with " (view)", that can be laid out and moved
|
|
4682
|
+
* about but is never kept — a live database seen as it is, without making a
|
|
4683
|
+
* design of it. Returns the part.
|
|
4684
|
+
*/
|
|
4685
|
+
viewBase(index) {
|
|
4686
|
+
const spec = this.bases()[index]?.spec;
|
|
4687
|
+
if (!spec) return null;
|
|
4688
|
+
const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
|
|
4689
|
+
const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
|
|
4690
|
+
part.keepOff = true;
|
|
4691
|
+
this.work.focus(part.id);
|
|
4692
|
+
return part;
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
/** What a base viewed is called: " (view)" after its name. */
|
|
4696
|
+
static VIEW_SUFFIX = ' (view)';
|
|
4697
|
+
|
|
4698
|
+
/** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
|
|
4489
4699
|
baseItems() {
|
|
4490
|
-
|
|
4700
|
+
const bases = this.bases();
|
|
4701
|
+
return [
|
|
4702
|
+
...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
|
|
4703
|
+
...(bases.length ? [{ separator: true }] : []),
|
|
4704
|
+
...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
|
|
4705
|
+
];
|
|
4491
4706
|
}
|
|
4492
4707
|
|
|
4493
4708
|
/**
|
|
@@ -4520,6 +4735,10 @@
|
|
|
4520
4735
|
return [
|
|
4521
4736
|
{ label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
|
|
4522
4737
|
{ label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
|
|
4738
|
+
{ separator: true },
|
|
4739
|
+
{ label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
|
|
4740
|
+
// The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
|
|
4741
|
+
...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
|
|
4523
4742
|
];
|
|
4524
4743
|
}
|
|
4525
4744
|
|
|
@@ -4604,7 +4823,7 @@
|
|
|
4604
4823
|
return [
|
|
4605
4824
|
...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
|
|
4606
4825
|
...(compare ? [compare, merge] : []),
|
|
4607
|
-
...(keeping.length ? [{ label:
|
|
4826
|
+
...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
|
|
4608
4827
|
{ separator: true },
|
|
4609
4828
|
{ label: 'Close model', action: () => this.closeModel(part.id) },
|
|
4610
4829
|
{ label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
|
|
@@ -23539,14 +23758,19 @@
|
|
|
23539
23758
|
|
|
23540
23759
|
/**
|
|
23541
23760
|
* Fruchterman–Reingold: lines pull their ends together, every pair of boxes
|
|
23542
|
-
* pushes apart, cooling over the iterations
|
|
23543
|
-
* where the boxes
|
|
23761
|
+
* pushes apart, cooling over the iterations, all within a frame — a square
|
|
23762
|
+
* round where the boxes were, room for them a grid's worth — so a box with no
|
|
23763
|
+
* lines is not pushed away for ever. Deterministic: it starts from where the
|
|
23764
|
+
* boxes are (boxes on one spot are spread round it first).
|
|
23544
23765
|
*/
|
|
23545
23766
|
function forceDirected(boxes, edges, gap) {
|
|
23546
23767
|
const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
|
|
23547
23768
|
const k = size * 1.2;
|
|
23548
23769
|
const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
|
|
23549
23770
|
const index = new Map(boxes.map((b, i) => [b.id, i]));
|
|
23771
|
+
const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
|
|
23772
|
+
const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
|
|
23773
|
+
const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
|
|
23550
23774
|
let temperature = size * 2;
|
|
23551
23775
|
for (let iteration = 0; iteration < 120; iteration++) {
|
|
23552
23776
|
const shift = pos.map(() => ({ x: 0, y: 0 }));
|
|
@@ -23571,8 +23795,8 @@
|
|
|
23571
23795
|
for (let i = 0; i < pos.length; i++) {
|
|
23572
23796
|
const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
|
|
23573
23797
|
const step = Math.min(d, temperature);
|
|
23574
|
-
pos[i].x
|
|
23575
|
-
pos[i].y
|
|
23798
|
+
pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
|
|
23799
|
+
pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
|
|
23576
23800
|
}
|
|
23577
23801
|
temperature *= 0.95;
|
|
23578
23802
|
}
|
|
@@ -28422,6 +28646,8 @@
|
|
|
28422
28646
|
this.instance = instance;
|
|
28423
28647
|
this.model = model;
|
|
28424
28648
|
this.nodeOptions = nodeOptions;
|
|
28649
|
+
/** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
|
|
28650
|
+
this.status = null;
|
|
28425
28651
|
}
|
|
28426
28652
|
|
|
28427
28653
|
/** State lives on the instance and the model element, not here. @protected */
|
|
@@ -28537,6 +28763,27 @@
|
|
|
28537
28763
|
* gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
|
|
28538
28764
|
*/
|
|
28539
28765
|
decorations(ctx) {
|
|
28766
|
+
return [...this._badge(ctx), ...this.statusPill(ctx)];
|
|
28767
|
+
}
|
|
28768
|
+
|
|
28769
|
+
/**
|
|
28770
|
+
* The host's status (`status`), as a small pill inside the box's bottom
|
|
28771
|
+
* right corner: its text, on its colour. Nothing without one.
|
|
28772
|
+
*/
|
|
28773
|
+
statusPill({ w, h }) {
|
|
28774
|
+
const text = this.status?.text;
|
|
28775
|
+
if (!text) return [];
|
|
28776
|
+
const label = String(text);
|
|
28777
|
+
const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
|
|
28778
|
+
const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
|
|
28779
|
+
return [Glyph.group([
|
|
28780
|
+
Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
|
|
28781
|
+
Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
|
|
28782
|
+
], { 'pointer-events': 'none', class: 'element-status' })];
|
|
28783
|
+
}
|
|
28784
|
+
|
|
28785
|
+
/** The type badge (decorations). @private */
|
|
28786
|
+
_badge(ctx) {
|
|
28540
28787
|
const node = this.node;
|
|
28541
28788
|
const { w, h } = ctx;
|
|
28542
28789
|
const fixed = ArchimateElementTemplate.badgeSize(w, h);
|
|
@@ -32006,7 +32253,9 @@
|
|
|
32006
32253
|
* ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
|
|
32007
32254
|
* each a button (⚠ a warning, ℹ for a look) that shows what it names
|
|
32008
32255
|
* (`onReveal`), with what can be done about it beside (`actionsFor`), or
|
|
32009
|
-
* "nothing found". Any editor or part that checks a model shows it this way
|
|
32256
|
+
* "nothing found". Any editor or part that checks a model shows it this way;
|
|
32257
|
+
* anything else listed to be opened (search results) too, with its own
|
|
32258
|
+
* `title` and `empty` text.
|
|
32010
32259
|
*/
|
|
32011
32260
|
class ModelCheckDialog {
|
|
32012
32261
|
/**
|
|
@@ -32015,9 +32264,13 @@
|
|
|
32015
32264
|
* @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
|
|
32016
32265
|
* @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
|
|
32017
32266
|
* @param {Document} [options.document]
|
|
32267
|
+
* @param {string} [options.title] - In place of "Model check: n findings"
|
|
32268
|
+
* @param {string} [options.empty] - What an empty list says
|
|
32018
32269
|
*/
|
|
32019
|
-
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
|
|
32270
|
+
constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
|
|
32020
32271
|
this.findings = findings;
|
|
32272
|
+
this._title = title;
|
|
32273
|
+
this.empty = empty;
|
|
32021
32274
|
this.onReveal = onReveal;
|
|
32022
32275
|
this.actionsFor = actionsFor;
|
|
32023
32276
|
this.document = doc;
|
|
@@ -32025,6 +32278,7 @@
|
|
|
32025
32278
|
|
|
32026
32279
|
/** The dialog's title: how many findings. */
|
|
32027
32280
|
get title() {
|
|
32281
|
+
if (this._title) return this._title;
|
|
32028
32282
|
const n = this.findings.length;
|
|
32029
32283
|
return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
|
|
32030
32284
|
}
|
|
@@ -32050,7 +32304,7 @@
|
|
|
32050
32304
|
on: { click: () => { dialog?.close(); act.action(); } },
|
|
32051
32305
|
})),
|
|
32052
32306
|
],
|
|
32053
|
-
})) : [{ text:
|
|
32307
|
+
})) : [{ text: this.empty }],
|
|
32054
32308
|
}, this.document);
|
|
32055
32309
|
dialog = new FormDialog({
|
|
32056
32310
|
title: this.title,
|
|
@@ -36733,8 +36987,12 @@
|
|
|
36733
36987
|
* elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
|
|
36734
36988
|
* @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
|
|
36735
36989
|
* the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
|
|
36990
|
+
* @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
|
|
36991
|
+
* live status for an element (a component's lifecycle state), drawn on its boxes
|
|
36992
|
+
* @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
|
|
36993
|
+
* element (its transitions), at the end of a box's right-click menu
|
|
36736
36994
|
*/
|
|
36737
|
-
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
|
|
36995
|
+
constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
|
|
36738
36996
|
super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
|
|
36739
36997
|
this.view = view;
|
|
36740
36998
|
this.model = model;
|
|
@@ -36747,6 +37005,9 @@
|
|
|
36747
37005
|
this.settingsManager = settingsManager;
|
|
36748
37006
|
/** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
|
|
36749
37007
|
this.templateMenu = templateMenu;
|
|
37008
|
+
/** A host's live status per element, drawn on its boxes, and its menu items per element. */
|
|
37009
|
+
this.elementStatus = elementStatus;
|
|
37010
|
+
this.elementMenuExtras = elementMenuExtras;
|
|
36750
37011
|
this.addDropHandler({
|
|
36751
37012
|
accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
|
|
36752
37013
|
drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
|
|
@@ -36784,6 +37045,7 @@
|
|
|
36784
37045
|
...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
|
|
36785
37046
|
...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
|
|
36786
37047
|
...(items.length ? [{ label: 'Save as template', items }] : []),
|
|
37048
|
+
...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
|
|
36787
37049
|
];
|
|
36788
37050
|
}
|
|
36789
37051
|
|
|
@@ -37008,7 +37270,11 @@
|
|
|
37008
37270
|
templates.set(inst.id, existing?.instance === inst ? existing
|
|
37009
37271
|
: new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
|
|
37010
37272
|
}
|
|
37011
|
-
for (const t of templates.values())
|
|
37273
|
+
for (const t of templates.values()) {
|
|
37274
|
+
t.nodeOptions = this.nodeOptions;
|
|
37275
|
+
// Asked afresh at every sync: a host's status is live, not part of the model.
|
|
37276
|
+
t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
|
|
37277
|
+
}
|
|
37012
37278
|
// The view's groups (Archi groups: model folders with bounds on this view).
|
|
37013
37279
|
for (const folder of view.placedFolders) {
|
|
37014
37280
|
const existing = this.templates.get(folder.id);
|
|
@@ -37507,6 +37773,9 @@
|
|
|
37507
37773
|
/** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
|
|
37508
37774
|
get imports() { return true; }
|
|
37509
37775
|
|
|
37776
|
+
/** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
|
|
37777
|
+
get exports() { return true; }
|
|
37778
|
+
|
|
37510
37779
|
/** True when import takes the file's bytes (a zip archive), else its text. */
|
|
37511
37780
|
get readsBytes() { return false; }
|
|
37512
37781
|
|
|
@@ -37566,7 +37835,7 @@
|
|
|
37566
37835
|
const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
|
|
37567
37836
|
const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
|
|
37568
37837
|
const groups = formats.map(format => [
|
|
37569
|
-
only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
37838
|
+
only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
|
|
37570
37839
|
only !== 'export' && format.imports && enabled(format, 'import') && {
|
|
37571
37840
|
label: only ? `${format.label}…` : `Import ${format.label}…`,
|
|
37572
37841
|
action: () => importFile(format, { choose, onImport, report: tell }),
|
|
@@ -45628,50 +45897,151 @@
|
|
|
45628
45897
|
}
|
|
45629
45898
|
}
|
|
45630
45899
|
|
|
45631
|
-
/**
|
|
45632
|
-
*
|
|
45633
|
-
*
|
|
45634
|
-
* the model as it was before and after (
|
|
45635
|
-
* puts the model back as it was (
|
|
45636
|
-
* undoable this way, whatever it touches.
|
|
45637
|
-
*/
|
|
45638
|
-
class
|
|
45639
|
-
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
45640
|
-
viewId = null;
|
|
45641
|
-
|
|
45642
|
-
/**
|
|
45643
|
-
* @param {
|
|
45644
|
-
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
45645
|
-
* @param {(model:
|
|
45646
|
-
*/
|
|
45647
|
-
constructor(model, label, change) {
|
|
45648
|
-
super(label);
|
|
45649
|
-
this.model = model;
|
|
45650
|
-
this.change = change;
|
|
45651
|
-
this.before = null;
|
|
45652
|
-
this.after = null;
|
|
45653
|
-
}
|
|
45654
|
-
|
|
45655
|
-
execute() {
|
|
45656
|
-
if (this.after) {
|
|
45657
|
-
this.model.restore(this.after);
|
|
45658
|
-
} else {
|
|
45659
|
-
this.before =
|
|
45660
|
-
this.result = this.change(this.model);
|
|
45661
|
-
this.after =
|
|
45662
|
-
}
|
|
45663
|
-
this.model.edited();
|
|
45664
|
-
}
|
|
45665
|
-
|
|
45666
|
-
undo() {
|
|
45667
|
-
this.model.restore(this.before);
|
|
45668
|
-
this.model.edited();
|
|
45669
|
-
}
|
|
45670
|
-
|
|
45671
|
-
/** The model as it is, a copy nothing later changes (its toJSON
|
|
45672
|
-
static snapshot(model) {
|
|
45673
|
-
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
45674
|
-
}
|
|
45900
|
+
/**
|
|
45901
|
+
* ModelEditCommand — one edit of an EditableModel (a data model's entity, a
|
|
45902
|
+
* requirements model's story, a delete) as one undo step: run, it makes the
|
|
45903
|
+
* change and keeps the model as it was before and after (its toJSON); undone
|
|
45904
|
+
* or redone, it puts the model back as it was (its restore). Any change to a
|
|
45905
|
+
* model is undoable this way, whatever it touches.
|
|
45906
|
+
*/
|
|
45907
|
+
class ModelEditCommand extends Command {
|
|
45908
|
+
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
45909
|
+
viewId = null;
|
|
45910
|
+
|
|
45911
|
+
/**
|
|
45912
|
+
* @param {EditableModel} model
|
|
45913
|
+
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
45914
|
+
* @param {(model: EditableModel) => *} change - Makes the change
|
|
45915
|
+
*/
|
|
45916
|
+
constructor(model, label, change) {
|
|
45917
|
+
super(label);
|
|
45918
|
+
this.model = model;
|
|
45919
|
+
this.change = change;
|
|
45920
|
+
this.before = null;
|
|
45921
|
+
this.after = null;
|
|
45922
|
+
}
|
|
45923
|
+
|
|
45924
|
+
execute() {
|
|
45925
|
+
if (this.after) {
|
|
45926
|
+
this.model.restore(this.after);
|
|
45927
|
+
} else {
|
|
45928
|
+
this.before = ModelEditCommand.snapshot(this.model);
|
|
45929
|
+
this.result = this.change(this.model);
|
|
45930
|
+
this.after = ModelEditCommand.snapshot(this.model);
|
|
45931
|
+
}
|
|
45932
|
+
this.model.edited();
|
|
45933
|
+
}
|
|
45934
|
+
|
|
45935
|
+
undo() {
|
|
45936
|
+
this.model.restore(this.before);
|
|
45937
|
+
this.model.edited();
|
|
45938
|
+
}
|
|
45939
|
+
|
|
45940
|
+
/** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
|
|
45941
|
+
static snapshot(model) {
|
|
45942
|
+
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
45943
|
+
}
|
|
45944
|
+
}
|
|
45945
|
+
|
|
45946
|
+
/**
|
|
45947
|
+
* EditableModel — base of a model edited as a whole rather than view by view
|
|
45948
|
+
* (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
|
|
45949
|
+
* its own way). What a part needs of any model it edits, keeps and merges:
|
|
45950
|
+
*
|
|
45951
|
+
* - a name;
|
|
45952
|
+
* - whether it changed since it was saved (`edited()`, `markSaved()`,
|
|
45953
|
+
* `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
|
|
45954
|
+
* it is lost;
|
|
45955
|
+
* - an undo history, every edit one step on it (`editCommand(label, change)`:
|
|
45956
|
+
* the model before and after, as its toJSON, put back by `restore`);
|
|
45957
|
+
* - how it differs from another of its kind (`compareWith`, over the
|
|
45958
|
+
* subclass's `comparedKinds()`), which merges the other's into it.
|
|
45959
|
+
*
|
|
45960
|
+
* A subclass gives `toJSON()`, its constructor reading that back, and
|
|
45961
|
+
* `static STATE`: the fields `restore` puts back.
|
|
45962
|
+
*/
|
|
45963
|
+
class EditableModel {
|
|
45964
|
+
/** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
|
|
45965
|
+
static STATE = Object.freeze(['name']);
|
|
45966
|
+
|
|
45967
|
+
/** @param {{name?: string}} [spec] */
|
|
45968
|
+
constructor(spec = {}) {
|
|
45969
|
+
if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
|
|
45970
|
+
/** What the model is called. */
|
|
45971
|
+
this.name = spec.name ?? '';
|
|
45972
|
+
this._version = 0;
|
|
45973
|
+
this._saved = 0;
|
|
45974
|
+
this._editListeners = [];
|
|
45975
|
+
}
|
|
45976
|
+
|
|
45977
|
+
/** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
|
|
45978
|
+
comparedKinds() {
|
|
45979
|
+
return [];
|
|
45980
|
+
}
|
|
45981
|
+
|
|
45982
|
+
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
45983
|
+
compareWith(other) {
|
|
45984
|
+
return new ModelComparison(this, other);
|
|
45985
|
+
}
|
|
45986
|
+
|
|
45987
|
+
/** Its undo history: a merge is one step on it. */
|
|
45988
|
+
get history() {
|
|
45989
|
+
return (this._history ??= new CommandHistory());
|
|
45990
|
+
}
|
|
45991
|
+
|
|
45992
|
+
/** A change made as one undo step (ModelEditCommand), not yet run. */
|
|
45993
|
+
editCommand(label, change) {
|
|
45994
|
+
return new ModelEditCommand(this, label, change);
|
|
45995
|
+
}
|
|
45996
|
+
|
|
45997
|
+
/** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
|
|
45998
|
+
restore(json) {
|
|
45999
|
+
const back = new this.constructor(JSON.parse(JSON.stringify(json)));
|
|
46000
|
+
for (const key of this.constructor.STATE) this[key] = back[key];
|
|
46001
|
+
}
|
|
46002
|
+
|
|
46003
|
+
// ===== Changed since saved =====
|
|
46004
|
+
|
|
46005
|
+
/** Something changed: listeners hear of it. */
|
|
46006
|
+
edited() {
|
|
46007
|
+
this._version++;
|
|
46008
|
+
this._emitEdit();
|
|
46009
|
+
}
|
|
46010
|
+
|
|
46011
|
+
/** This state is the saved one (a file opened or saved). */
|
|
46012
|
+
markSaved() {
|
|
46013
|
+
this._saved = this._version;
|
|
46014
|
+
this._emitEdit();
|
|
46015
|
+
}
|
|
46016
|
+
|
|
46017
|
+
/** Whether it changed since markSaved. */
|
|
46018
|
+
isDirty() {
|
|
46019
|
+
return this._version !== this._saved;
|
|
46020
|
+
}
|
|
46021
|
+
|
|
46022
|
+
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
46023
|
+
onEdit(fn) {
|
|
46024
|
+
this._editListeners.push(fn);
|
|
46025
|
+
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
46026
|
+
}
|
|
46027
|
+
|
|
46028
|
+
/** @private */
|
|
46029
|
+
_emitEdit() {
|
|
46030
|
+
for (const fn of this._editListeners) fn(this);
|
|
46031
|
+
}
|
|
46032
|
+
|
|
46033
|
+
/** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
|
|
46034
|
+
_freshId(name, taken) {
|
|
46035
|
+
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
46036
|
+
let id = base;
|
|
46037
|
+
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
46038
|
+
return id;
|
|
46039
|
+
}
|
|
46040
|
+
|
|
46041
|
+
/** The model as plain JSON, read back by its constructor. @abstract */
|
|
46042
|
+
toJSON() {
|
|
46043
|
+
throw new Error(`${this.constructor.name} must implement toJSON()`);
|
|
46044
|
+
}
|
|
45675
46045
|
}
|
|
45676
46046
|
|
|
45677
46047
|
/** Words that stay small inside a title. */
|
|
@@ -46325,8 +46695,42 @@
|
|
|
46325
46695
|
}
|
|
46326
46696
|
}
|
|
46327
46697
|
|
|
46698
|
+
/** A plain copy of JSON-shaped data. */
|
|
46699
|
+
const copy$2 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
46700
|
+
|
|
46701
|
+
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
46702
|
+
class ListCompared extends ComparedKind {
|
|
46703
|
+
constructor() {
|
|
46704
|
+
super();
|
|
46705
|
+
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
46706
|
+
}
|
|
46707
|
+
|
|
46708
|
+
/** The array the model holds them in. @abstract */
|
|
46709
|
+
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
46710
|
+
|
|
46711
|
+
items(model) { return this.list(model); }
|
|
46712
|
+
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
46713
|
+
|
|
46714
|
+
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
46715
|
+
bring(model, item) {
|
|
46716
|
+
const list = this.list(model);
|
|
46717
|
+
const twin = this.copyOf(item);
|
|
46718
|
+
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
46719
|
+
if (at < 0) {
|
|
46720
|
+
list.push(twin);
|
|
46721
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
46722
|
+
}
|
|
46723
|
+
const old = list[at];
|
|
46724
|
+
list[at] = twin;
|
|
46725
|
+
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
46726
|
+
}
|
|
46727
|
+
|
|
46728
|
+
/** A copy to put in the other model. */
|
|
46729
|
+
copyOf(item) { return copy$2(item); }
|
|
46730
|
+
}
|
|
46731
|
+
|
|
46328
46732
|
/** A plain copy of JSON-shaped data. */
|
|
46329
|
-
const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
46733
|
+
const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
46330
46734
|
|
|
46331
46735
|
/**
|
|
46332
46736
|
* The kinds two DataModels are compared on and merged by (ModelComparison,
|
|
@@ -46336,37 +46740,6 @@
|
|
|
46336
46740
|
* two models alike but for where their boxes are, are alike.
|
|
46337
46741
|
*/
|
|
46338
46742
|
|
|
46339
|
-
/** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
|
|
46340
|
-
class ListCompared extends ComparedKind {
|
|
46341
|
-
constructor() {
|
|
46342
|
-
super();
|
|
46343
|
-
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
46344
|
-
}
|
|
46345
|
-
|
|
46346
|
-
/** The array the model holds them in. @abstract */
|
|
46347
|
-
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
46348
|
-
|
|
46349
|
-
items(model) { return this.list(model); }
|
|
46350
|
-
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
46351
|
-
|
|
46352
|
-
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
46353
|
-
bring(model, item) {
|
|
46354
|
-
const list = this.list(model);
|
|
46355
|
-
const twin = this.copyOf(item);
|
|
46356
|
-
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
46357
|
-
if (at < 0) {
|
|
46358
|
-
list.push(twin);
|
|
46359
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
46360
|
-
}
|
|
46361
|
-
const old = list[at];
|
|
46362
|
-
list[at] = twin;
|
|
46363
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
46364
|
-
}
|
|
46365
|
-
|
|
46366
|
-
/** A copy to put in the other model. */
|
|
46367
|
-
copyOf(item) { return copy(item); }
|
|
46368
|
-
}
|
|
46369
|
-
|
|
46370
46743
|
/** One level's entities: compared on all but where they are drawn. */
|
|
46371
46744
|
class LevelEntitiesCompared extends ListCompared {
|
|
46372
46745
|
constructor(level) {
|
|
@@ -46380,7 +46753,7 @@
|
|
|
46380
46753
|
|
|
46381
46754
|
content(entity) {
|
|
46382
46755
|
const { x, y, ...rest } = entity;
|
|
46383
|
-
return copy(rest);
|
|
46756
|
+
return copy$1(rest);
|
|
46384
46757
|
}
|
|
46385
46758
|
}
|
|
46386
46759
|
|
|
@@ -46399,7 +46772,7 @@
|
|
|
46399
46772
|
list(model) { return model.level(this.level).relationships; }
|
|
46400
46773
|
idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
|
|
46401
46774
|
nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
|
|
46402
|
-
content(r) { return copy(r); }
|
|
46775
|
+
content(r) { return copy$1(r); }
|
|
46403
46776
|
requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
|
|
46404
46777
|
}
|
|
46405
46778
|
|
|
@@ -46426,8 +46799,8 @@
|
|
|
46426
46799
|
|
|
46427
46800
|
/** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
|
|
46428
46801
|
class ModelPartsCompared extends ListCompared {
|
|
46429
|
-
content(item) { return copy(item.toJSON()); }
|
|
46430
|
-
copyOf(item) { return new item.constructor(copy(item.toJSON())); }
|
|
46802
|
+
content(item) { return copy$1(item.toJSON()); }
|
|
46803
|
+
copyOf(item) { return new item.constructor(copy$1(item.toJSON())); }
|
|
46431
46804
|
}
|
|
46432
46805
|
|
|
46433
46806
|
class ContextsCompared extends ModelPartsCompared {
|
|
@@ -46469,11 +46842,13 @@
|
|
|
46469
46842
|
* (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
|
|
46470
46843
|
* three levels mapped one to one.
|
|
46471
46844
|
*
|
|
46472
|
-
*
|
|
46473
|
-
*
|
|
46474
|
-
* keep it and ask before it is lost.
|
|
46845
|
+
* What any model a part edits has — whether it changed since saved, its undo
|
|
46846
|
+
* history, compare and merge — is EditableModel's.
|
|
46475
46847
|
*/
|
|
46476
|
-
class DataModel {
|
|
46848
|
+
class DataModel extends EditableModel {
|
|
46849
|
+
/** What a snapshot puts back (EditableModel#restore). */
|
|
46850
|
+
static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
|
|
46851
|
+
|
|
46477
46852
|
/** The levels, outermost first. */
|
|
46478
46853
|
static LEVELS = LEVELS;
|
|
46479
46854
|
|
|
@@ -46557,11 +46932,7 @@
|
|
|
46557
46932
|
* @param {string} [spec.name] - What the model is called
|
|
46558
46933
|
*/
|
|
46559
46934
|
constructor(spec = {}) {
|
|
46560
|
-
|
|
46561
|
-
this.name = spec.name ?? '';
|
|
46562
|
-
this._version = 0;
|
|
46563
|
-
this._saved = 0;
|
|
46564
|
-
this._editListeners = [];
|
|
46935
|
+
super(spec);
|
|
46565
46936
|
const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
|
|
46566
46937
|
this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
|
|
46567
46938
|
this.mappings = LEVELS.slice(1).map((to, i) => {
|
|
@@ -46588,6 +46959,14 @@
|
|
|
46588
46959
|
return new ModelScorecard(this);
|
|
46589
46960
|
}
|
|
46590
46961
|
|
|
46962
|
+
/** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
|
|
46963
|
+
benchmark() {
|
|
46964
|
+
return [
|
|
46965
|
+
...Benchmark.findings(this),
|
|
46966
|
+
{ key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
|
|
46967
|
+
];
|
|
46968
|
+
}
|
|
46969
|
+
|
|
46591
46970
|
/** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
|
|
46592
46971
|
scorecardCommand(spec) {
|
|
46593
46972
|
return this.editCommand('Score the model', (model) => {
|
|
@@ -46635,46 +47014,6 @@
|
|
|
46635
47014
|
];
|
|
46636
47015
|
}
|
|
46637
47016
|
|
|
46638
|
-
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
46639
|
-
compareWith(other) {
|
|
46640
|
-
return new ModelComparison(this, other);
|
|
46641
|
-
}
|
|
46642
|
-
|
|
46643
|
-
/** Its undo history: a merge is one step on it. */
|
|
46644
|
-
get history() {
|
|
46645
|
-
return (this._history ??= new CommandHistory());
|
|
46646
|
-
}
|
|
46647
|
-
|
|
46648
|
-
// ===== Changed since saved =====
|
|
46649
|
-
|
|
46650
|
-
/** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
|
|
46651
|
-
edited() {
|
|
46652
|
-
this._version++;
|
|
46653
|
-
this._emitEdit();
|
|
46654
|
-
}
|
|
46655
|
-
|
|
46656
|
-
/** This state is the saved one (a file opened or saved). */
|
|
46657
|
-
markSaved() {
|
|
46658
|
-
this._saved = this._version;
|
|
46659
|
-
this._emitEdit();
|
|
46660
|
-
}
|
|
46661
|
-
|
|
46662
|
-
/** Whether it changed since markSaved. */
|
|
46663
|
-
isDirty() {
|
|
46664
|
-
return this._version !== this._saved;
|
|
46665
|
-
}
|
|
46666
|
-
|
|
46667
|
-
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
46668
|
-
onEdit(fn) {
|
|
46669
|
-
this._editListeners.push(fn);
|
|
46670
|
-
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
46671
|
-
}
|
|
46672
|
-
|
|
46673
|
-
/** @private */
|
|
46674
|
-
_emitEdit() {
|
|
46675
|
-
for (const fn of this._editListeners) fn(this);
|
|
46676
|
-
}
|
|
46677
|
-
|
|
46678
47017
|
/** A level's schema (LevelSchema). */
|
|
46679
47018
|
level(name) {
|
|
46680
47019
|
const schema = this.levels[name];
|
|
@@ -46890,13 +47229,7 @@
|
|
|
46890
47229
|
|
|
46891
47230
|
// ===== Perspectives and contexts of one's own =====
|
|
46892
47231
|
|
|
46893
|
-
// ===== Editing what a level holds (each
|
|
46894
|
-
|
|
46895
|
-
/** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
|
|
46896
|
-
restore(json) {
|
|
46897
|
-
const back = new DataModel(JSON.parse(JSON.stringify(json)));
|
|
46898
|
-
for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
|
|
46899
|
-
}
|
|
47232
|
+
// ===== Editing what a level holds (each an editCommand: one undo step) =====
|
|
46900
47233
|
|
|
46901
47234
|
/** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
|
|
46902
47235
|
classificationSchemeCommand(spec) {
|
|
@@ -46912,11 +47245,6 @@
|
|
|
46912
47245
|
});
|
|
46913
47246
|
}
|
|
46914
47247
|
|
|
46915
|
-
/** A change made as one undo step. */
|
|
46916
|
-
editCommand(label, change) {
|
|
46917
|
-
return new DataModelEditCommand(this, label, change);
|
|
46918
|
-
}
|
|
46919
|
-
|
|
46920
47248
|
/**
|
|
46921
47249
|
* Add an entity at a level (`id` null: one made from its name) or change
|
|
46922
47250
|
* one (what the spec leaves out, where it is drawn among it, kept).
|
|
@@ -47268,13 +47596,6 @@
|
|
|
47268
47596
|
}
|
|
47269
47597
|
|
|
47270
47598
|
/** An id from a name, not yet among `taken`. @private */
|
|
47271
|
-
_freshId(name, taken) {
|
|
47272
|
-
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
47273
|
-
let id = base;
|
|
47274
|
-
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
47275
|
-
return id;
|
|
47276
|
-
}
|
|
47277
|
-
|
|
47278
47599
|
toJSON() {
|
|
47279
47600
|
return {
|
|
47280
47601
|
name: this.name,
|
|
@@ -48374,6 +48695,853 @@
|
|
|
48374
48695
|
}
|
|
48375
48696
|
}
|
|
48376
48697
|
|
|
48698
|
+
/**
|
|
48699
|
+
* The kinds two RequirementsModels are compared on and merged by
|
|
48700
|
+
* (ModelComparison): epics, releases, then stories, in the order a merge
|
|
48701
|
+
* brings them — a story needs its epic and its release.
|
|
48702
|
+
*/
|
|
48703
|
+
|
|
48704
|
+
class EpicsCompared extends ListCompared {
|
|
48705
|
+
get key() { return 'epic'; }
|
|
48706
|
+
get label() { return 'Epics'; }
|
|
48707
|
+
list(model) { return model.epics; }
|
|
48708
|
+
content(epic) { return { ...epic }; }
|
|
48709
|
+
}
|
|
48710
|
+
|
|
48711
|
+
class ReleasesCompared extends ListCompared {
|
|
48712
|
+
get key() { return 'release'; }
|
|
48713
|
+
get label() { return 'Releases'; }
|
|
48714
|
+
list(model) { return model.releases; }
|
|
48715
|
+
content(release) { return { ...release }; }
|
|
48716
|
+
}
|
|
48717
|
+
|
|
48718
|
+
/** Stories: compared on everything, where they sit in the list aside. */
|
|
48719
|
+
class StoriesCompared extends ListCompared {
|
|
48720
|
+
get key() { return 'story'; }
|
|
48721
|
+
get label() { return 'Stories'; }
|
|
48722
|
+
list(model) { return model.stories; }
|
|
48723
|
+
content(story) { return JSON.parse(JSON.stringify(story)); }
|
|
48724
|
+
|
|
48725
|
+
requires(story) {
|
|
48726
|
+
return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
|
|
48727
|
+
}
|
|
48728
|
+
}
|
|
48729
|
+
|
|
48730
|
+
/**
|
|
48731
|
+
* RequirementsChecker — what a requirements model is missing, as a team
|
|
48732
|
+
* reviewing a backlog would say it: a story with no acceptance criteria
|
|
48733
|
+
* (nothing to say when it is done), one that says neither who wants it nor
|
|
48734
|
+
* what (no "As a … I want …"), one that traces to nothing (nothing realises
|
|
48735
|
+
* it), one under no epic, an epic with no stories, two stories of one name.
|
|
48736
|
+
*
|
|
48737
|
+
* With `resolve(trace)` — the host's way to find what a trace names in its
|
|
48738
|
+
* other models (true, false, or undefined when it cannot tell) — a trace to
|
|
48739
|
+
* something that is not there is found too.
|
|
48740
|
+
*
|
|
48741
|
+
* Findings name a story or an epic (`kind`), as ModelChecker's do.
|
|
48742
|
+
*/
|
|
48743
|
+
class RequirementsChecker extends ModelChecker {
|
|
48744
|
+
/**
|
|
48745
|
+
* @param {RequirementsModel} model
|
|
48746
|
+
* @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
|
|
48747
|
+
*/
|
|
48748
|
+
constructor(model, { resolve = null } = {}) {
|
|
48749
|
+
super(model);
|
|
48750
|
+
this.resolve = resolve;
|
|
48751
|
+
}
|
|
48752
|
+
|
|
48753
|
+
checks() {
|
|
48754
|
+
return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
|
|
48755
|
+
}
|
|
48756
|
+
|
|
48757
|
+
/** @private */
|
|
48758
|
+
_story(story, code, severity, message) {
|
|
48759
|
+
return { code, severity, kind: 'story', ids: [story.id], message };
|
|
48760
|
+
}
|
|
48761
|
+
|
|
48762
|
+
noCriteria() {
|
|
48763
|
+
return this.model.stories.filter(s => !s.criteria.length)
|
|
48764
|
+
.map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
|
|
48765
|
+
}
|
|
48766
|
+
|
|
48767
|
+
noNarrative() {
|
|
48768
|
+
return this.model.stories.filter(s => !s.want && !s.description)
|
|
48769
|
+
.map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
|
|
48770
|
+
}
|
|
48771
|
+
|
|
48772
|
+
untraced() {
|
|
48773
|
+
return this.model.stories.filter(s => !s.traces.length)
|
|
48774
|
+
.map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
|
|
48775
|
+
}
|
|
48776
|
+
|
|
48777
|
+
brokenTraces() {
|
|
48778
|
+
if (!this.resolve) return [];
|
|
48779
|
+
return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
|
|
48780
|
+
.map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
|
|
48781
|
+
}
|
|
48782
|
+
|
|
48783
|
+
noEpic() {
|
|
48784
|
+
if (!this.model.epics.length) return [];
|
|
48785
|
+
return this.model.stories.filter(s => !this.model.epic(s.epic))
|
|
48786
|
+
.map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
|
|
48787
|
+
}
|
|
48788
|
+
|
|
48789
|
+
emptyEpics() {
|
|
48790
|
+
return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
|
|
48791
|
+
.map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
|
|
48792
|
+
}
|
|
48793
|
+
|
|
48794
|
+
sameNames() {
|
|
48795
|
+
const byName = new Map();
|
|
48796
|
+
for (const s of this.model.stories) {
|
|
48797
|
+
const key = s.name.trim().toLowerCase();
|
|
48798
|
+
if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
|
|
48799
|
+
}
|
|
48800
|
+
return [...byName.values()].filter(list => list.length > 1)
|
|
48801
|
+
.map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
|
|
48802
|
+
}
|
|
48803
|
+
}
|
|
48804
|
+
|
|
48805
|
+
/** A plain copy of JSON-shaped data. */
|
|
48806
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
48807
|
+
|
|
48808
|
+
/**
|
|
48809
|
+
* What a story can trace to, and what its trace is called: ArchiMate's
|
|
48810
|
+
* motivation and implementation elements, any other element of an
|
|
48811
|
+
* architecture, and a data model's concepts, entities and tables — so a trace
|
|
48812
|
+
* runs epic → story → requirement → component → entity → table. A trace's
|
|
48813
|
+
* `kind` is any string; these are the ones offered.
|
|
48814
|
+
*/
|
|
48815
|
+
const TRACE_KINDS = Object.freeze([
|
|
48816
|
+
['requirement', 'Requirement (ArchiMate)'],
|
|
48817
|
+
['work-package', 'Work package (ArchiMate)'],
|
|
48818
|
+
['element', 'Architecture element'],
|
|
48819
|
+
['concept', 'Data concept'],
|
|
48820
|
+
['entity', 'Data entity'],
|
|
48821
|
+
['table', 'Table'],
|
|
48822
|
+
]);
|
|
48823
|
+
|
|
48824
|
+
/** Where a story is in its life, in order; a status is any string, these are offered. */
|
|
48825
|
+
const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
|
|
48826
|
+
|
|
48827
|
+
/**
|
|
48828
|
+
* RequirementsModel — what a system is to do, as an agile team writes it:
|
|
48829
|
+
* **epics** (large outcomes), the **stories** that deliver them ("As a … I
|
|
48830
|
+
* want … so that …"), each with its **acceptance criteria**, sliced into
|
|
48831
|
+
* **releases**; and **traces** from a story to what realises it in the other
|
|
48832
|
+
* models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
|
|
48833
|
+
* a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
|
|
48834
|
+
* model's id).
|
|
48835
|
+
*
|
|
48836
|
+
* Read as a **story map** (`storyMap()`): epics across in order, releases
|
|
48837
|
+
* down, each cell the stories of that epic in that release; stories with no
|
|
48838
|
+
* epic or no release have a column and a row of their own. `coverage()` and
|
|
48839
|
+
* the checker (RequirementsChecker) say what is missing — a story with no
|
|
48840
|
+
* acceptance criteria, nothing realising it — the numbers a bench measures.
|
|
48841
|
+
*
|
|
48842
|
+
* Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
|
|
48843
|
+
* [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
|
|
48844
|
+
* description?, criteria: [{id, text}], status?, priority?, points?, labels?,
|
|
48845
|
+
* source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
|
|
48846
|
+
* Ids are made from names when not given. Every edit is a command (one undo
|
|
48847
|
+
* step); compare and merge, and changed-since-saved, are EditableModel's.
|
|
48848
|
+
*/
|
|
48849
|
+
class RequirementsModel extends EditableModel {
|
|
48850
|
+
static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
|
|
48851
|
+
|
|
48852
|
+
static TRACE_KINDS = TRACE_KINDS;
|
|
48853
|
+
|
|
48854
|
+
static STATUSES = STATUSES;
|
|
48855
|
+
|
|
48856
|
+
/** A story map's column for stories with no epic, and its row for those in no release. */
|
|
48857
|
+
static NO_EPIC = '';
|
|
48858
|
+
static NO_RELEASE = '';
|
|
48859
|
+
|
|
48860
|
+
constructor(spec = {}) {
|
|
48861
|
+
super(spec);
|
|
48862
|
+
this.epics = [];
|
|
48863
|
+
this.releases = [];
|
|
48864
|
+
this.stories = [];
|
|
48865
|
+
for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
|
|
48866
|
+
for (const r of spec.releases ?? []) this.releases.push(this._release(r));
|
|
48867
|
+
for (const s of spec.stories ?? []) this.stories.push(this._story(s));
|
|
48868
|
+
}
|
|
48869
|
+
|
|
48870
|
+
// ===== Reading =====
|
|
48871
|
+
|
|
48872
|
+
epic(id) { return this.epics.find(e => e.id === id) ?? null; }
|
|
48873
|
+
release(id) { return this.releases.find(r => r.id === id) ?? null; }
|
|
48874
|
+
story(id) { return this.stories.find(s => s.id === id) ?? null; }
|
|
48875
|
+
|
|
48876
|
+
/** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
|
|
48877
|
+
storiesOf(epicId, release) {
|
|
48878
|
+
return this.stories.filter(s => (s.epic || '') === (epicId || '')
|
|
48879
|
+
&& (release === undefined || (s.release || '') === (release || '')));
|
|
48880
|
+
}
|
|
48881
|
+
|
|
48882
|
+
/** The story's sentence: "As a … I want … so that …", from what it has. */
|
|
48883
|
+
static narrative(story) {
|
|
48884
|
+
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}`]
|
|
48885
|
+
.filter(Boolean).join(', ');
|
|
48886
|
+
}
|
|
48887
|
+
|
|
48888
|
+
/**
|
|
48889
|
+
* The story map: `columns` the epics in order (then NO_EPIC, if any story
|
|
48890
|
+
* has none), `rows` the releases in order (then NO_RELEASE, if any story is
|
|
48891
|
+
* in none, or there are no releases), and `cell(epicId, releaseId)` the
|
|
48892
|
+
* stories there, in order.
|
|
48893
|
+
*/
|
|
48894
|
+
storyMap() {
|
|
48895
|
+
const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
|
|
48896
|
+
if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
|
|
48897
|
+
const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
|
|
48898
|
+
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 });
|
|
48899
|
+
const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
|
|
48900
|
+
const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
|
|
48901
|
+
return {
|
|
48902
|
+
columns, rows,
|
|
48903
|
+
cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
|
|
48904
|
+
};
|
|
48905
|
+
}
|
|
48906
|
+
|
|
48907
|
+
/** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
|
|
48908
|
+
tracingTo(ref, kind) {
|
|
48909
|
+
return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
|
|
48910
|
+
}
|
|
48911
|
+
|
|
48912
|
+
/**
|
|
48913
|
+
* How complete it is, as numbers to measure by: stories, how many have
|
|
48914
|
+
* acceptance criteria, how many trace to something, epics with no stories,
|
|
48915
|
+
* and each as a share (0..1; 1 with nothing to count).
|
|
48916
|
+
*/
|
|
48917
|
+
coverage() {
|
|
48918
|
+
const stories = this.stories.length;
|
|
48919
|
+
const withCriteria = this.stories.filter(s => s.criteria.length).length;
|
|
48920
|
+
const traced = this.stories.filter(s => s.traces.length).length;
|
|
48921
|
+
const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
|
|
48922
|
+
const share = (n, of) => (of ? n / of : 1);
|
|
48923
|
+
return {
|
|
48924
|
+
stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
|
|
48925
|
+
criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
|
|
48926
|
+
};
|
|
48927
|
+
}
|
|
48928
|
+
|
|
48929
|
+
/** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
|
|
48930
|
+
benchmark() {
|
|
48931
|
+
const c = this.coverage();
|
|
48932
|
+
return [
|
|
48933
|
+
...Benchmark.findings(this),
|
|
48934
|
+
{ key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
|
|
48935
|
+
{ key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
|
|
48936
|
+
];
|
|
48937
|
+
}
|
|
48938
|
+
|
|
48939
|
+
/** Its checks (RequirementsChecker). */
|
|
48940
|
+
checker(options) {
|
|
48941
|
+
return new RequirementsChecker(this, options);
|
|
48942
|
+
}
|
|
48943
|
+
|
|
48944
|
+
comparedKinds() {
|
|
48945
|
+
return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
|
|
48946
|
+
}
|
|
48947
|
+
|
|
48948
|
+
// ===== Editing (each an editCommand: one undo step) =====
|
|
48949
|
+
|
|
48950
|
+
/** Add an epic (`id` null) or change one. The command's result is the epic. */
|
|
48951
|
+
saveEpicCommand(id, spec) {
|
|
48952
|
+
return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
|
|
48953
|
+
if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
|
|
48954
|
+
const epic = model.epic(id);
|
|
48955
|
+
if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
|
|
48956
|
+
return epic;
|
|
48957
|
+
});
|
|
48958
|
+
}
|
|
48959
|
+
|
|
48960
|
+
/** Remove an epic; its stories stay, with no epic. */
|
|
48961
|
+
removeEpicCommand(id) {
|
|
48962
|
+
return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
|
|
48963
|
+
model.epics = model.epics.filter(e => e.id !== id);
|
|
48964
|
+
for (const s of model.stories) if (s.epic === id) s.epic = '';
|
|
48965
|
+
});
|
|
48966
|
+
}
|
|
48967
|
+
|
|
48968
|
+
/** Add a release (`id` null) or rename one. */
|
|
48969
|
+
saveReleaseCommand(id, spec) {
|
|
48970
|
+
return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
|
|
48971
|
+
if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
|
|
48972
|
+
const release = model.release(id);
|
|
48973
|
+
if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
|
|
48974
|
+
return release;
|
|
48975
|
+
});
|
|
48976
|
+
}
|
|
48977
|
+
|
|
48978
|
+
/** Remove a release; its stories stay, planned in none. */
|
|
48979
|
+
removeReleaseCommand(id) {
|
|
48980
|
+
return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
|
|
48981
|
+
model.releases = model.releases.filter(r => r.id !== id);
|
|
48982
|
+
for (const s of model.stories) if (s.release === id) s.release = '';
|
|
48983
|
+
});
|
|
48984
|
+
}
|
|
48985
|
+
|
|
48986
|
+
/** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
|
|
48987
|
+
saveStoryCommand(id, spec) {
|
|
48988
|
+
return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
|
|
48989
|
+
if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
|
|
48990
|
+
const at = model.stories.findIndex(s => s.id === id);
|
|
48991
|
+
if (at < 0) return null;
|
|
48992
|
+
model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
|
|
48993
|
+
return model.stories[at];
|
|
48994
|
+
});
|
|
48995
|
+
}
|
|
48996
|
+
|
|
48997
|
+
removeStoryCommand(id) {
|
|
48998
|
+
return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
|
|
48999
|
+
model.stories = model.stories.filter(s => s.id !== id);
|
|
49000
|
+
});
|
|
49001
|
+
}
|
|
49002
|
+
|
|
49003
|
+
/**
|
|
49004
|
+
* Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
|
|
49005
|
+
* none), before another story there (`before`; none: last).
|
|
49006
|
+
*/
|
|
49007
|
+
moveStoryCommand(id, { epic, release, before = null }) {
|
|
49008
|
+
return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
|
|
49009
|
+
const story = model.story(id);
|
|
49010
|
+
if (!story || before === id) return;
|
|
49011
|
+
if (epic !== undefined) story.epic = epic || '';
|
|
49012
|
+
if (release !== undefined) story.release = release || '';
|
|
49013
|
+
model.stories.splice(model.stories.indexOf(story), 1);
|
|
49014
|
+
const at = before ? model.stories.findIndex(s => s.id === before) : -1;
|
|
49015
|
+
model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
|
|
49016
|
+
});
|
|
49017
|
+
}
|
|
49018
|
+
|
|
49019
|
+
/** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
|
|
49020
|
+
traceCommand(storyId, trace) {
|
|
49021
|
+
return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
|
|
49022
|
+
const story = model.story(storyId);
|
|
49023
|
+
if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
|
|
49024
|
+
story.traces.push(RequirementsModel._trace(trace));
|
|
49025
|
+
});
|
|
49026
|
+
}
|
|
49027
|
+
|
|
49028
|
+
/** Remove a story's trace to `ref` (of `kind`, if given). */
|
|
49029
|
+
untraceCommand(storyId, ref, kind) {
|
|
49030
|
+
return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
|
|
49031
|
+
const story = model.story(storyId);
|
|
49032
|
+
if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
|
|
49033
|
+
});
|
|
49034
|
+
}
|
|
49035
|
+
|
|
49036
|
+
/**
|
|
49037
|
+
* Bring epics, releases and stories in (an import): one undo step. One with
|
|
49038
|
+
* the id of one there replaces it; the rest are added.
|
|
49039
|
+
*/
|
|
49040
|
+
bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
|
|
49041
|
+
return this.editCommand(label, (model) => {
|
|
49042
|
+
const put = (list, item) => {
|
|
49043
|
+
const at = list.findIndex(x => x.id === item.id);
|
|
49044
|
+
if (at < 0) list.push(item); else list[at] = item;
|
|
49045
|
+
};
|
|
49046
|
+
for (const e of epics) put(model.epics, model._epic(e));
|
|
49047
|
+
for (const r of releases) put(model.releases, model._release(r));
|
|
49048
|
+
for (const s of stories) put(model.stories, model._story(s));
|
|
49049
|
+
});
|
|
49050
|
+
}
|
|
49051
|
+
|
|
49052
|
+
// ===== Its parts, read from a spec =====
|
|
49053
|
+
|
|
49054
|
+
/** @private */
|
|
49055
|
+
_epic({ id, name = '', description = '', source } = {}) {
|
|
49056
|
+
return {
|
|
49057
|
+
id: id ?? this._freshId(name || 'epic', this.epics), name, description,
|
|
49058
|
+
...(source && { source: copy(source) }),
|
|
49059
|
+
};
|
|
49060
|
+
}
|
|
49061
|
+
|
|
49062
|
+
/** @private */
|
|
49063
|
+
_release({ id, name = '' } = {}) {
|
|
49064
|
+
return { id: id ?? this._freshId(name || 'release', this.releases), name };
|
|
49065
|
+
}
|
|
49066
|
+
|
|
49067
|
+
/** @private */
|
|
49068
|
+
_story(spec = {}) {
|
|
49069
|
+
const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
|
|
49070
|
+
const story = {
|
|
49071
|
+
id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
|
|
49072
|
+
as, want, soThat, description, status, priority,
|
|
49073
|
+
points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
|
|
49074
|
+
labels: [...labels],
|
|
49075
|
+
criteria: [],
|
|
49076
|
+
traces: traces.map(t => RequirementsModel._trace(t)),
|
|
49077
|
+
...(source && { source: copy(source) }),
|
|
49078
|
+
};
|
|
49079
|
+
// A new criterion is numbered past every id the story's criteria have, so none is given twice.
|
|
49080
|
+
const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
|
|
49081
|
+
let n = 0;
|
|
49082
|
+
const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
|
|
49083
|
+
for (const c of criteria) {
|
|
49084
|
+
const text = typeof c === 'string' ? c : c?.text;
|
|
49085
|
+
if (!String(text ?? '').trim()) continue;
|
|
49086
|
+
story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
|
|
49087
|
+
}
|
|
49088
|
+
return story;
|
|
49089
|
+
}
|
|
49090
|
+
|
|
49091
|
+
/** @private */
|
|
49092
|
+
static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
|
|
49093
|
+
return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
|
|
49094
|
+
}
|
|
49095
|
+
|
|
49096
|
+
toJSON() {
|
|
49097
|
+
return {
|
|
49098
|
+
name: this.name,
|
|
49099
|
+
epics: copy(this.epics),
|
|
49100
|
+
releases: copy(this.releases),
|
|
49101
|
+
stories: copy(this.stories),
|
|
49102
|
+
};
|
|
49103
|
+
}
|
|
49104
|
+
}
|
|
49105
|
+
|
|
49106
|
+
/**
|
|
49107
|
+
* RequirementsFileFormat — a requirements model as a file
|
|
49108
|
+
* (`.requirements.json`): its epics, releases and stories
|
|
49109
|
+
* (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
|
|
49110
|
+
*/
|
|
49111
|
+
class RequirementsFileFormat extends ModelFileFormat {
|
|
49112
|
+
get label() { return '.requirements.json'; }
|
|
49113
|
+
get accept() { return '.requirements.json,.json'; }
|
|
49114
|
+
get fileName() { return 'model.requirements.json'; }
|
|
49115
|
+
get featureName() { return 'Requirements'; }
|
|
49116
|
+
|
|
49117
|
+
/** JSON with a requirements model's shape: stories, or epics. */
|
|
49118
|
+
recognizes(text) {
|
|
49119
|
+
try {
|
|
49120
|
+
const json = JSON.parse(text);
|
|
49121
|
+
return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
|
|
49122
|
+
} catch {
|
|
49123
|
+
return false;
|
|
49124
|
+
}
|
|
49125
|
+
}
|
|
49126
|
+
|
|
49127
|
+
export(model) {
|
|
49128
|
+
return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
|
|
49129
|
+
}
|
|
49130
|
+
|
|
49131
|
+
import(text) {
|
|
49132
|
+
return new RequirementsModel(JSON.parse(text));
|
|
49133
|
+
}
|
|
49134
|
+
}
|
|
49135
|
+
|
|
49136
|
+
/**
|
|
49137
|
+
* Where each tracker's export keeps what a story needs: per tracker, the
|
|
49138
|
+
* column names (CSV) each field may be under, first found wins; which issue
|
|
49139
|
+
* types are epics; how its list fields are separated. One definition per
|
|
49140
|
+
* tracker — the CSV and the JSON readers both map onto these fields. A host
|
|
49141
|
+
* gives its own (a custom field's column) through `new TrackerExport({ columns })`.
|
|
49142
|
+
*/
|
|
49143
|
+
const TRACKERS = Object.freeze({
|
|
49144
|
+
jira: {
|
|
49145
|
+
label: 'Jira',
|
|
49146
|
+
// Columns only its exports have, to tell it by.
|
|
49147
|
+
marks: ['Issue key', 'Issue Type'],
|
|
49148
|
+
columns: {
|
|
49149
|
+
key: ['Issue key', 'Key'], id: ['Issue id'], type: ['Issue Type'], title: ['Summary'],
|
|
49150
|
+
description: ['Description'], parent: ['Parent', 'Parent id', 'Parent key', 'Custom field (Epic Link)', 'Epic Link'],
|
|
49151
|
+
status: ['Status'], priority: ['Priority'],
|
|
49152
|
+
points: ['Custom field (Story Points)', 'Story Points', 'Custom field (Story point estimate)', 'Story point estimate'],
|
|
49153
|
+
release: ['Fix Version/s', 'Fix versions', 'Sprint'], labels: ['Labels'],
|
|
49154
|
+
criteria: ['Custom field (Acceptance Criteria)', 'Acceptance Criteria'], url: [],
|
|
49155
|
+
},
|
|
49156
|
+
epicTypes: ['Epic'], skipTypes: ['Sub-task', 'Subtask'], labelSeparator: /[\s,]+/,
|
|
49157
|
+
},
|
|
49158
|
+
azure: {
|
|
49159
|
+
label: 'Azure DevOps',
|
|
49160
|
+
marks: ['Work Item Type'],
|
|
49161
|
+
columns: {
|
|
49162
|
+
key: ['ID'], id: ['ID'], type: ['Work Item Type'], title: ['Title', 'Title 1', 'Title 2', 'Title 3'],
|
|
49163
|
+
description: ['Description'], parent: ['Parent'], status: ['State'], priority: ['Priority'],
|
|
49164
|
+
points: ['Story Points', 'Effort', 'Size'], release: ['Iteration Path'], labels: ['Tags'],
|
|
49165
|
+
criteria: ['Acceptance Criteria'], url: [],
|
|
49166
|
+
},
|
|
49167
|
+
epicTypes: ['Epic', 'Feature'], skipTypes: ['Task'], labelSeparator: /\s*;\s*/,
|
|
49168
|
+
},
|
|
49169
|
+
gitlab: {
|
|
49170
|
+
label: 'GitLab',
|
|
49171
|
+
marks: ['Issue ID', 'URL'],
|
|
49172
|
+
columns: {
|
|
49173
|
+
key: ['Issue ID', 'IID'], id: ['Issue ID', 'IID'], type: [], title: ['Title'], description: ['Description'],
|
|
49174
|
+
parent: ['Epic ID'], parentName: ['Epic Title'], status: ['State'], priority: [], points: ['Weight'],
|
|
49175
|
+
release: ['Milestone'], labels: ['Labels'], criteria: [], url: ['URL'],
|
|
49176
|
+
},
|
|
49177
|
+
epicTypes: [], skipTypes: [], labelSeparator: /\s*,\s*/,
|
|
49178
|
+
},
|
|
49179
|
+
});
|
|
49180
|
+
|
|
49181
|
+
/**
|
|
49182
|
+
* Where Jira's JSON keeps what has no field of its own: custom fields, by key.
|
|
49183
|
+
* Jira Cloud's "Story point estimate" is customfield_10016 unless a site
|
|
49184
|
+
* changed it; a host names its own (`columns: { points: ['customfield_10026'] }`).
|
|
49185
|
+
*/
|
|
49186
|
+
const JIRA_FIELDS = Object.freeze({
|
|
49187
|
+
points: ['story_points', 'storyPoints', 'customfield_10016'],
|
|
49188
|
+
criteria: ['acceptanceCriteria'],
|
|
49189
|
+
});
|
|
49190
|
+
|
|
49191
|
+
/** Text with HTML in it (Azure DevOps keeps descriptions as HTML) as lines of plain text. */
|
|
49192
|
+
function plainText(value) {
|
|
49193
|
+
if (value == null) return '';
|
|
49194
|
+
if (typeof value === 'object') return documentText(value);
|
|
49195
|
+
return String(value)
|
|
49196
|
+
.replace(/<\s*(br|\/p|\/div|\/li|\/h\d)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ')
|
|
49197
|
+
.replace(/<[^>]+>/g, '')
|
|
49198
|
+
.replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
49199
|
+
.replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
49200
|
+
}
|
|
49201
|
+
|
|
49202
|
+
/** Jira Cloud's description, a document of nodes, as text: each paragraph and list item a line. */
|
|
49203
|
+
function documentText(node) {
|
|
49204
|
+
if (!node || typeof node !== 'object') return '';
|
|
49205
|
+
if (node.type === 'text') return node.text ?? '';
|
|
49206
|
+
const inner = (node.content ?? []).map(documentText).join('');
|
|
49207
|
+
return ['paragraph', 'heading', 'listItem', 'codeBlock'].includes(node.type) ? `${node.type === 'listItem' ? '- ' : ''}${inner.trim()}\n` : inner;
|
|
49208
|
+
}
|
|
49209
|
+
|
|
49210
|
+
/** The lines of a list: "- x", "* x", "1. x", "• x", "[ ] x" — each item a line, its marker gone. */
|
|
49211
|
+
function listItems(text) {
|
|
49212
|
+
return String(text ?? '').split('\n')
|
|
49213
|
+
.map(line => line.replace(/^\s*(?:[-*•+]|\d+[.)]|\[[ xX]?\])\s+/, '').trim())
|
|
49214
|
+
.filter(Boolean);
|
|
49215
|
+
}
|
|
49216
|
+
|
|
49217
|
+
/**
|
|
49218
|
+
* Acceptance criteria in a story's text: the lines under an "Acceptance
|
|
49219
|
+
* criteria" heading, else its Given/When/Then scenarios (one each), else none.
|
|
49220
|
+
* @returns {{criteria: Array<string>, rest: string}} - and the text without them
|
|
49221
|
+
*/
|
|
49222
|
+
function criteriaFrom(text) {
|
|
49223
|
+
const s = plainText(text);
|
|
49224
|
+
const heading = /^\s*(?:#+\s*|\*\*)?acceptance criteria\b[^\n]*\n/im.exec(s);
|
|
49225
|
+
if (heading) {
|
|
49226
|
+
const after = s.slice(heading.index + heading[0].length);
|
|
49227
|
+
const end = after.search(/\n\s*\n\s*(?:#+\s|\*\*|[A-Z][^\n:]{0,40}:\s*\n)/);
|
|
49228
|
+
const block = end < 0 ? after : after.slice(0, end);
|
|
49229
|
+
return { criteria: listItems(block), rest: (s.slice(0, heading.index) + (end < 0 ? '' : after.slice(end))).replace(/\n{3,}/g, '\n\n').trim() };
|
|
49230
|
+
}
|
|
49231
|
+
const scenarios = [];
|
|
49232
|
+
let current = null;
|
|
49233
|
+
for (const line of s.split('\n')) {
|
|
49234
|
+
const t = line.replace(/^\s*(?:[-*•]\s+)?/, '').trim();
|
|
49235
|
+
if (/^given\b/i.test(t)) { current = [t]; scenarios.push(current); } else if (current && /^(when|then|and|but)\b/i.test(t)) current.push(t); else current = null;
|
|
49236
|
+
}
|
|
49237
|
+
if (scenarios.length) return { criteria: scenarios.map(lines => lines.join(' ')), rest: s.split('\n').filter(l => !/^\s*(?:[-*•]\s+)?(given|when|then|and|but)\b/i.test(l)).join('\n').trim() };
|
|
49238
|
+
return { criteria: [], rest: s };
|
|
49239
|
+
}
|
|
49240
|
+
|
|
49241
|
+
/** A field that holds only acceptance criteria: as criteriaFrom reads them, else each of its lines one. */
|
|
49242
|
+
function criteriaField(text) {
|
|
49243
|
+
const found = criteriaFrom(text).criteria;
|
|
49244
|
+
return found.length ? found : listItems(plainText(text));
|
|
49245
|
+
}
|
|
49246
|
+
|
|
49247
|
+
/** "As a <who>, I want <what> so that <why>" in a text: its three parts (empty where it says nothing). */
|
|
49248
|
+
function narrativeFrom(text) {
|
|
49249
|
+
const m = /\bas an?\s+([^,\n]+?)\s*,?\s+i\s+(?:want|need|would like)\s+([^\n]+?)(?:\s*,?\s+so\s+that\s+([^\n]+?))?\s*(?:[.\n]|$)/i.exec(plainText(text));
|
|
49250
|
+
return m ? { as: m[1].trim(), want: m[2].trim(), soThat: (m[3] ?? '').trim() } : { as: '', want: '', soThat: '' };
|
|
49251
|
+
}
|
|
49252
|
+
|
|
49253
|
+
/** A text without its "As a … I want …" line: what is left to describe the story, once its sentence is read (narrativeFrom). */
|
|
49254
|
+
function withoutNarrative(text) {
|
|
49255
|
+
return String(text ?? '').replace(/^\s*\**\s*as an?\s[^\n]*\bi\s+(want|need|would like)\b[^\n]*\n?/im, '').trim();
|
|
49256
|
+
}
|
|
49257
|
+
|
|
49258
|
+
/**
|
|
49259
|
+
* TrackerExport — epics and stories from what an issue tracker exports: Jira,
|
|
49260
|
+
* Azure DevOps or GitLab (TRACKERS), as CSV or as their API's JSON. An issue
|
|
49261
|
+
* of an epic type becomes an epic (GitLab's epics are named by their issues'
|
|
49262
|
+
* Epic ID and title); the rest become stories under their parent epic, with
|
|
49263
|
+
* their status, priority, points, labels and release (fix version, iteration
|
|
49264
|
+
* or milestone). Acceptance criteria come from their own field, else from
|
|
49265
|
+
* the description (criteriaFrom); "As a … I want … so that …" from the
|
|
49266
|
+
* description or the title (narrativeFrom). Each keeps where it came from
|
|
49267
|
+
* (`source: {system, key, url?}`), and its id is the tracker's key, so
|
|
49268
|
+
* reading a newer export over a model replaces what it had.
|
|
49269
|
+
*
|
|
49270
|
+
* `read(text)` gives a RequirementsModel spec (`{epics, releases, stories}`).
|
|
49271
|
+
*/
|
|
49272
|
+
class TrackerExport {
|
|
49273
|
+
/**
|
|
49274
|
+
* @param {Object} [options]
|
|
49275
|
+
* @param {string} [options.tracker] - 'jira', 'azure', 'gitlab' (default: told from the export)
|
|
49276
|
+
* @param {Object<string, Array<string>>} [options.columns] - Column names to look in first, by field (in Jira's JSON, field keys)
|
|
49277
|
+
*/
|
|
49278
|
+
constructor({ tracker = null, columns = {} } = {}) {
|
|
49279
|
+
this.tracker = tracker;
|
|
49280
|
+
this.columns = columns;
|
|
49281
|
+
}
|
|
49282
|
+
|
|
49283
|
+
/** What a tracker is called ('Jira'). */
|
|
49284
|
+
static label(tracker) {
|
|
49285
|
+
return TRACKERS[tracker]?.label ?? 'Tracker';
|
|
49286
|
+
}
|
|
49287
|
+
|
|
49288
|
+
/** Which tracker an export's columns (or JSON) are from, or null. */
|
|
49289
|
+
static detect(text) {
|
|
49290
|
+
const json = TrackerExport._json(text);
|
|
49291
|
+
if (json) {
|
|
49292
|
+
if (Array.isArray(json.issues) && json.issues[0]?.fields) return 'jira';
|
|
49293
|
+
if (Array.isArray(json.value ?? json.workItems) && (json.value ?? json.workItems)[0]?.fields) return 'azure';
|
|
49294
|
+
if (Array.isArray(json) && json[0] && 'iid' in json[0]) return 'gitlab';
|
|
49295
|
+
return null;
|
|
49296
|
+
}
|
|
49297
|
+
const { columns } = parseCsvTable(text);
|
|
49298
|
+
const has = new Set(columns.map(c => c.toLowerCase()));
|
|
49299
|
+
const found = Object.entries(TRACKERS).find(([, t]) => t.marks.every(m => has.has(m.toLowerCase())));
|
|
49300
|
+
return found?.[0] ?? null;
|
|
49301
|
+
}
|
|
49302
|
+
|
|
49303
|
+
/** @private */
|
|
49304
|
+
static _json(text) {
|
|
49305
|
+
const s = String(text ?? '').trim();
|
|
49306
|
+
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
49307
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
49308
|
+
}
|
|
49309
|
+
|
|
49310
|
+
/**
|
|
49311
|
+
* The export as a requirements model's spec.
|
|
49312
|
+
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
49313
|
+
*/
|
|
49314
|
+
read(text) {
|
|
49315
|
+
const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
|
|
49316
|
+
const def = TRACKERS[tracker];
|
|
49317
|
+
if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
|
|
49318
|
+
const json = TrackerExport._json(text);
|
|
49319
|
+
const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
|
|
49320
|
+
return { ...this._model(tracker, def, issues), tracker };
|
|
49321
|
+
}
|
|
49322
|
+
|
|
49323
|
+
/** CSV rows as issues: each field from the first of its columns there. @private */
|
|
49324
|
+
_fromCsv(def, text) {
|
|
49325
|
+
const { columns, rows } = parseCsvTable(text);
|
|
49326
|
+
const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
|
|
49327
|
+
const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
|
|
49328
|
+
const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
|
|
49329
|
+
const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
|
|
49330
|
+
return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
|
|
49331
|
+
}
|
|
49332
|
+
|
|
49333
|
+
/** An API's JSON as issues, in the CSV's fields. @private */
|
|
49334
|
+
_fromJson(tracker, json) {
|
|
49335
|
+
if (tracker === 'jira') {
|
|
49336
|
+
return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
|
|
49337
|
+
const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
|
|
49338
|
+
// A field the host named (its custom field's key), else Jira Cloud's usual one.
|
|
49339
|
+
const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
|
|
49340
|
+
return {
|
|
49341
|
+
key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
|
|
49342
|
+
parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
|
|
49343
|
+
points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
|
|
49344
|
+
release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
|
|
49345
|
+
criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
|
|
49346
|
+
};
|
|
49347
|
+
});
|
|
49348
|
+
}
|
|
49349
|
+
if (tracker === 'azure') {
|
|
49350
|
+
return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
|
|
49351
|
+
key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
|
|
49352
|
+
description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
|
|
49353
|
+
priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
|
|
49354
|
+
points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
|
|
49355
|
+
release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
|
|
49356
|
+
criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
|
|
49357
|
+
}));
|
|
49358
|
+
}
|
|
49359
|
+
return (Array.isArray(json) ? json : []).map(i => ({
|
|
49360
|
+
key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
|
|
49361
|
+
parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
|
|
49362
|
+
points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
|
|
49363
|
+
criteria: '', url: i.web_url ?? '',
|
|
49364
|
+
}));
|
|
49365
|
+
}
|
|
49366
|
+
|
|
49367
|
+
/** Issues as epics, releases and stories. @private */
|
|
49368
|
+
_model(tracker, def, issues) {
|
|
49369
|
+
const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
|
|
49370
|
+
const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
|
|
49371
|
+
for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
|
|
49372
|
+
const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
|
|
49373
|
+
|
|
49374
|
+
const epics = new Map();
|
|
49375
|
+
for (const i of issues) {
|
|
49376
|
+
if (!is(def.epicTypes, i.type)) continue;
|
|
49377
|
+
const key = String(i.key || i.id);
|
|
49378
|
+
epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
|
|
49379
|
+
}
|
|
49380
|
+
const releases = new Map();
|
|
49381
|
+
const stories = [];
|
|
49382
|
+
for (const i of issues) {
|
|
49383
|
+
if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
|
|
49384
|
+
const key = String(i.key || i.id);
|
|
49385
|
+
const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
|
|
49386
|
+
if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
|
|
49387
|
+
const release = plainText(i.release);
|
|
49388
|
+
if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
|
|
49389
|
+
const own = criteriaField(i.criteria);
|
|
49390
|
+
const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
|
|
49391
|
+
const said = narrativeFrom(i.description);
|
|
49392
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
|
|
49393
|
+
stories.push({
|
|
49394
|
+
id: key, name: plainText(i.title), epic: parent, release,
|
|
49395
|
+
as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
|
|
49396
|
+
criteria: own.length ? own : inText.criteria,
|
|
49397
|
+
status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
|
|
49398
|
+
labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
|
|
49399
|
+
source: source(i, key), traces: [],
|
|
49400
|
+
});
|
|
49401
|
+
}
|
|
49402
|
+
// A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
|
|
49403
|
+
for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
|
|
49404
|
+
return { epics: [...epics.values()], releases: [...releases.values()], stories };
|
|
49405
|
+
}
|
|
49406
|
+
}
|
|
49407
|
+
|
|
49408
|
+
/**
|
|
49409
|
+
* Storyboard — epics and stories from a storyboard or requirements document
|
|
49410
|
+
* written in Markdown (or plain text with Markdown headings), as teams keep
|
|
49411
|
+
* them beside or before a tracker:
|
|
49412
|
+
*
|
|
49413
|
+
* - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
|
|
49414
|
+
* "Story" (`### Story: Pay by card`) a story; otherwise the document's one
|
|
49415
|
+
* title (`#`) is its name, the next level down are epics and the level
|
|
49416
|
+
* below that stories;
|
|
49417
|
+
* - under an epic, a list item that reads "As a … I want …" is a story too;
|
|
49418
|
+
* - under a story, "As a … I want … so that …" is its narrative, the list
|
|
49419
|
+
* under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
|
|
49420
|
+
* and the rest its description — a picture (``) kept there
|
|
49421
|
+
* by its caption.
|
|
49422
|
+
*
|
|
49423
|
+
* `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
|
|
49424
|
+
* are made from names (prefix: `prefix`, so two storyboards read into one
|
|
49425
|
+
* model stay apart).
|
|
49426
|
+
*/
|
|
49427
|
+
class Storyboard {
|
|
49428
|
+
/** @param {{prefix?: string}} [options] */
|
|
49429
|
+
constructor({ prefix = '' } = {}) {
|
|
49430
|
+
this.prefix = prefix;
|
|
49431
|
+
}
|
|
49432
|
+
|
|
49433
|
+
read(text) {
|
|
49434
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
49435
|
+
const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
|
|
49436
|
+
.map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
|
|
49437
|
+
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);
|
|
49438
|
+
const top = Math.min(...headings.map(h => h.level));
|
|
49439
|
+
const tops = headings.filter(h => h.level === top);
|
|
49440
|
+
// One top heading, a title (#), names the document; its children are the epics.
|
|
49441
|
+
const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
|
|
49442
|
+
const epicLevel = named ? top + 1 : top;
|
|
49443
|
+
const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
|
|
49444
|
+
|
|
49445
|
+
const epics = [], stories = [];
|
|
49446
|
+
const ids = new Set();
|
|
49447
|
+
const idFor = (name, kind) => {
|
|
49448
|
+
const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
|
|
49449
|
+
let id = base;
|
|
49450
|
+
for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
|
|
49451
|
+
ids.add(id);
|
|
49452
|
+
return id;
|
|
49453
|
+
};
|
|
49454
|
+
const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
|
|
49455
|
+
let epic = null;
|
|
49456
|
+
headings.forEach((h, i) => {
|
|
49457
|
+
if (named && h === tops[0]) return;
|
|
49458
|
+
const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
|
|
49459
|
+
const body = bodyOf(h, i);
|
|
49460
|
+
if (kind === 'epic') {
|
|
49461
|
+
epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
|
|
49462
|
+
epics.push(epic);
|
|
49463
|
+
const listed = this._listedStories(body);
|
|
49464
|
+
epic.description = listed.rest;
|
|
49465
|
+
for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
|
|
49466
|
+
} else if (kind === 'story') {
|
|
49467
|
+
stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
|
|
49468
|
+
}
|
|
49469
|
+
});
|
|
49470
|
+
if (!headings.length || (!epics.length && !stories.length)) {
|
|
49471
|
+
for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
|
|
49472
|
+
}
|
|
49473
|
+
return { name: named ? tops[0].text : '', epics, stories };
|
|
49474
|
+
}
|
|
49475
|
+
|
|
49476
|
+
/** A story from its heading and what is under it. @private */
|
|
49477
|
+
_story(name, body) {
|
|
49478
|
+
const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
|
|
49479
|
+
const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
|
|
49480
|
+
const said = narrativeFrom(text);
|
|
49481
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(name);
|
|
49482
|
+
const { criteria, rest } = criteriaFrom(text);
|
|
49483
|
+
const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
|
|
49484
|
+
return { name, as, want, soThat, description, criteria };
|
|
49485
|
+
}
|
|
49486
|
+
|
|
49487
|
+
/** "As a … I want …" list items: stories; the rest of the text, kept. @private */
|
|
49488
|
+
_listedStories(body) {
|
|
49489
|
+
const stories = [];
|
|
49490
|
+
const rest = [];
|
|
49491
|
+
for (const line of body.split('\n')) {
|
|
49492
|
+
const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
49493
|
+
const said = item && narrativeFrom(item[1]);
|
|
49494
|
+
if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
|
|
49495
|
+
else rest.push(line);
|
|
49496
|
+
}
|
|
49497
|
+
return { stories, rest: rest.join('\n').trim() };
|
|
49498
|
+
}
|
|
49499
|
+
}
|
|
49500
|
+
|
|
49501
|
+
/**
|
|
49502
|
+
* The files a requirements model is read from that another tool wrote — they
|
|
49503
|
+
* import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
|
|
49504
|
+
* GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
|
|
49505
|
+
* the file as a new model; `spec(text)` is what it holds, for a part that
|
|
49506
|
+
* brings it into the model open (RequirementsModel#bringCommand).
|
|
49507
|
+
*/
|
|
49508
|
+
class RequirementsImportFormat extends ModelFileFormat {
|
|
49509
|
+
get exports() { return false; }
|
|
49510
|
+
|
|
49511
|
+
/** What the file holds, as a RequirementsModel spec. @abstract */
|
|
49512
|
+
spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
|
|
49513
|
+
|
|
49514
|
+
import(text) {
|
|
49515
|
+
return new RequirementsModel(this.spec(text));
|
|
49516
|
+
}
|
|
49517
|
+
}
|
|
49518
|
+
|
|
49519
|
+
class TrackerExportFileFormat extends RequirementsImportFormat {
|
|
49520
|
+
get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
|
|
49521
|
+
get accept() { return '.csv,.json'; }
|
|
49522
|
+
get featureName() { return 'TrackerExport'; }
|
|
49523
|
+
|
|
49524
|
+
/** A CSV or JSON export one of the trackers wrote. */
|
|
49525
|
+
recognizes(text) {
|
|
49526
|
+
return !!TrackerExport.detect(text);
|
|
49527
|
+
}
|
|
49528
|
+
|
|
49529
|
+
spec(text) {
|
|
49530
|
+
const { tracker, ...spec } = new TrackerExport().read(text);
|
|
49531
|
+
return { name: `${TrackerExport.label(tracker)} export`, ...spec };
|
|
49532
|
+
}
|
|
49533
|
+
}
|
|
49534
|
+
|
|
49535
|
+
class StoryboardFileFormat extends RequirementsImportFormat {
|
|
49536
|
+
get label() { return 'Storyboard (Markdown)'; }
|
|
49537
|
+
get accept() { return '.md,.markdown,.txt'; }
|
|
49538
|
+
get featureName() { return 'Storyboard'; }
|
|
49539
|
+
|
|
49540
|
+
spec(text) {
|
|
49541
|
+
return new Storyboard().read(text);
|
|
49542
|
+
}
|
|
49543
|
+
}
|
|
49544
|
+
|
|
48377
49545
|
/**
|
|
48378
49546
|
* propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
|
|
48379
49547
|
*
|
|
@@ -51504,7 +52672,9 @@
|
|
|
51504
52672
|
/** Open what `choose()` read: the part edits its model, from that file. Returns the model. */
|
|
51505
52673
|
adopt({ model, name, format, handle = null }) {
|
|
51506
52674
|
this.part.replaceModel(model);
|
|
51507
|
-
|
|
52675
|
+
// Read from another tool's export (a format that only reads): a new model, saved in the part's own format.
|
|
52676
|
+
if (format && format.exports === false) Object.assign(this, { name: null, format: null, handle: null });
|
|
52677
|
+
else Object.assign(this, { name, format, handle });
|
|
51508
52678
|
this.showState();
|
|
51509
52679
|
return model;
|
|
51510
52680
|
}
|
|
@@ -52288,7 +53458,7 @@ th { color: #555; font-weight: 500; }
|
|
|
52288
53458
|
...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'], ...(this.modelsHost?.baseItems?.() ?? []))),
|
|
52289
53459
|
{ separator: true },
|
|
52290
53460
|
...some(open, models['Open… in a new tab'], ...saving, models['Duplicate model'], models['Close model']),
|
|
52291
|
-
...EditorPart.menuGroup('This browser', this.modelsHost?.keepItems?.(this) ?? []),
|
|
53461
|
+
...EditorPart.menuGroup(this.modelsHost?.keptWhere ?? 'This browser', this.modelsHost?.keepItems?.(this) ?? []),
|
|
52292
53462
|
{ separator: true },
|
|
52293
53463
|
...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
|
|
52294
53464
|
...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
|
|
@@ -52444,723 +53614,1280 @@ th { color: #555; font-weight: 500; }
|
|
|
52444
53614
|
}
|
|
52445
53615
|
}
|
|
52446
53616
|
|
|
52447
|
-
/**
|
|
52448
|
-
*
|
|
52449
|
-
*
|
|
52450
|
-
*
|
|
52451
|
-
*
|
|
52452
|
-
*
|
|
52453
|
-
*
|
|
52454
|
-
*
|
|
52455
|
-
*
|
|
52456
|
-
*
|
|
52457
|
-
*
|
|
52458
|
-
*
|
|
52459
|
-
*
|
|
52460
|
-
*
|
|
52461
|
-
|
|
52462
|
-
|
|
52463
|
-
|
|
52464
|
-
|
|
52465
|
-
|
|
52466
|
-
|
|
52467
|
-
|
|
52468
|
-
|
|
52469
|
-
|
|
52470
|
-
|
|
52471
|
-
|
|
52472
|
-
|
|
52473
|
-
|
|
52474
|
-
|
|
52475
|
-
|
|
52476
|
-
|
|
52477
|
-
|
|
52478
|
-
|
|
52479
|
-
|
|
52480
|
-
|
|
52481
|
-
|
|
52482
|
-
|
|
52483
|
-
|
|
52484
|
-
|
|
52485
|
-
|
|
52486
|
-
|
|
52487
|
-
|
|
52488
|
-
|
|
52489
|
-
|
|
52490
|
-
|
|
52491
|
-
|
|
52492
|
-
|
|
52493
|
-
|
|
52494
|
-
|
|
52495
|
-
|
|
52496
|
-
|
|
52497
|
-
|
|
52498
|
-
|
|
52499
|
-
|
|
52500
|
-
|
|
52501
|
-
|
|
52502
|
-
|
|
52503
|
-
|
|
52504
|
-
|
|
52505
|
-
|
|
52506
|
-
|
|
52507
|
-
|
|
52508
|
-
|
|
52509
|
-
|
|
52510
|
-
|
|
52511
|
-
|
|
52512
|
-
|
|
52513
|
-
|
|
52514
|
-
|
|
52515
|
-
|
|
52516
|
-
|
|
52517
|
-
|
|
52518
|
-
|
|
52519
|
-
|
|
52520
|
-
|
|
52521
|
-
|
|
52522
|
-
|
|
52523
|
-
|
|
52524
|
-
|
|
52525
|
-
|
|
52526
|
-
|
|
52527
|
-
|
|
52528
|
-
|
|
52529
|
-
|
|
52530
|
-
|
|
52531
|
-
|
|
52532
|
-
|
|
52533
|
-
|
|
52534
|
-
|
|
52535
|
-
|
|
52536
|
-
|
|
52537
|
-
|
|
52538
|
-
|
|
52539
|
-
|
|
52540
|
-
|
|
52541
|
-
|
|
52542
|
-
|
|
52543
|
-
|
|
52544
|
-
|
|
52545
|
-
|
|
52546
|
-
|
|
52547
|
-
|
|
52548
|
-
|
|
52549
|
-
|
|
52550
|
-
|
|
52551
|
-
|
|
52552
|
-
|
|
52553
|
-
|
|
52554
|
-
|
|
52555
|
-
|
|
52556
|
-
|
|
52557
|
-
|
|
52558
|
-
|
|
52559
|
-
|
|
52560
|
-
|
|
52561
|
-
|
|
52562
|
-
|
|
52563
|
-
|
|
52564
|
-
|
|
52565
|
-
|
|
52566
|
-
|
|
52567
|
-
|
|
52568
|
-
|
|
52569
|
-
|
|
52570
|
-
*
|
|
52571
|
-
|
|
52572
|
-
|
|
52573
|
-
|
|
52574
|
-
|
|
52575
|
-
|
|
52576
|
-
|
|
52577
|
-
|
|
52578
|
-
|
|
52579
|
-
|
|
52580
|
-
|
|
52581
|
-
|
|
52582
|
-
|
|
52583
|
-
|
|
52584
|
-
|
|
52585
|
-
|
|
52586
|
-
|
|
52587
|
-
|
|
52588
|
-
|
|
52589
|
-
|
|
52590
|
-
|
|
52591
|
-
|
|
52592
|
-
|
|
52593
|
-
|
|
52594
|
-
|
|
52595
|
-
|
|
52596
|
-
|
|
52597
|
-
|
|
52598
|
-
|
|
52599
|
-
this.
|
|
52600
|
-
|
|
52601
|
-
|
|
52602
|
-
|
|
52603
|
-
|
|
52604
|
-
|
|
52605
|
-
|
|
52606
|
-
return
|
|
52607
|
-
|
|
52608
|
-
|
|
52609
|
-
|
|
52610
|
-
|
|
52611
|
-
|
|
52612
|
-
|
|
52613
|
-
|
|
52614
|
-
|
|
52615
|
-
|
|
52616
|
-
|
|
52617
|
-
|
|
52618
|
-
|
|
52619
|
-
|
|
52620
|
-
|
|
52621
|
-
|
|
52622
|
-
|
|
52623
|
-
|
|
52624
|
-
|
|
52625
|
-
|
|
52626
|
-
|
|
52627
|
-
|
|
52628
|
-
|
|
52629
|
-
|
|
52630
|
-
|
|
52631
|
-
|
|
52632
|
-
|
|
52633
|
-
|
|
52634
|
-
|
|
52635
|
-
|
|
52636
|
-
|
|
52637
|
-
|
|
52638
|
-
|
|
52639
|
-
|
|
52640
|
-
|
|
52641
|
-
|
|
52642
|
-
|
|
52643
|
-
|
|
52644
|
-
|
|
52645
|
-
|
|
52646
|
-
|
|
52647
|
-
|
|
52648
|
-
|
|
52649
|
-
|
|
52650
|
-
}
|
|
52651
|
-
|
|
52652
|
-
|
|
52653
|
-
|
|
52654
|
-
|
|
52655
|
-
|
|
52656
|
-
|
|
52657
|
-
|
|
52658
|
-
this.
|
|
52659
|
-
}
|
|
52660
|
-
|
|
52661
|
-
|
|
52662
|
-
|
|
52663
|
-
|
|
52664
|
-
|
|
52665
|
-
|
|
52666
|
-
|
|
52667
|
-
|
|
52668
|
-
|
|
52669
|
-
|
|
52670
|
-
|
|
52671
|
-
|
|
52672
|
-
|
|
52673
|
-
|
|
52674
|
-
|
|
52675
|
-
|
|
52676
|
-
|
|
52677
|
-
|
|
52678
|
-
|
|
52679
|
-
|
|
52680
|
-
|
|
52681
|
-
}
|
|
52682
|
-
|
|
52683
|
-
/**
|
|
52684
|
-
|
|
52685
|
-
return
|
|
52686
|
-
|
|
52687
|
-
|
|
52688
|
-
|
|
52689
|
-
|
|
52690
|
-
|
|
52691
|
-
|
|
52692
|
-
|
|
52693
|
-
|
|
52694
|
-
|
|
52695
|
-
|
|
52696
|
-
|
|
52697
|
-
|
|
52698
|
-
|
|
52699
|
-
|
|
52700
|
-
|
|
52701
|
-
|
|
52702
|
-
|
|
52703
|
-
|
|
52704
|
-
|
|
52705
|
-
|
|
52706
|
-
|
|
52707
|
-
|
|
52708
|
-
|
|
52709
|
-
|
|
52710
|
-
|
|
52711
|
-
|
|
52712
|
-
|
|
52713
|
-
|
|
52714
|
-
|
|
52715
|
-
|
|
52716
|
-
|
|
52717
|
-
|
|
52718
|
-
return
|
|
52719
|
-
|
|
52720
|
-
|
|
52721
|
-
|
|
52722
|
-
|
|
52723
|
-
|
|
52724
|
-
|
|
52725
|
-
|
|
52726
|
-
|
|
52727
|
-
|
|
52728
|
-
|
|
52729
|
-
|
|
52730
|
-
|
|
52731
|
-
|
|
52732
|
-
|
|
52733
|
-
|
|
52734
|
-
|
|
52735
|
-
|
|
52736
|
-
|
|
52737
|
-
|
|
52738
|
-
|
|
52739
|
-
|
|
52740
|
-
|
|
52741
|
-
}
|
|
52742
|
-
|
|
52743
|
-
/**
|
|
52744
|
-
|
|
52745
|
-
|
|
52746
|
-
|
|
52747
|
-
}
|
|
52748
|
-
|
|
52749
|
-
|
|
52750
|
-
|
|
52751
|
-
|
|
52752
|
-
|
|
52753
|
-
|
|
52754
|
-
|
|
52755
|
-
|
|
52756
|
-
|
|
52757
|
-
|
|
52758
|
-
const
|
|
52759
|
-
|
|
52760
|
-
|
|
52761
|
-
|
|
52762
|
-
|
|
52763
|
-
|
|
52764
|
-
|
|
52765
|
-
|
|
52766
|
-
|
|
52767
|
-
|
|
52768
|
-
|
|
52769
|
-
|
|
52770
|
-
|
|
52771
|
-
|
|
52772
|
-
|
|
52773
|
-
|
|
52774
|
-
|
|
52775
|
-
|
|
52776
|
-
|
|
52777
|
-
|
|
52778
|
-
|
|
52779
|
-
|
|
52780
|
-
|
|
52781
|
-
|
|
52782
|
-
|
|
52783
|
-
|
|
52784
|
-
|
|
52785
|
-
|
|
52786
|
-
|
|
52787
|
-
|
|
52788
|
-
|
|
52789
|
-
|
|
52790
|
-
*
|
|
52791
|
-
|
|
52792
|
-
|
|
52793
|
-
|
|
52794
|
-
const
|
|
52795
|
-
|
|
52796
|
-
|
|
52797
|
-
|
|
52798
|
-
|
|
52799
|
-
|
|
52800
|
-
|
|
52801
|
-
|
|
52802
|
-
|
|
52803
|
-
|
|
52804
|
-
|
|
52805
|
-
|
|
52806
|
-
|
|
52807
|
-
|
|
52808
|
-
|
|
52809
|
-
|
|
52810
|
-
|
|
52811
|
-
|
|
52812
|
-
|
|
52813
|
-
|
|
52814
|
-
|
|
52815
|
-
|
|
52816
|
-
|
|
52817
|
-
|
|
52818
|
-
|
|
52819
|
-
|
|
52820
|
-
|
|
52821
|
-
|
|
52822
|
-
|
|
52823
|
-
|
|
52824
|
-
|
|
52825
|
-
|
|
52826
|
-
|
|
52827
|
-
|
|
52828
|
-
|
|
52829
|
-
|
|
52830
|
-
|
|
52831
|
-
|
|
52832
|
-
|
|
52833
|
-
|
|
52834
|
-
|
|
52835
|
-
|
|
52836
|
-
|
|
52837
|
-
|
|
52838
|
-
|
|
52839
|
-
|
|
52840
|
-
|
|
52841
|
-
|
|
52842
|
-
|
|
52843
|
-
|
|
52844
|
-
|
|
52845
|
-
|
|
52846
|
-
|
|
52847
|
-
|
|
52848
|
-
|
|
52849
|
-
|
|
52850
|
-
*
|
|
52851
|
-
|
|
52852
|
-
|
|
52853
|
-
|
|
52854
|
-
|
|
52855
|
-
|
|
52856
|
-
|
|
52857
|
-
|
|
52858
|
-
|
|
52859
|
-
|
|
52860
|
-
|
|
52861
|
-
|
|
52862
|
-
|
|
52863
|
-
|
|
52864
|
-
|
|
52865
|
-
|
|
52866
|
-
|
|
52867
|
-
|
|
52868
|
-
|
|
52869
|
-
|
|
52870
|
-
|
|
52871
|
-
|
|
52872
|
-
|
|
52873
|
-
|
|
52874
|
-
|
|
52875
|
-
|
|
52876
|
-
|
|
52877
|
-
|
|
52878
|
-
|
|
52879
|
-
|
|
52880
|
-
|
|
52881
|
-
|
|
52882
|
-
|
|
52883
|
-
|
|
52884
|
-
|
|
52885
|
-
|
|
52886
|
-
|
|
52887
|
-
|
|
52888
|
-
|
|
52889
|
-
|
|
52890
|
-
|
|
52891
|
-
|
|
52892
|
-
|
|
52893
|
-
|
|
52894
|
-
|
|
52895
|
-
|
|
52896
|
-
|
|
52897
|
-
|
|
52898
|
-
|
|
52899
|
-
|
|
52900
|
-
|
|
52901
|
-
|
|
52902
|
-
|
|
52903
|
-
|
|
52904
|
-
|
|
52905
|
-
|
|
52906
|
-
|
|
52907
|
-
|
|
52908
|
-
|
|
52909
|
-
|
|
52910
|
-
|
|
52911
|
-
|
|
52912
|
-
|
|
52913
|
-
|
|
52914
|
-
|
|
52915
|
-
|
|
52916
|
-
|
|
52917
|
-
|
|
52918
|
-
|
|
52919
|
-
|
|
52920
|
-
|
|
52921
|
-
|
|
52922
|
-
|
|
52923
|
-
|
|
52924
|
-
|
|
52925
|
-
|
|
52926
|
-
|
|
52927
|
-
|
|
52928
|
-
|
|
52929
|
-
|
|
52930
|
-
|
|
52931
|
-
|
|
52932
|
-
|
|
52933
|
-
|
|
52934
|
-
|
|
52935
|
-
|
|
52936
|
-
|
|
52937
|
-
|
|
52938
|
-
|
|
52939
|
-
|
|
52940
|
-
|
|
52941
|
-
|
|
52942
|
-
|
|
52943
|
-
|
|
52944
|
-
|
|
52945
|
-
|
|
52946
|
-
|
|
52947
|
-
|
|
52948
|
-
|
|
52949
|
-
|
|
52950
|
-
|
|
52951
|
-
|
|
52952
|
-
|
|
52953
|
-
|
|
52954
|
-
|
|
52955
|
-
|
|
52956
|
-
|
|
52957
|
-
|
|
52958
|
-
|
|
52959
|
-
{
|
|
52960
|
-
...(
|
|
52961
|
-
|
|
52962
|
-
|
|
52963
|
-
|
|
52964
|
-
|
|
52965
|
-
|
|
52966
|
-
|
|
52967
|
-
|
|
52968
|
-
|
|
52969
|
-
|
|
52970
|
-
|
|
52971
|
-
|
|
52972
|
-
|
|
52973
|
-
|
|
52974
|
-
|
|
52975
|
-
|
|
52976
|
-
|
|
52977
|
-
|
|
52978
|
-
|
|
52979
|
-
|
|
52980
|
-
|
|
52981
|
-
|
|
52982
|
-
|
|
52983
|
-
...
|
|
52984
|
-
|
|
52985
|
-
|
|
52986
|
-
|
|
52987
|
-
|
|
52988
|
-
|
|
52989
|
-
|
|
52990
|
-
|
|
52991
|
-
|
|
52992
|
-
|
|
52993
|
-
|
|
52994
|
-
|
|
52995
|
-
|
|
52996
|
-
|
|
52997
|
-
|
|
52998
|
-
|
|
52999
|
-
|
|
53000
|
-
|
|
53001
|
-
|
|
53002
|
-
|
|
53003
|
-
|
|
53004
|
-
|
|
53005
|
-
|
|
53006
|
-
|
|
53007
|
-
|
|
53008
|
-
this.
|
|
53009
|
-
|
|
53010
|
-
|
|
53011
|
-
|
|
53012
|
-
|
|
53013
|
-
|
|
53014
|
-
|
|
53015
|
-
|
|
53016
|
-
|
|
53017
|
-
|
|
53018
|
-
|
|
53019
|
-
|
|
53020
|
-
|
|
53021
|
-
|
|
53022
|
-
|
|
53023
|
-
|
|
53024
|
-
|
|
53025
|
-
|
|
53026
|
-
|
|
53027
|
-
|
|
53028
|
-
|
|
53029
|
-
|
|
53030
|
-
|
|
53031
|
-
|
|
53032
|
-
|
|
53033
|
-
|
|
53034
|
-
|
|
53035
|
-
const
|
|
53036
|
-
|
|
53037
|
-
|
|
53038
|
-
|
|
53039
|
-
|
|
53040
|
-
|
|
53041
|
-
|
|
53042
|
-
|
|
53043
|
-
|
|
53044
|
-
|
|
53045
|
-
|
|
53046
|
-
|
|
53047
|
-
|
|
53048
|
-
|
|
53049
|
-
|
|
53050
|
-
|
|
53051
|
-
|
|
53052
|
-
|
|
53053
|
-
|
|
53054
|
-
|
|
53055
|
-
|
|
53056
|
-
|
|
53057
|
-
|
|
53058
|
-
|
|
53059
|
-
|
|
53060
|
-
|
|
53061
|
-
|
|
53062
|
-
|
|
53063
|
-
|
|
53064
|
-
|
|
53065
|
-
|
|
53066
|
-
|
|
53067
|
-
|
|
53068
|
-
|
|
53069
|
-
|
|
53070
|
-
|
|
53071
|
-
|
|
53072
|
-
|
|
53073
|
-
|
|
53074
|
-
|
|
53075
|
-
|
|
53076
|
-
|
|
53077
|
-
|
|
53078
|
-
|
|
53079
|
-
|
|
53080
|
-
|
|
53081
|
-
|
|
53082
|
-
|
|
53083
|
-
|
|
53084
|
-
|
|
53085
|
-
|
|
53086
|
-
|
|
53087
|
-
|
|
53088
|
-
|
|
53089
|
-
|
|
53090
|
-
|
|
53091
|
-
|
|
53092
|
-
|
|
53093
|
-
|
|
53094
|
-
|
|
53095
|
-
|
|
53096
|
-
|
|
53097
|
-
|
|
53098
|
-
|
|
53099
|
-
|
|
53100
|
-
|
|
53101
|
-
|
|
53102
|
-
|
|
53103
|
-
|
|
53104
|
-
|
|
53105
|
-
|
|
53106
|
-
|
|
53107
|
-
|
|
53108
|
-
|
|
53109
|
-
|
|
53110
|
-
|
|
53111
|
-
|
|
53112
|
-
|
|
53113
|
-
|
|
53114
|
-
|
|
53115
|
-
|
|
53116
|
-
|
|
53117
|
-
|
|
53118
|
-
|
|
53119
|
-
|
|
53120
|
-
|
|
53121
|
-
|
|
53122
|
-
|
|
53123
|
-
|
|
53124
|
-
|
|
53125
|
-
|
|
53126
|
-
|
|
53127
|
-
|
|
53128
|
-
|
|
53129
|
-
const
|
|
53130
|
-
|
|
53131
|
-
|
|
53132
|
-
|
|
53133
|
-
|
|
53134
|
-
|
|
53135
|
-
|
|
53136
|
-
|
|
53137
|
-
|
|
53138
|
-
|
|
53139
|
-
|
|
53140
|
-
|
|
53141
|
-
const
|
|
53142
|
-
const
|
|
53143
|
-
|
|
53144
|
-
|
|
53145
|
-
|
|
53146
|
-
}
|
|
53147
|
-
|
|
53148
|
-
|
|
53149
|
-
this.
|
|
53150
|
-
this.
|
|
53151
|
-
this.
|
|
53152
|
-
|
|
53153
|
-
|
|
53154
|
-
|
|
53155
|
-
|
|
53156
|
-
|
|
53157
|
-
|
|
53158
|
-
|
|
53159
|
-
|
|
53160
|
-
|
|
53161
|
-
|
|
53162
|
-
|
|
53163
|
-
|
|
53617
|
+
/**
|
|
53618
|
+
* ModelHosted — what a part that edits one model as a whole (an
|
|
53619
|
+
* EditableModel: DataModelPart, RequirementsPart) needs to be one of a
|
|
53620
|
+
* ModelsPart's models, as an EditorPart is: its file (ModelFile: Open…, Save,
|
|
53621
|
+
* Save As…, • while changed, asking before the page is left), kept by the
|
|
53622
|
+
* host (`keepId`, `keepEntry`), its File menu (New, Duplicate, Close model,
|
|
53623
|
+
* where models are kept, Import of what other tools wrote), Undo and Redo,
|
|
53624
|
+
* running a model-wide edit (a merge, a proposal taken) as one undo step,
|
|
53625
|
+
* and reading a file into the model as one (readInto).
|
|
53626
|
+
*
|
|
53627
|
+
* A mixin, for parts on different bases (a ChartPart, a plain Part):
|
|
53628
|
+
* `class DataModelPart extends ModelHosted(ChartPart)`. The part gives
|
|
53629
|
+
* `fileFormats()` (its own first: what it saves as), and `afterModelEdit()`
|
|
53630
|
+
* to show an edit (redraw).
|
|
53631
|
+
*/
|
|
53632
|
+
const ModelHosted = (Base) => class extends Base {
|
|
53633
|
+
constructor(spec = {}) {
|
|
53634
|
+
super(spec);
|
|
53635
|
+
/** The file it is open from and saved to. */
|
|
53636
|
+
this.file = new ModelFile(this);
|
|
53637
|
+
/** Its id among the models kept by its host, or null. */
|
|
53638
|
+
this.keepId = spec.keepId ?? null;
|
|
53639
|
+
}
|
|
53640
|
+
|
|
53641
|
+
/** Its file formats: none here. @abstract */
|
|
53642
|
+
fileFormats() {
|
|
53643
|
+
return [];
|
|
53644
|
+
}
|
|
53645
|
+
|
|
53646
|
+
/** What a new file is saved as: its first format. */
|
|
53647
|
+
saveFormat() {
|
|
53648
|
+
return this.fileFormats()[0];
|
|
53649
|
+
}
|
|
53650
|
+
|
|
53651
|
+
/** The ModelsPart it is a tab of, or null. */
|
|
53652
|
+
get modelsHost() {
|
|
53653
|
+
return this.ctx?.models ?? null;
|
|
53654
|
+
}
|
|
53655
|
+
|
|
53656
|
+
/** The page it is in. */
|
|
53657
|
+
get document() {
|
|
53658
|
+
return this.container?.ownerDocument ?? globalThis.document;
|
|
53659
|
+
}
|
|
53660
|
+
|
|
53661
|
+
/** What its tab and the page title show: the file's name (or the model's), and • while changed. */
|
|
53662
|
+
get fileLabel() {
|
|
53663
|
+
return this.file.label;
|
|
53664
|
+
}
|
|
53665
|
+
|
|
53666
|
+
/** The model as kept by its host (ModelFile#keepEntry). */
|
|
53667
|
+
keepEntry() {
|
|
53668
|
+
return this.file.keepEntry();
|
|
53669
|
+
}
|
|
53670
|
+
|
|
53671
|
+
/** A model opened from a file: in a host's new tab, else in place of this one (as EditorPart does). */
|
|
53672
|
+
onImported(model) {
|
|
53673
|
+
return EditorPart.prototype.onImported.call(this, model);
|
|
53674
|
+
}
|
|
53675
|
+
|
|
53676
|
+
/** The file or its saved state changed: a ModelsPart's tab follows it. */
|
|
53677
|
+
onFileState() {
|
|
53678
|
+
this.modelsHost?.fileStateChanged?.(this);
|
|
53679
|
+
}
|
|
53680
|
+
|
|
53681
|
+
/** Edit `model`: as saved, and every change of it shows (• and the title) and is kept. @protected */
|
|
53682
|
+
_adoptModel(model) {
|
|
53683
|
+
this._unsubscribeEdits?.();
|
|
53684
|
+
this.model = model;
|
|
53685
|
+
model.markSaved();
|
|
53686
|
+
this._unsubscribeEdits = model.onEdit(() => {
|
|
53687
|
+
this.file.showState();
|
|
53688
|
+
this.modelsHost?.modelEdited?.(this);
|
|
53689
|
+
});
|
|
53690
|
+
}
|
|
53691
|
+
|
|
53692
|
+
mount(host, ctx) {
|
|
53693
|
+
const first = !this.mounted;
|
|
53694
|
+
super.mount(host, ctx);
|
|
53695
|
+
if (first) {
|
|
53696
|
+
this.file.listen();
|
|
53697
|
+
this.file.showState();
|
|
53698
|
+
}
|
|
53699
|
+
}
|
|
53700
|
+
|
|
53701
|
+
/** Show an edit of the model (a redraw): a subclass's. */
|
|
53702
|
+
afterModelEdit() {}
|
|
53703
|
+
|
|
53704
|
+
/** Run a model-wide edit (a merge) as one undo step, then show it. */
|
|
53705
|
+
runModelEdit(command) {
|
|
53706
|
+
if (!command) return null;
|
|
53707
|
+
this.model.history.execute(command);
|
|
53708
|
+
this.afterModelEdit();
|
|
53709
|
+
return command;
|
|
53710
|
+
}
|
|
53711
|
+
|
|
53712
|
+
/**
|
|
53713
|
+
* File ▸: Open…, Save, Save As…, Import of what other tools wrote (its
|
|
53714
|
+
* formats that only read), then with a ModelsPart New model, Duplicate,
|
|
53715
|
+
* Close model, and where models are kept ▸.
|
|
53716
|
+
*/
|
|
53717
|
+
fileItems() {
|
|
53718
|
+
const host = this.modelsHost;
|
|
53719
|
+
return EditorPart.tidyMenu([
|
|
53720
|
+
...this.file.menuItems(),
|
|
53721
|
+
...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
|
|
53722
|
+
...(host ? [
|
|
53723
|
+
{ separator: true },
|
|
53724
|
+
...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
|
|
53725
|
+
{ label: 'Duplicate model', action: () => host.duplicate(this) },
|
|
53726
|
+
{ label: 'Close model', action: () => host.closeModel(this.id) },
|
|
53727
|
+
...EditorPart.menuGroup(host.keptWhere ?? 'This browser', host.keepItems?.(this) ?? []),
|
|
53728
|
+
] : []),
|
|
53729
|
+
]);
|
|
53730
|
+
}
|
|
53731
|
+
|
|
53732
|
+
/** Import ▸: each format that only reads (another tool's export), opened as a new model. */
|
|
53733
|
+
importItems() {
|
|
53734
|
+
return modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
|
|
53735
|
+
model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
|
|
53736
|
+
});
|
|
53737
|
+
}
|
|
53738
|
+
|
|
53739
|
+
/**
|
|
53740
|
+
* Ask for a file, make a command from its bytes, and run it as one undo
|
|
53741
|
+
* step; a file that cannot be read, or brings nothing, is reported.
|
|
53742
|
+
* @param {Object} how
|
|
53743
|
+
* @param {string} how.accept - The file chooser's filter
|
|
53744
|
+
* @param {string} how.what - What is read, for the report ('the landscape')
|
|
53745
|
+
* @param {(bytes: Uint8Array, name: string) => Command|null} how.command
|
|
53746
|
+
* @param {(name: string) => string} how.nothing - The report when the command is null
|
|
53747
|
+
* @param {() => void} [how.then] - What to show once it is run (before the redraw)
|
|
53748
|
+
* @param {(accept: string) => Promise<File|null>} [how.chooseFile]
|
|
53749
|
+
* @param {(message: string) => void} [how.report]
|
|
53750
|
+
* @returns {Promise<Command|null>}
|
|
53751
|
+
*/
|
|
53752
|
+
async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
|
|
53753
|
+
const doc = this.container?.ownerDocument ?? globalThis.document;
|
|
53754
|
+
const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
|
|
53755
|
+
const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
|
|
53756
|
+
if (!file) return null;
|
|
53757
|
+
let command;
|
|
53758
|
+
try {
|
|
53759
|
+
command = make(new Uint8Array(await file.arrayBuffer()), file.name);
|
|
53760
|
+
} catch (err) {
|
|
53761
|
+
tell(`Could not read ${what}: ${err.message}`);
|
|
53762
|
+
return null;
|
|
53763
|
+
}
|
|
53764
|
+
if (!command) {
|
|
53765
|
+
tell(nothing(file.name));
|
|
53766
|
+
return null;
|
|
53767
|
+
}
|
|
53768
|
+
then();
|
|
53769
|
+
this.runModelEdit(command);
|
|
53770
|
+
return command;
|
|
53771
|
+
}
|
|
53772
|
+
|
|
53773
|
+
/** Undo and Redo, each named by what it undoes (a merge is one step). */
|
|
53774
|
+
undoItems() {
|
|
53775
|
+
const history = this.model.history;
|
|
53776
|
+
return [
|
|
53777
|
+
{ label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this.afterModelEdit(); } },
|
|
53778
|
+
{ label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this.afterModelEdit(); } },
|
|
53779
|
+
];
|
|
53780
|
+
}
|
|
53781
|
+
|
|
53782
|
+
dispose() {
|
|
53783
|
+
this._unsubscribeEdits?.();
|
|
53784
|
+
this.file.restoreTitle();
|
|
53785
|
+
super.dispose();
|
|
53786
|
+
}
|
|
53787
|
+
};
|
|
53788
|
+
|
|
53789
|
+
/**
|
|
53790
|
+
* DataModelPart — a data model (DataModel, drawn by dataModelDiagram) in a
|
|
53791
|
+
* pane: its conceptual, logical and physical levels, each its own schema, and
|
|
53792
|
+
* how they map; seen through a perspective (whose concepts, under whose names)
|
|
53793
|
+
* read in a context (what they mean here, the rules that hold, where and
|
|
53794
|
+
* when), in a notation (Chen, Crow's Foot, UML). Its toolbar switches each
|
|
53795
|
+
* of them, Level offering the views that are no level (DataModel.OTHER_VIEWS:
|
|
53796
|
+
* Mapping, the levels side by side joined where they map; Service landscape,
|
|
53797
|
+
* the service domains in their business areas and domains, a double-click
|
|
53798
|
+
* seeing the model through one), and its Model menu makes, changes and removes
|
|
53799
|
+
* perspectives and contexts and reads service domains from a BIAN landscape. Boxes and notes stay where they were dragged. Export ▸ saves the
|
|
53800
|
+
* picture (PNG, SVG). The drawing, its sizing and redrawing on resize are
|
|
53801
|
+
* ChartPart's.
|
|
53802
|
+
*
|
|
53803
|
+
* It is saved as a file (DataModelFileFormat, `.datamodel.json`) as an
|
|
53804
|
+
* editor's model is (ModelHosted: ModelFile): File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S),
|
|
53805
|
+
* • while changed since, and asking before the page is left with changes
|
|
53806
|
+
* unsaved. Hosted by a ModelsPart (`editorType: 'data-model'`) it is one of
|
|
53807
|
+
* several models open, a tab each, and with that part's `keep` it is kept in
|
|
53808
|
+
* this browser and backed up (File ▸ This browser), as the modeller's are.
|
|
53809
|
+
*
|
|
53810
|
+
* Spec: `{ id, type: 'data-model', title?, levels?, mappings?, mappingLayout?,
|
|
53811
|
+
* perspectives?, contexts?, perspective?, context?, level?, notation?, options? }`
|
|
53812
|
+
* — the model as DataModel takes it (or `entities`/`relationships` in the older
|
|
53813
|
+
* one-entity form); `perspective`/`context` the ids chosen; `level` default
|
|
53814
|
+
* 'logical' ('mapping' for the mapping view), `notation` default 'crowsfoot';
|
|
53815
|
+
* `options` anything else dataModelDiagram takes (editable, resizable by default).
|
|
53816
|
+
*/
|
|
53817
|
+
class DataModelPart extends ModelHosted(ChartPart) {
|
|
53818
|
+
static type = 'data-model';
|
|
53819
|
+
|
|
53820
|
+
constructor(spec = {}) {
|
|
53821
|
+
const { perspective = null, context = null, level = 'logical', notation = 'crowsfoot', options = {} } = spec;
|
|
53822
|
+
super({ ...spec, chartType: 'dataModel', options: { editable: true, resizable: true, ...options, level, notation } });
|
|
53823
|
+
this._adoptModel(new DataModel(spec));
|
|
53824
|
+
/** The ids chosen, or null. */
|
|
53825
|
+
this.perspectiveId = perspective;
|
|
53826
|
+
this.contextId = context ?? this.model.perspective(perspective)?.context ?? null;
|
|
53827
|
+
/** The level the drawing on screen is of (where a drag is kept). */
|
|
53828
|
+
this._drawnLevel = level;
|
|
53829
|
+
}
|
|
53830
|
+
|
|
53831
|
+
// ===== A model saved as a file, kept, backed up =====
|
|
53832
|
+
|
|
53833
|
+
/** Its file formats: the data model's own. */
|
|
53834
|
+
fileFormats() {
|
|
53835
|
+
return [DataModelFileFormat.instance ??= new DataModelFileFormat()];
|
|
53836
|
+
}
|
|
53837
|
+
|
|
53838
|
+
/** Edit another model (one opened, imported or kept), from scratch: nothing chosen, nothing changed. */
|
|
53839
|
+
replaceModel(model) {
|
|
53840
|
+
this._adoptModel(model);
|
|
53841
|
+
this.perspectiveId = null;
|
|
53842
|
+
this.contextId = null;
|
|
53843
|
+
if (!this.mounted) return;
|
|
53844
|
+
// Not rendered again (that would add a second toolbar and canvas): the toolbar
|
|
53845
|
+
// shows the new model's choices, and it is drawn. What was on screen is the
|
|
53846
|
+
// other model's, so where its boxes were is not kept into this one.
|
|
53847
|
+
this._drawnLevel = null;
|
|
53848
|
+
this._drawnPlaces = null;
|
|
53849
|
+
this._changed();
|
|
53850
|
+
this._redraw();
|
|
53851
|
+
}
|
|
53852
|
+
|
|
53853
|
+
/** Edit ▸: undo and redo (a merge is one step), and laying the level shown out again. */
|
|
53854
|
+
editItems() {
|
|
53855
|
+
return [
|
|
53856
|
+
...this.undoItems(),
|
|
53857
|
+
...(this.editsLevel ? [
|
|
53858
|
+
{ separator: true },
|
|
53859
|
+
{ label: 'Lay out', items: LAYOUTS.map(([kind, label]) => ({ label, action: () => this.runModelEdit(this.layoutCommand(kind)) })) },
|
|
53860
|
+
] : []),
|
|
53861
|
+
];
|
|
53862
|
+
}
|
|
53863
|
+
|
|
53864
|
+
/**
|
|
53865
|
+
* Lay the boxes of the level shown out again — what is drawn there (the
|
|
53866
|
+
* perspective's), by one of the arrangement's layouts (LAYOUTS: grid,
|
|
53867
|
+
* circle, trees, force-directed, by kind), its lines guiding the trees and
|
|
53868
|
+
* the force. Where each box goes is kept in the model as a drag would be.
|
|
53869
|
+
* One undo step; null on a view that is no level of the schema.
|
|
53870
|
+
* @param {string} kind - A LAYOUTS key
|
|
53871
|
+
*/
|
|
53872
|
+
layoutCommand(kind) {
|
|
53873
|
+
if (!this.editsLevel) return null;
|
|
53874
|
+
const level = this.level;
|
|
53875
|
+
const view = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level });
|
|
53876
|
+
const notation = DataModelNotation.notation(this.notation);
|
|
53877
|
+
const boxes = view.entities.map(e => {
|
|
53878
|
+
const node = DataModelDiagram.buildNode({ ...e, x: Number.isFinite(e.x) ? e.x : 0, y: Number.isFinite(e.y) ? e.y : 0 }, { level, notation });
|
|
53879
|
+
const b = DataModelDiagram.boxOf(node);
|
|
53880
|
+
return { id: e.id, left: b.x0, top: b.y0, width: b.x1 - b.x0, height: b.y1 - b.y0, kind: e.role ?? e.kind ?? '' };
|
|
53881
|
+
});
|
|
53882
|
+
const placed = layout(kind, boxes, {
|
|
53883
|
+
edges: (view.relationships ?? []).map(r => ({ source: r.source, target: r.target })),
|
|
53884
|
+
categoryOf: (box) => box.kind,
|
|
53885
|
+
});
|
|
53886
|
+
const sizes = new Map(boxes.map(b => [b.id, b]));
|
|
53887
|
+
const label = LAYOUTS.find(([k]) => k === kind)?.[1] ?? kind;
|
|
53888
|
+
return this.model.editCommand(`Lay out: ${label}`, (model) => {
|
|
53889
|
+
for (const p of placed) {
|
|
53890
|
+
const entity = model.level(level).entity(p.id), b = sizes.get(p.id);
|
|
53891
|
+
if (entity) Object.assign(entity, { x: Math.round(p.left + b.width / 2), y: Math.round(p.top + b.height / 2) });
|
|
53892
|
+
}
|
|
53893
|
+
});
|
|
53894
|
+
}
|
|
53895
|
+
|
|
53896
|
+
/** An edit shown: the toolbar's choices anew, and drawn. */
|
|
53897
|
+
afterModelEdit() {
|
|
53898
|
+
this._changed();
|
|
53899
|
+
this._redraw();
|
|
53900
|
+
}
|
|
53901
|
+
|
|
53902
|
+
/** The entities at the level shown (the concepts in the mapping view). */
|
|
53903
|
+
get entities() {
|
|
53904
|
+
return this.model.level(DataModel.otherView(this.level) ? 'conceptual' : this.level).entities;
|
|
53905
|
+
}
|
|
53906
|
+
get level() { return this.chartOptions.level; }
|
|
53907
|
+
get notation() { return this.chartOptions.notation; }
|
|
53908
|
+
/** The perspective chosen (DataModelPerspective), or null. */
|
|
53909
|
+
get perspective() { return this.model.perspective(this.perspectiveId); }
|
|
53910
|
+
/** The context chosen (DataModelContext), or null. */
|
|
53911
|
+
get context() { return this.model.context(this.contextId); }
|
|
53912
|
+
|
|
53913
|
+
/** Show another level ('conceptual', 'logical', 'physical'), or how they map ('mapping'). */
|
|
53914
|
+
setLevel(level) {
|
|
53915
|
+
this.chartOptions.level = level;
|
|
53916
|
+
this._redraw();
|
|
53917
|
+
}
|
|
53918
|
+
|
|
53919
|
+
/** Draw the model in another notation ('chen', 'crowsfoot', 'uml'). */
|
|
53920
|
+
setNotation(notation) {
|
|
53921
|
+
this.chartOptions.notation = notation;
|
|
53922
|
+
this._redraw();
|
|
53923
|
+
}
|
|
53924
|
+
|
|
53925
|
+
/** See the model through a perspective (an id; null: the whole schema), in the context it is read in, if it names one. */
|
|
53926
|
+
setPerspective(id) {
|
|
53927
|
+
this.perspectiveId = id || null;
|
|
53928
|
+
const own = this.perspective?.context;
|
|
53929
|
+
if (own) this.contextId = own;
|
|
53930
|
+
this._redraw();
|
|
53931
|
+
}
|
|
53932
|
+
|
|
53933
|
+
/** Read the model in a context (an id; null: the schema's own descriptions). */
|
|
53934
|
+
setContext(id) {
|
|
53935
|
+
this.contextId = id || null;
|
|
53936
|
+
this._redraw();
|
|
53937
|
+
}
|
|
53938
|
+
|
|
53939
|
+
/** What is drawn: the perspective's view of the level (or the mapping) in the context (DataModel#view), in the notation. */
|
|
53940
|
+
drawOptions() {
|
|
53941
|
+
return {
|
|
53942
|
+
...this.chartOptions,
|
|
53943
|
+
...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }),
|
|
53944
|
+
...(this.level === DataModel.LANDSCAPE && { onNodeDoubleClick: (node) => this.openServiceDomain(ServiceLandscape.serviceDomainOf(node.id)) }),
|
|
53945
|
+
...(this.editsLevel && this.editingOptions()),
|
|
53946
|
+
...(this.level === DataModel.MAPPING && this.lineageOptions()),
|
|
53947
|
+
};
|
|
53948
|
+
}
|
|
53949
|
+
|
|
53950
|
+
/**
|
|
53951
|
+
* Show an entity's lineage: the mapping view, what it realises and what
|
|
53952
|
+
* realises it (DataModel#lineage) as they are, the rest greyed.
|
|
53953
|
+
*/
|
|
53954
|
+
showLineage(level, id) {
|
|
53955
|
+
this.lineageFocus = { level, id };
|
|
53956
|
+
this.setLevel(DataModel.MAPPING);
|
|
53957
|
+
}
|
|
53958
|
+
|
|
53959
|
+
/**
|
|
53960
|
+
* In the mapping view: an entity's lineage picked out (`lineageFocus`), and
|
|
53961
|
+
* its menus to show another's or show all again.
|
|
53962
|
+
*/
|
|
53963
|
+
lineageOptions() {
|
|
53964
|
+
const view = {};
|
|
53965
|
+
const focus = this.lineageFocus;
|
|
53966
|
+
if (focus) {
|
|
53967
|
+
const { up, down } = this.model.lineage(focus.level, focus.id);
|
|
53968
|
+
const on = new Set([`${focus.level}:${focus.id}`, ...[...up, ...down].map(({ level, entity }) => `${level}:${entity.id}`)]);
|
|
53969
|
+
const drawn = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: DataModel.MAPPING });
|
|
53970
|
+
view.entities = drawn.entities.map(e => (on.has(e.id) ? e : { ...e, color: '#f3f4f6' }));
|
|
53971
|
+
view.links = drawn.links.map(l => (on.has(l.source) && on.has(l.target) ? l : { ...l, color: '#e5e7eb' }));
|
|
53972
|
+
}
|
|
53973
|
+
const split = (nodeId) => { const i = String(nodeId).indexOf(':'); return [nodeId.slice(0, i), nodeId.slice(i + 1)]; };
|
|
53974
|
+
return {
|
|
53975
|
+
...view,
|
|
53976
|
+
contextMenuItems: [{ label: 'Show lineage', action: (node) => { const [level, id] = split(node.id); this.showLineage(level, id); } }],
|
|
53977
|
+
canvasContextMenuItems: [{ label: 'Show all', action: () => { this.lineageFocus = null; this._redraw(); } }],
|
|
53978
|
+
};
|
|
53979
|
+
}
|
|
53980
|
+
|
|
53981
|
+
/** Whether what is shown is a level of the schema (so its entities and relationships can be edited here). */
|
|
53982
|
+
get editsLevel() {
|
|
53983
|
+
return !DataModel.otherView(this.level);
|
|
53984
|
+
}
|
|
53985
|
+
|
|
53986
|
+
/**
|
|
53987
|
+
* At a level of the schema: double-click an entity or a relationship to
|
|
53988
|
+
* edit it; right-click one to edit or delete it, or the canvas for a new
|
|
53989
|
+
* entity there or a new relationship. (These stand in for graphDiagram's
|
|
53990
|
+
* own, which change only the drawing.)
|
|
53991
|
+
*/
|
|
53992
|
+
editingOptions() {
|
|
53993
|
+
const relationshipOf = (drawn) => this.model.relationshipIndex(this.level, drawn);
|
|
53994
|
+
const isEntity = (node) => !!this.model.level(this.level).entity(node.id);
|
|
53995
|
+
return {
|
|
53996
|
+
onNodeDoubleClick: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))),
|
|
53997
|
+
onEdgeDoubleClick: (edge) => this.editRelationship(relationshipOf(edge)),
|
|
53998
|
+
contextMenuItems: [
|
|
53999
|
+
{ label: 'Edit…', action: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))) },
|
|
54000
|
+
{ label: 'Delete', action: (node) => (isEntity(node) ? this.removeEntity(node.id) : this.removeRelationship(relationshipOf(node))) },
|
|
54001
|
+
{ label: 'Show lineage', action: (node) => { if (isEntity(node)) this.showLineage(this.level, node.id); } },
|
|
54002
|
+
],
|
|
54003
|
+
edgeContextMenuItems: [
|
|
54004
|
+
{ label: 'Edit relationship…', action: (edge) => this.editRelationship(relationshipOf(edge)) },
|
|
54005
|
+
{ label: 'Delete relationship', action: (edge) => this.removeRelationship(relationshipOf(edge)) },
|
|
54006
|
+
],
|
|
54007
|
+
canvasContextMenuItems: [
|
|
54008
|
+
{ label: `New ${this.levelInfo.entityNoun.toLowerCase()} here…`, action: (subject, engine, point) => this.editEntity(null, point) },
|
|
54009
|
+
{ label: 'New relationship…', action: () => this.editRelationship(null) },
|
|
54010
|
+
],
|
|
54011
|
+
};
|
|
54012
|
+
}
|
|
54013
|
+
|
|
54014
|
+
/** The level shown, as DataModelNotation has it (its nouns, its dialogs' fields). */
|
|
54015
|
+
get levelInfo() {
|
|
54016
|
+
return DataModelNotation.level(this.level);
|
|
54017
|
+
}
|
|
54018
|
+
|
|
54019
|
+
/**
|
|
54020
|
+
* An entity's dialog at the level shown (none: a new one, placed at
|
|
54021
|
+
* `point` or below the others): its level's fields (name, definition, and
|
|
54022
|
+
* at the logical and physical levels its attributes as a table). Saved, it
|
|
54023
|
+
* is one undo step.
|
|
54024
|
+
*/
|
|
54025
|
+
editEntity(id, point = null) {
|
|
54026
|
+
if (!this.editsLevel) return null;
|
|
54027
|
+
const info = this.levelInfo;
|
|
54028
|
+
const schema = this.model.level(this.level);
|
|
54029
|
+
const entity = id ? schema.entity(id) : null;
|
|
54030
|
+
if (id && !entity) return null;
|
|
54031
|
+
const scheme = this.model.classificationScheme;
|
|
54032
|
+
const context = { typeNames: this.model.dataTypes.map(t => t.name), sensitivities: scheme.levels.map(l => l.name), categories: scheme.categories };
|
|
54033
|
+
const title = entity ? `${info.entityNoun}: ${entity.name}` : `New ${info.entityNoun.toLowerCase()}`;
|
|
54034
|
+
return this._dialog(title, info.entityFields(entity, context), (values) => {
|
|
54035
|
+
const spec = info.entitySpecFrom(values, entity);
|
|
54036
|
+
if (!entity) Object.assign(spec, point ?? this._freeSpot(schema));
|
|
54037
|
+
this.runModelEdit(this.model.saveEntityCommand(this.level, id, spec));
|
|
54038
|
+
});
|
|
54039
|
+
}
|
|
54040
|
+
|
|
54041
|
+
/** Where a new entity goes with no point given: below what is there. @private */
|
|
54042
|
+
_freeSpot(schema) {
|
|
54043
|
+
const ys = schema.entities.map(e => e.y).filter(Number.isFinite);
|
|
54044
|
+
return { x: 160, y: (ys.length ? Math.max(...ys) : 0) + 160 };
|
|
54045
|
+
}
|
|
54046
|
+
|
|
54047
|
+
removeEntity(id) {
|
|
54048
|
+
if (!this.editsLevel || !this.model.level(this.level).entity(id)) return null;
|
|
54049
|
+
return this.runModelEdit(this.model.removeEntityCommand(this.level, id));
|
|
54050
|
+
}
|
|
54051
|
+
|
|
54052
|
+
/** A relationship's dialog at the level shown (`index` null or -1: a new one); saved, one undo step. */
|
|
54053
|
+
editRelationship(index) {
|
|
54054
|
+
if (!this.editsLevel) return null;
|
|
54055
|
+
const info = this.levelInfo;
|
|
54056
|
+
const schema = this.model.level(this.level);
|
|
54057
|
+
const relationship = index == null || index < 0 ? null : schema.relationships[index];
|
|
54058
|
+
if (!schema.entities.length) return null;
|
|
54059
|
+
const title = relationship ? `Relationship: ${relationship.name || `${relationship.source} – ${relationship.target}`}` : 'New relationship';
|
|
54060
|
+
return this._dialog(title, info.relationshipFields(schema.entities, relationship), (values) => {
|
|
54061
|
+
this.runModelEdit(this.model.saveRelationshipCommand(this.level, relationship ? index : null, info.relationshipSpecFrom(values, relationship)));
|
|
54062
|
+
}, ({ source, target }) => (source && target ? null : 'Choose both ends'));
|
|
54063
|
+
}
|
|
54064
|
+
|
|
54065
|
+
removeRelationship(index) {
|
|
54066
|
+
if (!this.editsLevel || index == null || index < 0) return null;
|
|
54067
|
+
return this.runModelEdit(this.model.removeRelationshipCommand(this.level, index));
|
|
54068
|
+
}
|
|
54069
|
+
|
|
54070
|
+
/** See the model through a service domain (an id), at the logical level, where its business objects are. */
|
|
54071
|
+
openServiceDomain(id) {
|
|
54072
|
+
if (!this.model.perspective(id)) return;
|
|
54073
|
+
this.chartOptions.level = 'logical';
|
|
54074
|
+
this.setPerspective(id);
|
|
54075
|
+
}
|
|
54076
|
+
|
|
54077
|
+
render() {
|
|
54078
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
|
|
54079
|
+
const toolbar = this.container.appendChild(el({}));
|
|
54080
|
+
super.render();
|
|
54081
|
+
Object.assign(this.canvasHost.style, { flex: '1 1 auto', minHeight: '0', height: 'auto' });
|
|
54082
|
+
this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.container.ownerDocument }).render();
|
|
54083
|
+
}
|
|
54084
|
+
|
|
54085
|
+
/**
|
|
54086
|
+
* Model ▸ (perspectives and contexts of one's own), Perspective and Context
|
|
54087
|
+
* (when the model has any; the context's scope beside them), Level (each
|
|
54088
|
+
* level from DataModelNotation, then Mapping) and Notation, and Export ▸.
|
|
54089
|
+
*/
|
|
54090
|
+
menuBarItems() {
|
|
54091
|
+
const { perspectives, contexts } = this.model;
|
|
54092
|
+
const host = this.modelsHost;
|
|
54093
|
+
return [
|
|
54094
|
+
new ToolbarMenu('File', () => this.fileItems()),
|
|
54095
|
+
new ToolbarMenu('Edit', () => this.editItems()),
|
|
54096
|
+
...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
|
|
54097
|
+
new ToolbarMenu('Model', () => this.modelItems()),
|
|
54098
|
+
...(perspectives.length ? [new ToolbarSelect('Perspective',
|
|
54099
|
+
() => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.constructor === DataModelPerspective ? p.name : `${p.name} (${p.constructor.kindLabel.toLowerCase()})`, p.description])],
|
|
54100
|
+
() => this.perspectiveId ?? '', (v) => this.setPerspective(v))] : []),
|
|
54101
|
+
...(contexts.length ? [
|
|
54102
|
+
new ToolbarSelect('Context',
|
|
54103
|
+
() => [['', 'None'], ...this.model.contexts.map(c => [c.id, c.name, c.description])],
|
|
54104
|
+
() => this.contextId ?? '', (v) => this.setContext(v)),
|
|
54105
|
+
new ToolbarText(() => this.context?.scope ?? ''),
|
|
54106
|
+
] : []),
|
|
54107
|
+
new ToolbarSelect('Level', () => [
|
|
54108
|
+
...DataModelNotation.levels().map(l => [l.name, l.label, l.description]),
|
|
54109
|
+
...DataModel.OTHER_VIEWS.filter(v => v.offered(this.model) || v.name === this.level).map(v => [v.name, v.label, v.description]),
|
|
54110
|
+
], () => this.level, (v) => this.setLevel(v)),
|
|
54111
|
+
new ToolbarSelect('Notation', () => DataModelNotation.notations().map(n => [n.name, n.label]), () => this.notation, (v) => this.setNotation(v)),
|
|
54112
|
+
new ToolbarMenu('Export', () => this.exportItems()),
|
|
54113
|
+
];
|
|
54114
|
+
}
|
|
54115
|
+
|
|
54116
|
+
/** Model ▸: make, change or remove a perspective or a context (the one chosen). */
|
|
54117
|
+
modelItems() {
|
|
54118
|
+
return [
|
|
54119
|
+
{ label: `New ${this.editsLevel ? this.levelInfo.entityNoun.toLowerCase() : 'entity'}…`, disabled: !this.editsLevel || undefined, action: () => this.editEntity(null) },
|
|
54120
|
+
{ label: 'New relationship…', disabled: !this.editsLevel || !this.model.level(this.level).entities.length || undefined, action: () => this.editRelationship(null) },
|
|
54121
|
+
{ separator: true },
|
|
54122
|
+
{ label: 'New perspective…', action: () => this.editPerspective(null) },
|
|
54123
|
+
{ label: 'New service domain…', action: () => this.editPerspective(null, ServiceDomain) },
|
|
54124
|
+
{ label: 'New classification lens…', action: () => this.editPerspective(null, ClassificationLens) },
|
|
54125
|
+
{ label: 'Read service domains from a BIAN landscape…', action: () => this.readLandscape() },
|
|
54126
|
+
{ label: 'Read a BIAN service domain (semantic API)…', action: () => this.readSemanticApi() },
|
|
54127
|
+
{ label: 'Edit perspective…', disabled: !this.perspective || undefined, action: () => this.editPerspective(this.perspective) },
|
|
54128
|
+
{ label: 'Delete perspective', disabled: !this.perspective || undefined, action: () => this.removePerspective(this.perspectiveId) },
|
|
54129
|
+
{ separator: true },
|
|
54130
|
+
...EditorPart.menuGroup('Data types', this.dataTypeItems()),
|
|
54131
|
+
{ separator: true },
|
|
54132
|
+
{ label: 'New context…', action: () => this.editContext(null) },
|
|
54133
|
+
{ label: 'Edit context…', disabled: !this.context || undefined, action: () => this.editContext(this.context) },
|
|
54134
|
+
{ label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
|
|
54135
|
+
{ separator: true },
|
|
54136
|
+
{ label: 'Check model…', action: () => this.checkModel() },
|
|
54137
|
+
{ label: 'Scorecard…', action: () => this.scoreModel() },
|
|
54138
|
+
...EditorPart.menuGroup('Naming standard', this.namingItems()),
|
|
54139
|
+
{ label: 'Classification scheme…', action: () => this.editClassificationScheme() },
|
|
54140
|
+
];
|
|
54141
|
+
}
|
|
54142
|
+
|
|
54143
|
+
/**
|
|
54144
|
+
* Service domains from BIAN's published service landscape (an ArchiMate
|
|
54145
|
+
* model: an Archi file or the Exchange Format; BianLandscapeReader), as one
|
|
54146
|
+
* undo step (DataModel#serviceDomainsCommand); then the landscape is shown.
|
|
54147
|
+
* @param {Object} [options] - As readInto's, and `reader` (a BianLandscapeReader)
|
|
54148
|
+
* @returns {Promise<Command|null>}
|
|
54149
|
+
*/
|
|
54150
|
+
readLandscape({ reader = new BianLandscapeReader(), ...options } = {}) {
|
|
54151
|
+
let found = 0;
|
|
54152
|
+
return this.readInto({
|
|
54153
|
+
...options, accept: '.archimate,.xml', what: 'the landscape',
|
|
54154
|
+
command: (bytes, name) => {
|
|
54155
|
+
const specs = reader.read(BianLandscapeReader.parse(bytes));
|
|
54156
|
+
found = specs.length;
|
|
54157
|
+
return this.model.serviceDomainsCommand(specs, `"${name}"`);
|
|
54158
|
+
},
|
|
54159
|
+
nothing: (name) => (found
|
|
54160
|
+
? `"${name}" brings nothing new: its ${found} service domain${found === 1 ? ' is' : 's are'} here as ${found === 1 ? 'it is' : 'they are'}.`
|
|
54161
|
+
: `"${name}" has no service domains (Capabilities with none inside them).`),
|
|
54162
|
+
then: () => { this.chartOptions.level = DataModel.LANDSCAPE; this.perspectiveId = null; },
|
|
54163
|
+
});
|
|
54164
|
+
}
|
|
54165
|
+
|
|
54166
|
+
/**
|
|
54167
|
+
* A service domain from one of BIAN's semantic APIs (an OpenAPI file, YAML
|
|
54168
|
+
* or JSON; BianSemanticApiReader): its control record and behaviour
|
|
54169
|
+
* qualifiers as logical entities, the schemas they use as data types, and
|
|
54170
|
+
* the service domain, as one undo step (DataModel#bringCommand); then the
|
|
54171
|
+
* model is seen through it at the logical level. An entity the model has
|
|
54172
|
+
* by name stays as it is and is used.
|
|
54173
|
+
* @param {Object} [options] - As readInto's, and `depth` (BianSemanticApiReader's)
|
|
54174
|
+
* @returns {Promise<Command|null>}
|
|
54175
|
+
*/
|
|
54176
|
+
readSemanticApi({ depth = 1, ...options } = {}) {
|
|
54177
|
+
let read = null;
|
|
54178
|
+
return this.readInto({
|
|
54179
|
+
...options, accept: '.yaml,.yml,.json', what: 'the semantic API',
|
|
54180
|
+
command: (bytes, name) => {
|
|
54181
|
+
const reader = new BianSemanticApiReader({ depth, entityNames: this.model.level('logical').entities.map(e => e.name) });
|
|
54182
|
+
read = reader.read(BianSemanticApiReader.parse(new TextDecoder().decode(bytes)));
|
|
54183
|
+
const [sd] = read.serviceDomains;
|
|
54184
|
+
return this.model.bringCommand(read, `Read ${sd.name} from "${name}"`);
|
|
54185
|
+
},
|
|
54186
|
+
nothing: (name) => `"${name}" brings nothing the model does not have.`,
|
|
54187
|
+
then: () => {
|
|
54188
|
+
// The service domain is the one here of its id or name (bringCommand keeps it), else the new one.
|
|
54189
|
+
const [sd] = read.serviceDomains;
|
|
54190
|
+
const here = this.model.perspective(sd.id) ?? this.model.perspectives.find(p => p.name.toLowerCase() === sd.name.toLowerCase());
|
|
54191
|
+
this.chartOptions.level = 'logical';
|
|
54192
|
+
this.perspectiveId = here?.id ?? sd.id;
|
|
54193
|
+
},
|
|
54194
|
+
});
|
|
54195
|
+
}
|
|
54196
|
+
|
|
54197
|
+
/**
|
|
54198
|
+
* The Data Model Scorecard (ModelScorecard): each category as the model
|
|
54199
|
+
* measures it, a reviewer's score and note, its weight; the total in the
|
|
54200
|
+
* title. Saved, the reviews and weights are kept (one undo step).
|
|
54201
|
+
*/
|
|
54202
|
+
scoreModel() {
|
|
54203
|
+
const card = this.model.scored();
|
|
54204
|
+
const results = card.results();
|
|
54205
|
+
const { score, coverage } = card.total(results);
|
|
54206
|
+
const title = score == null ? 'Scorecard: nothing scored yet' : `Scorecard: ${score} of 100 (${coverage}% of the weight scored)`;
|
|
54207
|
+
return this._dialog(title, card.fields(results), (values) => {
|
|
54208
|
+
this.runModelEdit(this.model.scorecardCommand(ModelScorecard.specFrom(values)));
|
|
54209
|
+
}, () => null);
|
|
54210
|
+
}
|
|
54211
|
+
|
|
54212
|
+
/** The classification scheme's dialog (its sensitivity levels in order, its categories); saved, one undo step. */
|
|
54213
|
+
editClassificationScheme() {
|
|
54214
|
+
return this._dialog('Classification scheme', ClassificationScheme.fields(this.model.classificationScheme), (values) => {
|
|
54215
|
+
this.runModelEdit(this.model.classificationSchemeCommand(ClassificationScheme.specFrom(values)));
|
|
54216
|
+
}, (values) => (ClassificationScheme.specFrom(values).levels.length ? null : 'Give it at least one level'));
|
|
54217
|
+
}
|
|
54218
|
+
|
|
54219
|
+
/** Model ▸ Naming standard ▸: edit it, start from the conventional one, or remove it (Check model… checks names against it). */
|
|
54220
|
+
namingItems() {
|
|
54221
|
+
const has = !!this.model.namingStandard;
|
|
54222
|
+
return [
|
|
54223
|
+
{ label: has ? 'Edit…' : 'Set…', action: () => this.editNamingStandard() },
|
|
54224
|
+
...(has ? [] : [{ label: 'Use the conventional one', action: () => this.runModelEdit(this.model.namingStandardCommand(NamingStandard.CONVENTIONAL)) }]),
|
|
54225
|
+
...(has ? [{ label: 'Remove', action: () => this.runModelEdit(this.model.namingStandardCommand(null)) }] : []),
|
|
54226
|
+
];
|
|
54227
|
+
}
|
|
54228
|
+
|
|
54229
|
+
/**
|
|
54230
|
+
* The naming standard's dialog (NamingStandard.fields: a rule per level for
|
|
54231
|
+
* entities and attributes); saved, one undo step. A standard that asks
|
|
54232
|
+
* nothing is none.
|
|
54233
|
+
*/
|
|
54234
|
+
editNamingStandard() {
|
|
54235
|
+
return this._dialog('Naming standard', NamingStandard.fields(this.model.namingStandard), (values) => {
|
|
54236
|
+
const spec = NamingStandard.specFrom(values);
|
|
54237
|
+
const asks = Object.values(spec).some(kinds => Object.values(kinds).some(r => r.style || r.classWords.length || r.maxLength || Object.keys(r.abbreviations).length));
|
|
54238
|
+
this.runModelEdit(this.model.namingStandardCommand(asks ? spec : null));
|
|
54239
|
+
}, () => null);
|
|
54240
|
+
}
|
|
54241
|
+
|
|
54242
|
+
/** Model ▸ Data types ▸: a new one of each kind, then each to change or remove. */
|
|
54243
|
+
dataTypeItems() {
|
|
54244
|
+
const types = this.model.dataTypes;
|
|
54245
|
+
return [
|
|
54246
|
+
...[EnumerationType, StructuredType, DataType].map(Kind => ({ label: `New ${Kind.kindLabel.toLowerCase()}…`, action: () => this.editDataType(null, Kind) })),
|
|
54247
|
+
...(types.length ? [
|
|
54248
|
+
{ separator: true },
|
|
54249
|
+
{ label: 'Show data types', action: () => this.setLevel(DataModel.DATA_TYPES) },
|
|
54250
|
+
{ label: 'Edit', items: types.map(t => ({ label: `${t.name} (${t.constructor.kindLabel.toLowerCase()})…`, action: () => this.editDataType(t) })) },
|
|
54251
|
+
{ label: 'Delete', items: types.map(t => ({ label: t.name, action: () => this.removeDataType(t.id) })) },
|
|
54252
|
+
] : []),
|
|
54253
|
+
];
|
|
54254
|
+
}
|
|
54255
|
+
|
|
54256
|
+
/** A dialog making a data type of a kind (none given) or changing one (its kind's fields); applied, the data types are shown. */
|
|
54257
|
+
editDataType(type, Kind = type?.constructor ?? DataType) {
|
|
54258
|
+
const title = type ? `${Kind.kindLabel}: ${type.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
|
|
54259
|
+
return this._dialog(title, Kind.fields(this.model, type), (values) => {
|
|
54260
|
+
const spec = Kind.specFrom(values, this.model, type);
|
|
54261
|
+
if (type) this.model.updateDataType(type.id, spec); else this.model.addDataType(spec);
|
|
54262
|
+
this.chartOptions.level = DataModel.DATA_TYPES;
|
|
54263
|
+
this._changed();
|
|
54264
|
+
this._redraw();
|
|
54265
|
+
});
|
|
54266
|
+
}
|
|
54267
|
+
|
|
54268
|
+
removeDataType(id) {
|
|
54269
|
+
this.model.removeDataType(id);
|
|
54270
|
+
if (this.level === DataModel.DATA_TYPES && !this.model.dataTypes.length) this.chartOptions.level = 'logical';
|
|
54271
|
+
this._changed();
|
|
54272
|
+
this._redraw();
|
|
54273
|
+
}
|
|
54274
|
+
|
|
54275
|
+
/** Check the model (DataModelChecker) and list the findings; choosing one shows it (revealFinding). Returns the findings. */
|
|
54276
|
+
checkModel() {
|
|
54277
|
+
const findings = this.model.checker().run();
|
|
54278
|
+
new ModelCheckDialog({ findings, onReveal: (finding) => this.revealFinding(finding), document: this.document }).open();
|
|
54279
|
+
return findings;
|
|
54280
|
+
}
|
|
54281
|
+
|
|
54282
|
+
/** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
|
|
54283
|
+
revealFinding(finding) {
|
|
54284
|
+
if (finding.kind === 'perspective') {
|
|
54285
|
+
this.chartOptions.level = 'logical';
|
|
54286
|
+
this.setPerspective(finding.ids[0]);
|
|
54287
|
+
} else if (finding.kind === 'mapping') {
|
|
54288
|
+
this.setLevel(DataModel.MAPPING);
|
|
54289
|
+
} else if (finding.kind === 'data-type') {
|
|
54290
|
+
this.setLevel(DataModel.DATA_TYPES);
|
|
54291
|
+
} else if (finding.level) {
|
|
54292
|
+
this.setLevel(finding.level);
|
|
54293
|
+
}
|
|
54294
|
+
this.menuBar?.render?.();
|
|
54295
|
+
}
|
|
54296
|
+
|
|
54297
|
+
/** A dialog making a perspective (none given) or changing one (DataModelPerspective.fields); applied, it is the one seen through. */
|
|
54298
|
+
editPerspective(perspective, Kind = perspective?.constructor ?? DataModelPerspective) {
|
|
54299
|
+
const title = perspective ? `${Kind.kindLabel}: ${perspective.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
|
|
54300
|
+
return this._dialog(title, Kind.fields(this.model, perspective), (values) => {
|
|
54301
|
+
const spec = Kind.specFrom(values, this.model, perspective);
|
|
54302
|
+
const saved = perspective ? this.model.updatePerspective(perspective.id, spec) : this.model.addPerspective(spec);
|
|
54303
|
+
this._changed();
|
|
54304
|
+
this.setPerspective(saved.id);
|
|
54305
|
+
});
|
|
54306
|
+
}
|
|
54307
|
+
|
|
54308
|
+
/** A dialog making a context (none given) or changing one (DataModelContext.fields); applied, it is the one read in. */
|
|
54309
|
+
editContext(context) {
|
|
54310
|
+
return this._dialog(context ? `Context: ${context.name}` : 'New context', DataModelContext.fields(this.model, context), (values) => {
|
|
54311
|
+
const spec = DataModelContext.specFrom(values, this.model, context);
|
|
54312
|
+
const saved = context ? this.model.updateContext(context.id, spec) : this.model.addContext(spec);
|
|
54313
|
+
this._changed();
|
|
54314
|
+
this.setContext(saved.id);
|
|
54315
|
+
});
|
|
54316
|
+
}
|
|
54317
|
+
|
|
54318
|
+
removePerspective(id) {
|
|
54319
|
+
this.model.removePerspective(id);
|
|
54320
|
+
this._changed();
|
|
54321
|
+
this.setPerspective(null);
|
|
54322
|
+
}
|
|
54323
|
+
|
|
54324
|
+
removeContext(id) {
|
|
54325
|
+
this.model.removeContext(id);
|
|
54326
|
+
this._changed();
|
|
54327
|
+
this.setContext(null);
|
|
54328
|
+
}
|
|
54329
|
+
|
|
54330
|
+
/** Open a form (a name is needed). @private */
|
|
54331
|
+
_dialog(title, fields, apply, validate = ({ name }) => ((name ?? '').trim() ? null : 'Give it a name')) {
|
|
54332
|
+
return new FormDialog({
|
|
54333
|
+
title, fields, submitLabel: 'Save', idPrefix: 'data-model-',
|
|
54334
|
+
document: this.container?.ownerDocument ?? globalThis.document,
|
|
54335
|
+
validate, onApply: apply,
|
|
54336
|
+
}).open();
|
|
54337
|
+
}
|
|
54338
|
+
|
|
54339
|
+
/** The perspectives or contexts changed: the toolbar shows them anew. @private */
|
|
54340
|
+
_changed() {
|
|
54341
|
+
if (this.menuBar) this.menuBar.items = this.menuBarItems();
|
|
54342
|
+
this.menuBar?.render?.();
|
|
54343
|
+
}
|
|
54344
|
+
|
|
54345
|
+
/** Export ▸: the picture, as PNG or SVG, named after the model. */
|
|
54346
|
+
exportItems() {
|
|
54347
|
+
const name = this.model.name || this.title || 'data-model';
|
|
54348
|
+
return [
|
|
54349
|
+
{ label: 'Image (PNG)', action: () => downloadPng(this.canvasHost, { name }) },
|
|
54350
|
+
{ label: 'Image (SVG)', action: () => downloadSvg(this.canvasHost, { name }) },
|
|
54351
|
+
{ separator: true },
|
|
54352
|
+
{ label: 'SQL DDL (physical level)', action: () => this.exportDdl() },
|
|
54353
|
+
];
|
|
54354
|
+
}
|
|
54355
|
+
|
|
54356
|
+
/** The physical level as SQL DDL (SqlDdlFileFormat), downloaded as the model's name. Returns the file. */
|
|
54357
|
+
exportDdl({ download = (file, fileName) => downloadBlob(new (this.document.defaultView ?? globalThis).Blob([file.data], { type: file.mimeType }), fileName) } = {}) {
|
|
54358
|
+
const file = new SqlDdlFileFormat().export(this.model);
|
|
54359
|
+
download(file, `${String(this.model.name || this.title || 'model').replace(/[^A-Za-z0-9._-]+/g, '-')}.sql`);
|
|
54360
|
+
return file;
|
|
54361
|
+
}
|
|
54362
|
+
|
|
54363
|
+
/** Redraw now (a choice made), when there is somewhere to draw. @private */
|
|
54364
|
+
_redraw() {
|
|
54365
|
+
if (this.canvasHost) this._draw();
|
|
54366
|
+
// Its toolbar shows what is drawn, however it was chosen (a finding, a lineage, a service domain opened).
|
|
54367
|
+
this.menuBar?.refresh?.();
|
|
54368
|
+
}
|
|
54369
|
+
|
|
54370
|
+
/**
|
|
54371
|
+
* Keep where the boxes and notes were dragged (the drawing is made anew each
|
|
54372
|
+
* time): on the level shown, or in the mapping view's own layout. Then draw.
|
|
54373
|
+
*/
|
|
54374
|
+
_draw() {
|
|
54375
|
+
if (this._keepPlaces()) this.model.edited(); // a box or note moved is a change, kept and saved
|
|
54376
|
+
this._drawnLevel = this.level;
|
|
54377
|
+
super._draw();
|
|
54378
|
+
this._drawnPlaces = this._placesOnScreen();
|
|
54379
|
+
}
|
|
54380
|
+
|
|
54381
|
+
/** Where the drawing has each box and note now, a copy a drag does not change. @private */
|
|
54382
|
+
_placesOnScreen() {
|
|
54383
|
+
const pos = this.engine?._graph?.pos;
|
|
54384
|
+
return pos ? new Map([...pos].map(([id, p]) => [id, { x: p.x, y: p.y }])) : null;
|
|
54385
|
+
}
|
|
54386
|
+
|
|
54387
|
+
/**
|
|
54388
|
+
* Keep where the drawing on screen has its boxes and notes: on the level
|
|
54389
|
+
* it shows, or in the mapping view's layout. Returns whether any moved.
|
|
54390
|
+
* A box the model has a place for and that was not moved on screen since
|
|
54391
|
+
* it was drawn keeps the model's place — the model may have moved it
|
|
54392
|
+
* since (laid out, an undo), and the screen still shows where it was. @private
|
|
54393
|
+
*/
|
|
54394
|
+
_keepPlaces() {
|
|
54395
|
+
const pos = this.engine?._graph?.pos;
|
|
54396
|
+
if (!pos || !this._drawnLevel) return false;
|
|
54397
|
+
let moved = false;
|
|
54398
|
+
const place = (p, before, put, id) => {
|
|
54399
|
+
if (!p || (before && before.x === p.x && before.y === p.y)) return;
|
|
54400
|
+
const drawn = this._drawnPlaces?.get(id);
|
|
54401
|
+
if (drawn && drawn.x === p.x && drawn.y === p.y && Number.isFinite(before?.x) && Number.isFinite(before?.y)) return;
|
|
54402
|
+
put({ x: p.x, y: p.y });
|
|
54403
|
+
moved = true;
|
|
54404
|
+
};
|
|
54405
|
+
const other = DataModel.otherView(this._drawnLevel);
|
|
54406
|
+
if (other && !other.keepsPlaces) return false; // laid out by its structure, anew each time
|
|
54407
|
+
if (other) {
|
|
54408
|
+
for (const [id, p] of pos) {
|
|
54409
|
+
if (id.includes(':')) place(p, this.model.mappingLayout[id], (at) => { this.model.mappingLayout[id] = at; }, id);
|
|
54410
|
+
}
|
|
54411
|
+
return moved;
|
|
54412
|
+
}
|
|
54413
|
+
for (const entity of this.model.level(this._drawnLevel).entities) {
|
|
54414
|
+
place(pos.get(entity.id), entity, (at) => Object.assign(entity, at), entity.id);
|
|
54415
|
+
}
|
|
54416
|
+
for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
|
|
54417
|
+
const p = pos.get(rule.id);
|
|
54418
|
+
const before = this.model.notePlace(this._drawnLevel, rule);
|
|
54419
|
+
place(p, Number.isFinite(before.x) ? before : null, (at) => this.model.placeNote(this._drawnLevel, rule, at), rule.id);
|
|
54420
|
+
}
|
|
54421
|
+
return moved;
|
|
54422
|
+
}
|
|
54423
|
+
|
|
54424
|
+
dispose() {
|
|
54425
|
+
this.menuBar?.dispose?.();
|
|
54426
|
+
super.dispose();
|
|
54427
|
+
}
|
|
54428
|
+
|
|
54429
|
+
/** The spec again: the model (with where its boxes are), what is chosen, and the other options. */
|
|
54430
|
+
serialize() {
|
|
54431
|
+
const { options, chartType, ...base } = super.serialize();
|
|
54432
|
+
const { level, notation, ...rest } = options;
|
|
54433
|
+
return {
|
|
54434
|
+
...base, ...this.model.toJSON(),
|
|
54435
|
+
perspective: this.perspectiveId, context: this.contextId, level, notation, options: rest,
|
|
54436
|
+
};
|
|
54437
|
+
}
|
|
54438
|
+
}
|
|
54439
|
+
|
|
54440
|
+
/** The ways the model is shown: [id, label]. */
|
|
54441
|
+
const VIEWS = Object.freeze([['map', 'Story map'], ['backlog', 'Backlog']]);
|
|
54442
|
+
|
|
54443
|
+
/** How a story card and the map's headers look, from the workspace theme. */
|
|
54444
|
+
const CARD = {
|
|
54445
|
+
background: 'var(--ws-panel-bg, #fff)', color: 'var(--ws-fg, #222)', border: '1px solid var(--ws-border, #ccc)',
|
|
54446
|
+
borderLeft: '4px solid #f0c040', borderRadius: '4px', padding: '6px 8px', margin: '0 0 6px', fontSize: '12px', cursor: 'pointer',
|
|
54447
|
+
};
|
|
54448
|
+
const HEAD = { padding: '6px 8px', fontWeight: '600', fontSize: '12px', textAlign: 'left', verticalAlign: 'top', cursor: 'pointer' };
|
|
54449
|
+
const CELL = { padding: '6px', verticalAlign: 'top', minWidth: '180px', borderTop: '1px solid var(--ws-border, #ddd)', borderLeft: '1px solid var(--ws-border, #ddd)' };
|
|
54450
|
+
const BADGE = { display: 'inline-block', marginRight: '6px', color: 'var(--ws-muted, #777)', fontSize: '11px' };
|
|
54451
|
+
|
|
54452
|
+
/**
|
|
54453
|
+
* RequirementsPart — a requirements model (RequirementsModel) in a pane, as an
|
|
54454
|
+
* agile team works with it: a **story map** (epics across, releases down,
|
|
54455
|
+
* each story a card — dragged to another epic or release, or among its
|
|
54456
|
+
* neighbours) or the **backlog** (every story in order, a row each). A card
|
|
54457
|
+
* or a row opened edits the story: its narrative ("As a … I want … so that
|
|
54458
|
+
* …"), acceptance criteria, status, priority, points, labels and traces to
|
|
54459
|
+
* what realises it in the other models. Model ▸ adds epics, releases and
|
|
54460
|
+
* stories, brings stories in from a tracker's export or a storyboard, and
|
|
54461
|
+
* checks the model (RequirementsChecker); the toolbar shows how complete it
|
|
54462
|
+
* is (coverage: criteria, traces).
|
|
54463
|
+
*
|
|
54464
|
+
* A model of a ModelsPart (`editorType: 'requirements'`) as the other
|
|
54465
|
+
* modellers' are (ModelHosted): saved as `.requirements.json`, opened from a
|
|
54466
|
+
* tracker's export or a storyboard too, kept, compared and merged, a proposal
|
|
54467
|
+
* reviewed.
|
|
54468
|
+
*
|
|
54469
|
+
* A host extends it by subclass: `traceTargets()` — what a story can be traced
|
|
54470
|
+
* to (the elements of its other models); `resolveTrace(trace)` — whether a
|
|
54471
|
+
* trace's target is there (for the check); `storyStatus(story)` — a live state
|
|
54472
|
+
* shown on the card (a work item's), `{label, color?, title?}` or null;
|
|
54473
|
+
* `searchByMeaning(query)` — the stories nearest a question (a vector
|
|
54474
|
+
* store's), which puts Model ▸ Find by meaning… in the menu.
|
|
54475
|
+
*
|
|
54476
|
+
* Spec: `{ id, type: 'requirements', title?, view?: 'map'|'backlog', ...the
|
|
54477
|
+
* model as RequirementsModel takes it }`.
|
|
54478
|
+
*/
|
|
54479
|
+
class RequirementsPart extends ModelHosted(Part) {
|
|
54480
|
+
static type = 'requirements';
|
|
54481
|
+
|
|
54482
|
+
static VIEWS = VIEWS;
|
|
54483
|
+
|
|
54484
|
+
constructor(spec = {}) {
|
|
54485
|
+
super(spec);
|
|
54486
|
+
this._adoptModel(new RequirementsModel(spec));
|
|
54487
|
+
/** The view shown: 'map' or 'backlog'. */
|
|
54488
|
+
this.view = VIEWS.some(([id]) => id === spec.view) ? spec.view : 'map';
|
|
54489
|
+
this.menuBar = null;
|
|
54490
|
+
this.body = null;
|
|
54491
|
+
}
|
|
54492
|
+
|
|
54493
|
+
// ===== A model saved as a file, kept, backed up =====
|
|
54494
|
+
|
|
54495
|
+
/** Its own format first (what it saves as), then what other tools wrote (import only). */
|
|
54496
|
+
fileFormats() {
|
|
54497
|
+
return [
|
|
54498
|
+
RequirementsFileFormat.instance ??= new RequirementsFileFormat(),
|
|
54499
|
+
TrackerExportFileFormat.instance ??= new TrackerExportFileFormat(),
|
|
54500
|
+
StoryboardFileFormat.instance ??= new StoryboardFileFormat(),
|
|
54501
|
+
];
|
|
54502
|
+
}
|
|
54503
|
+
|
|
54504
|
+
/** Edit another model (one opened, imported or kept). */
|
|
54505
|
+
replaceModel(model) {
|
|
54506
|
+
this._adoptModel(model);
|
|
54507
|
+
this.afterModelEdit();
|
|
54508
|
+
}
|
|
54509
|
+
|
|
54510
|
+
afterModelEdit() {
|
|
54511
|
+
if (this.menuBar) { this.menuBar.items = this.menuBarItems(); this.menuBar.render?.(); }
|
|
54512
|
+
this._draw();
|
|
54513
|
+
}
|
|
54514
|
+
|
|
54515
|
+
// ===== What a host adds =====
|
|
54516
|
+
|
|
54517
|
+
/** What a story can be traced to: `[{kind, ref, name, model?}]`. None here; a host gives its models' elements. */
|
|
54518
|
+
traceTargets() {
|
|
54519
|
+
return [];
|
|
54520
|
+
}
|
|
54521
|
+
|
|
54522
|
+
/** Whether a trace's target is there: true, false, or undefined (cannot tell). */
|
|
54523
|
+
resolveTrace(trace) {
|
|
54524
|
+
return undefined;
|
|
54525
|
+
}
|
|
54526
|
+
|
|
54527
|
+
/** A live state shown on a story's card: `{label, color?, title?}`, or null. */
|
|
54528
|
+
storyStatus(story) {
|
|
54529
|
+
return null;
|
|
54530
|
+
}
|
|
54531
|
+
|
|
54532
|
+
/**
|
|
54533
|
+
* The stories nearest a question by meaning, best first: a promise of
|
|
54534
|
+
* `[{id, score}]` (score 0..1). None here; a host with a vector store gives
|
|
54535
|
+
* them, and Model ▸ Find by meaning… is offered.
|
|
54536
|
+
*/
|
|
54537
|
+
searchByMeaning(query) {
|
|
54538
|
+
return null;
|
|
54539
|
+
}
|
|
54540
|
+
|
|
54541
|
+
/** Whether stories can be found by meaning here (a host gives searchByMeaning). */
|
|
54542
|
+
get searchable() {
|
|
54543
|
+
return this.searchByMeaning !== RequirementsPart.prototype.searchByMeaning;
|
|
54544
|
+
}
|
|
54545
|
+
|
|
54546
|
+
// ===== Shown =====
|
|
54547
|
+
|
|
54548
|
+
render() {
|
|
54549
|
+
Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
|
|
54550
|
+
const toolbar = this.container.appendChild(el({}, this.document));
|
|
54551
|
+
this.body = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto', padding: '8px', background: 'var(--ws-bg, #fafafa)' } }, this.document));
|
|
54552
|
+
this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.document }).render();
|
|
54553
|
+
this._draw();
|
|
54554
|
+
}
|
|
54555
|
+
|
|
54556
|
+
/** Draw the view shown. @private */
|
|
54557
|
+
_draw() {
|
|
54558
|
+
if (!this.body) return;
|
|
54559
|
+
this.body.replaceChildren(el(this.view === 'backlog' ? this.backlogSpec() : this.storyMapSpec(), this.document));
|
|
54560
|
+
}
|
|
54561
|
+
|
|
54562
|
+
/** Show the story map or the backlog. */
|
|
54563
|
+
setView(view) {
|
|
54564
|
+
if (!VIEWS.some(([id]) => id === view)) return;
|
|
54565
|
+
this.view = view;
|
|
54566
|
+
this._draw();
|
|
54567
|
+
this.menuBar?.refresh?.();
|
|
54568
|
+
}
|
|
54569
|
+
|
|
54570
|
+
menuBarItems() {
|
|
54571
|
+
const host = this.modelsHost;
|
|
54572
|
+
return [
|
|
54573
|
+
new ToolbarMenu('File', () => this.fileItems()),
|
|
54574
|
+
new ToolbarMenu('Edit', () => this.undoItems()),
|
|
54575
|
+
...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
|
|
54576
|
+
new ToolbarMenu('Model', () => this.modelItems()),
|
|
54577
|
+
new ToolbarSelect('View', () => VIEWS.map(([id, label]) => [id, label]), () => this.view, (v) => this.setView(v)),
|
|
54578
|
+
new ToolbarText(() => this.coverageText()),
|
|
54579
|
+
];
|
|
54580
|
+
}
|
|
54581
|
+
|
|
54582
|
+
/** How complete it is, in a line: stories, with criteria, traced. */
|
|
54583
|
+
coverageText() {
|
|
54584
|
+
const c = this.model.coverage();
|
|
54585
|
+
if (!c.stories) return 'No stories yet';
|
|
54586
|
+
const pct = (share) => `${Math.round(share * 100)}%`;
|
|
54587
|
+
return `${c.stories} stor${c.stories === 1 ? 'y' : 'ies'} · ${pct(c.criteriaShare)} with acceptance criteria · ${pct(c.traceShare)} traced`;
|
|
54588
|
+
}
|
|
54589
|
+
|
|
54590
|
+
/** Model ▸: new epic, release and story; stories brought in; the check. */
|
|
54591
|
+
modelItems() {
|
|
54592
|
+
return [
|
|
54593
|
+
{ label: 'New story…', action: () => this.editStory(null) },
|
|
54594
|
+
{ label: 'New epic…', action: () => this.editEpic(null) },
|
|
54595
|
+
{ label: 'New release…', action: () => this.editRelease(null) },
|
|
54596
|
+
{ separator: true },
|
|
54597
|
+
...EditorPart.menuGroup('Bring in stories', [
|
|
54598
|
+
{ label: 'From a tracker\'s export (Jira, Azure DevOps, GitLab)…', action: () => this.bringFrom(this.fileFormats()[1]) },
|
|
54599
|
+
{ label: 'From a storyboard (Markdown)…', action: () => this.bringFrom(this.fileFormats()[2]) },
|
|
54600
|
+
]),
|
|
54601
|
+
{ separator: true },
|
|
54602
|
+
...(this.searchable ? [{ label: 'Find by meaning…', action: () => this.findByMeaning() }] : []),
|
|
54603
|
+
{ label: 'Check model…', action: () => this.checkModel() },
|
|
54604
|
+
];
|
|
54605
|
+
}
|
|
54606
|
+
|
|
54607
|
+
/** Ask what to look for, then list the stories nearest it by meaning; one chosen is opened. */
|
|
54608
|
+
findByMeaning() {
|
|
54609
|
+
return new FormDialog({
|
|
54610
|
+
title: 'Find stories by meaning', document: this.document, submitLabel: 'Find',
|
|
54611
|
+
description: 'Describe what you are looking for in your own words: stories that mean the same are found, whatever their words.',
|
|
54612
|
+
fields: [{ key: 'query', label: 'Looking for', value: '' }],
|
|
54613
|
+
validate: (v) => (String(v.query ?? '').trim() ? null : 'Say what to look for'),
|
|
54614
|
+
onApply: ({ query }) => this.showFound(query),
|
|
54615
|
+
}).open();
|
|
54616
|
+
}
|
|
54617
|
+
|
|
54618
|
+
/** The stories nearest `query` (searchByMeaning), listed to open; a failure said. */
|
|
54619
|
+
async showFound(query) {
|
|
54620
|
+
let results;
|
|
54621
|
+
try {
|
|
54622
|
+
results = (await this.searchByMeaning(query)) ?? [];
|
|
54623
|
+
} catch (err) {
|
|
54624
|
+
(this.document.defaultView ?? globalThis).alert?.(`Could not search: ${err.message}`);
|
|
54625
|
+
return null;
|
|
54626
|
+
}
|
|
54627
|
+
const found = results.filter(r => this.model.story(r.id)).map(r => ({
|
|
54628
|
+
code: 'found', severity: 'info', kind: 'story', ids: [r.id],
|
|
54629
|
+
message: `${Math.round((r.score ?? 0) * 100)}% — ${this.model.story(r.id).name}`,
|
|
54630
|
+
}));
|
|
54631
|
+
new ModelCheckDialog({
|
|
54632
|
+
findings: found, title: `Stories like "${query}"`, empty: 'No story found: index the model for search, or ask another way.',
|
|
54633
|
+
onReveal: (f) => this.revealFinding(f), document: this.document,
|
|
54634
|
+
}).open();
|
|
54635
|
+
return found;
|
|
54636
|
+
}
|
|
54637
|
+
|
|
54638
|
+
/**
|
|
54639
|
+
* Bring a file's epics, releases and stories into this model (an import
|
|
54640
|
+
* format's spec), as one undo step: one with the id of one here replaces it.
|
|
54641
|
+
*/
|
|
54642
|
+
bringFrom(format, options = {}) {
|
|
54643
|
+
return this.readInto({
|
|
54644
|
+
...options, accept: format.accept, what: 'the file',
|
|
54645
|
+
command: (bytes, name) => {
|
|
54646
|
+
const spec = format.spec(new TextDecoder().decode(bytes));
|
|
54647
|
+
if (!spec.stories.length && !spec.epics.length) return null;
|
|
54648
|
+
return this.model.bringCommand(spec, `Bring in "${name}"`);
|
|
54649
|
+
},
|
|
54650
|
+
nothing: (name) => `"${name}" has no epics or stories.`,
|
|
54651
|
+
});
|
|
54652
|
+
}
|
|
54653
|
+
|
|
54654
|
+
/** The findings (RequirementsChecker, traces resolved by the host); one opened edits its story or epic. */
|
|
54655
|
+
checkModel() {
|
|
54656
|
+
const findings = this.model.checker({ resolve: (trace) => this.resolveTrace(trace) }).run();
|
|
54657
|
+
new ModelCheckDialog({ findings, onReveal: (f) => this.revealFinding(f), document: this.document }).open();
|
|
54658
|
+
return findings;
|
|
54659
|
+
}
|
|
54660
|
+
|
|
54661
|
+
/** Open what a finding is about. */
|
|
54662
|
+
revealFinding(finding) {
|
|
54663
|
+
const [id] = finding.ids ?? [];
|
|
54664
|
+
if (finding.kind === 'epic') return this.editEpic(id);
|
|
54665
|
+
if (finding.kind === 'story') return this.editStory(id);
|
|
54666
|
+
return null;
|
|
54667
|
+
}
|
|
54668
|
+
|
|
54669
|
+
// ===== The views, as element specs =====
|
|
54670
|
+
|
|
54671
|
+
/** A story as a card: its name, its sentence, and badges (criteria, traces, points, status). */
|
|
54672
|
+
cardSpec(story) {
|
|
54673
|
+
const live = this.storyStatus(story);
|
|
54674
|
+
const badges = [
|
|
54675
|
+
story.criteria.length ? `✓ ${story.criteria.length}` : '✓ none',
|
|
54676
|
+
story.traces.length ? `→ ${story.traces.length}` : null,
|
|
54677
|
+
story.points != null ? `${story.points} pt` : null,
|
|
54678
|
+
story.status || null,
|
|
54679
|
+
].filter(Boolean);
|
|
54680
|
+
return {
|
|
54681
|
+
cls: 'rq-card', dataset: { storyId: story.id }, attrs: { draggable: 'true', title: RequirementsModel.narrative(story) || story.description || story.name },
|
|
54682
|
+
style: { ...CARD, ...(story.criteria.length ? {} : { borderLeftColor: '#e08a3c' }) },
|
|
54683
|
+
on: {
|
|
54684
|
+
click: () => this.editStory(story.id),
|
|
54685
|
+
contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(story.id), this.document); },
|
|
54686
|
+
dragstart: (e) => { e.dataTransfer?.setData('text/plain', story.id); },
|
|
54687
|
+
dragover: (e) => e.preventDefault(),
|
|
54688
|
+
drop: (e) => { e.preventDefault(); e.stopPropagation(); this.dropStory(e.dataTransfer?.getData('text/plain'), { before: story.id, epic: story.epic, release: story.release }); },
|
|
54689
|
+
},
|
|
54690
|
+
children: [
|
|
54691
|
+
{ text: story.name, style: { fontWeight: '600' } },
|
|
54692
|
+
...(story.want ? [{ text: RequirementsModel.narrative(story), style: { color: 'var(--ws-muted, #666)', margin: '2px 0' } }] : []),
|
|
54693
|
+
{
|
|
54694
|
+
style: { marginTop: '4px' },
|
|
54695
|
+
children: [
|
|
54696
|
+
...badges.map(text => ({ tag: 'span', text, style: BADGE })),
|
|
54697
|
+
...(live ? [{ tag: 'span', text: live.label, attrs: { title: live.title ?? '' }, style: { ...BADGE, color: '#fff', background: live.color ?? '#557', borderRadius: '8px', padding: '0 6px' } }] : []),
|
|
54698
|
+
],
|
|
54699
|
+
},
|
|
54700
|
+
],
|
|
54701
|
+
};
|
|
54702
|
+
}
|
|
54703
|
+
|
|
54704
|
+
/** The story map: a table, epics across, releases down; a cell takes a card dropped on it, and + adds one there. */
|
|
54705
|
+
storyMapSpec() {
|
|
54706
|
+
const map = this.model.storyMap();
|
|
54707
|
+
const drop = (epic, release) => ({
|
|
54708
|
+
dragover: (e) => e.preventDefault(),
|
|
54709
|
+
drop: (e) => { e.preventDefault(); this.dropStory(e.dataTransfer?.getData('text/plain'), { epic, release }); },
|
|
54710
|
+
});
|
|
54711
|
+
return {
|
|
54712
|
+
tag: 'table', cls: 'rq-map', style: { borderCollapse: 'collapse', color: 'var(--ws-fg, #222)' },
|
|
54713
|
+
children: [
|
|
54714
|
+
{
|
|
54715
|
+
tag: 'thead', children: [{
|
|
54716
|
+
tag: 'tr', children: [
|
|
54717
|
+
{ tag: 'th', style: HEAD },
|
|
54718
|
+
...map.columns.map(c => ({
|
|
54719
|
+
tag: 'th', text: c.name, dataset: { epicId: c.id }, style: { ...HEAD, background: 'var(--ws-panel-bg, #fff)', borderBottom: '3px solid #6b8fd6' },
|
|
54720
|
+
attrs: { title: c.epic?.description ?? '' },
|
|
54721
|
+
on: c.epic ? { click: () => this.editEpic(c.id) } : {},
|
|
54722
|
+
})),
|
|
54723
|
+
],
|
|
54724
|
+
}],
|
|
54725
|
+
},
|
|
54726
|
+
{
|
|
54727
|
+
tag: 'tbody', children: map.rows.map(r => ({
|
|
54728
|
+
tag: 'tr', dataset: { releaseId: r.id }, children: [
|
|
54729
|
+
{ tag: 'th', text: r.name, style: { ...HEAD, color: 'var(--ws-muted, #666)' }, on: r.release ? { click: () => this.editRelease(r.id) } : {} },
|
|
54730
|
+
...map.columns.map(c => ({
|
|
54731
|
+
tag: 'td', cls: 'rq-cell', dataset: { epicId: c.id, releaseId: r.id }, style: CELL, on: drop(c.id, r.id),
|
|
54732
|
+
children: [
|
|
54733
|
+
...map.cell(c.id, r.id).map(s => this.cardSpec(s)),
|
|
54734
|
+
{ 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 }) } },
|
|
54735
|
+
],
|
|
54736
|
+
})),
|
|
54737
|
+
],
|
|
54738
|
+
})),
|
|
54739
|
+
},
|
|
54740
|
+
],
|
|
54741
|
+
};
|
|
54742
|
+
}
|
|
54743
|
+
|
|
54744
|
+
/** The backlog: every story in order, a row each. */
|
|
54745
|
+
backlogSpec() {
|
|
54746
|
+
const epicName = (id) => this.model.epic(id)?.name ?? '';
|
|
54747
|
+
const releaseName = (id) => this.model.release(id)?.name ?? '';
|
|
54748
|
+
const columns = ['Story', 'Epic', 'Release', 'Status', 'Priority', 'Points', 'Criteria', 'Traces'];
|
|
54749
|
+
return {
|
|
54750
|
+
tag: 'table', cls: 'rq-backlog', style: { borderCollapse: 'collapse', width: '100%', fontSize: '12px', color: 'var(--ws-fg, #222)' },
|
|
54751
|
+
children: [
|
|
54752
|
+
{ tag: 'thead', children: [{ tag: 'tr', children: columns.map(c => ({ tag: 'th', text: c, style: { ...HEAD, borderBottom: '1px solid var(--ws-border, #ccc)' } })) }] },
|
|
54753
|
+
{
|
|
54754
|
+
tag: 'tbody', children: this.model.stories.map(s => ({
|
|
54755
|
+
tag: 'tr', dataset: { storyId: s.id }, style: { cursor: 'pointer' },
|
|
54756
|
+
on: { click: () => this.editStory(s.id), contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(s.id), this.document); } },
|
|
54757
|
+
children: [s.name, epicName(s.epic), releaseName(s.release), s.status, s.priority, s.points ?? '', s.criteria.length, s.traces.length]
|
|
54758
|
+
.map(text => ({ tag: 'td', text, style: { padding: '4px 8px', borderBottom: '1px solid var(--ws-border, #eee)' } })),
|
|
54759
|
+
})),
|
|
54760
|
+
},
|
|
54761
|
+
],
|
|
54762
|
+
};
|
|
54763
|
+
}
|
|
54764
|
+
|
|
54765
|
+
/** A card dropped: the story moved there (before the card it fell on). */
|
|
54766
|
+
dropStory(id, { epic, release, before = null }) {
|
|
54767
|
+
if (!id || !this.model.story(id) || id === before) return null;
|
|
54768
|
+
return this.runModelEdit(this.model.moveStoryCommand(id, { epic, release, before }));
|
|
54769
|
+
}
|
|
54770
|
+
|
|
54771
|
+
/** A story's right-click menu. */
|
|
54772
|
+
storyMenuItems(id) {
|
|
54773
|
+
return [
|
|
54774
|
+
{ label: 'Edit story…', action: () => this.editStory(id) },
|
|
54775
|
+
{ label: 'Trace to…', action: () => this.editTraces(id) },
|
|
54776
|
+
{ separator: true },
|
|
54777
|
+
{ label: 'Remove story', action: () => this.runModelEdit(this.model.removeStoryCommand(id)) },
|
|
54778
|
+
];
|
|
54779
|
+
}
|
|
54780
|
+
|
|
54781
|
+
// ===== Dialogs =====
|
|
54782
|
+
|
|
54783
|
+
/** The fields a story is edited in (one definition: the dialog shows them, saveStory reads them). */
|
|
54784
|
+
storyFields(story = {}) {
|
|
54785
|
+
const choices = (list, none) => [['', none], ...list.map(x => [x.id, x.name])];
|
|
54786
|
+
const statuses = [...new Set([...RequirementsModel.STATUSES, ...(story.status ? [story.status] : [])])];
|
|
54787
|
+
return [
|
|
54788
|
+
{ key: 'name', label: 'Story', value: story.name ?? '' },
|
|
54789
|
+
{ key: 'epic', label: 'Epic', type: 'select', value: story.epic ?? '', choices: choices(this.model.epics, 'No epic') },
|
|
54790
|
+
{ key: 'release', label: 'Release', type: 'select', value: story.release ?? '', choices: choices(this.model.releases, 'Not planned') },
|
|
54791
|
+
{ type: 'heading', label: 'As a … I want … so that …' },
|
|
54792
|
+
{ key: 'as', label: 'As a', value: story.as ?? '' },
|
|
54793
|
+
{ key: 'want', label: 'I want', value: story.want ?? '' },
|
|
54794
|
+
{ key: 'soThat', label: 'So that', value: story.soThat ?? '' },
|
|
54795
|
+
{ key: 'description', label: 'Description', type: 'textarea', rows: 3, value: story.description ?? '' },
|
|
54796
|
+
{ key: 'criteria', label: 'Acceptance criteria (one a line)', type: 'textarea', rows: 4, value: (story.criteria ?? []).map(c => c.text).join('\n') },
|
|
54797
|
+
{ key: 'status', label: 'Status', type: 'select', value: story.status ?? '', choices: [['', '—'], ...statuses.map(s => [s, s])] },
|
|
54798
|
+
{ key: 'priority', label: 'Priority', value: story.priority ?? '' },
|
|
54799
|
+
{ key: 'points', label: 'Points', type: 'number', value: story.points ?? '' },
|
|
54800
|
+
{ key: 'labels', label: 'Labels (comma between)', value: (story.labels ?? []).join(', ') },
|
|
54801
|
+
];
|
|
54802
|
+
}
|
|
54803
|
+
|
|
54804
|
+
/**
|
|
54805
|
+
* A story's values from its dialog, as RequirementsModel takes them; a
|
|
54806
|
+
* criterion whose text is unchanged keeps its id.
|
|
54807
|
+
*/
|
|
54808
|
+
storyFromValues(values, story = null) {
|
|
54809
|
+
const old = new Map((story?.criteria ?? []).map(c => [c.text, c.id]));
|
|
54810
|
+
return {
|
|
54811
|
+
name: String(values.name ?? '').trim(), epic: values.epic ?? '', release: values.release ?? '',
|
|
54812
|
+
as: values.as ?? '', want: values.want ?? '', soThat: values.soThat ?? '', description: values.description ?? '',
|
|
54813
|
+
criteria: String(values.criteria ?? '').split('\n').map(t => t.trim()).filter(Boolean).map(text => (old.has(text) ? { id: old.get(text), text } : text)),
|
|
54814
|
+
status: values.status ?? '', priority: values.priority ?? '',
|
|
54815
|
+
points: values.points === '' || values.points == null || Number.isNaN(Number(values.points)) ? null : Number(values.points),
|
|
54816
|
+
labels: String(values.labels ?? '').split(',').map(l => l.trim()).filter(Boolean),
|
|
54817
|
+
};
|
|
54818
|
+
}
|
|
54819
|
+
|
|
54820
|
+
/** Edit a story (`id` null: a new one, where `at` says). */
|
|
54821
|
+
editStory(id, at = {}) {
|
|
54822
|
+
const story = id ? this.model.story(id) : null;
|
|
54823
|
+
if (id && !story) return null;
|
|
54824
|
+
return new FormDialog({
|
|
54825
|
+
title: story ? `Story: ${story.name}` : 'New story', document: this.document, submitLabel: story ? 'Save' : 'Add',
|
|
54826
|
+
fields: this.storyFields(story ?? { epic: at.epic ?? '', release: at.release ?? '' }),
|
|
54827
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'A story needs a name'),
|
|
54828
|
+
onApply: (values) => this.runModelEdit(this.model.saveStoryCommand(id, this.storyFromValues(values, story))),
|
|
54829
|
+
}).open();
|
|
54830
|
+
}
|
|
54831
|
+
|
|
54832
|
+
/** Edit a story's traces: each a row (kind, what, its name), what the host offers suggested. */
|
|
54833
|
+
editTraces(id) {
|
|
54834
|
+
const story = this.model.story(id);
|
|
54835
|
+
if (!story) return null;
|
|
54836
|
+
const targets = this.traceTargets();
|
|
54837
|
+
return new FormDialog({
|
|
54838
|
+
title: `Traces: ${story.name}`, document: this.document,
|
|
54839
|
+
description: 'What realises this story: an ArchiMate requirement or work package, an element, a data concept, entity or table.',
|
|
54840
|
+
fields: [{
|
|
54841
|
+
key: 'traces', label: '', type: 'rows', addLabel: 'Add trace', value: story.traces,
|
|
54842
|
+
columns: [
|
|
54843
|
+
{ key: 'kind', label: 'Kind', type: 'select', choices: RequirementsModel.TRACE_KINDS },
|
|
54844
|
+
{ key: 'ref', label: 'What (its id)', suggestions: targets.map(t => t.ref) },
|
|
54845
|
+
{ key: 'name', label: 'Name', suggestions: targets.map(t => t.name).filter(Boolean) },
|
|
54846
|
+
],
|
|
54847
|
+
}],
|
|
54848
|
+
onApply: ({ traces = [] }) => {
|
|
54849
|
+
// A ref the host knows names itself, and says which model it is in.
|
|
54850
|
+
const known = new Map(targets.map(t => [t.ref, t]));
|
|
54851
|
+
const rows = traces.filter(t => t.ref).map(t => ({ ...known.get(t.ref), ...t, name: t.name || known.get(t.ref)?.name || '' }));
|
|
54852
|
+
return this.runModelEdit(this.model.saveStoryCommand(id, { traces: rows }));
|
|
54853
|
+
},
|
|
54854
|
+
}).open();
|
|
54855
|
+
}
|
|
54856
|
+
|
|
54857
|
+
editEpic(id) {
|
|
54858
|
+
const epic = id ? this.model.epic(id) : null;
|
|
54859
|
+
if (id && !epic) return null;
|
|
54860
|
+
return new FormDialog({
|
|
54861
|
+
title: epic ? `Epic: ${epic.name}` : 'New epic', document: this.document, submitLabel: epic ? 'Save' : 'Add',
|
|
54862
|
+
fields: [
|
|
54863
|
+
{ key: 'name', label: 'Epic', value: epic?.name ?? '' },
|
|
54864
|
+
{ key: 'description', label: 'Outcome', type: 'textarea', rows: 3, value: epic?.description ?? '' },
|
|
54865
|
+
],
|
|
54866
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'An epic needs a name'),
|
|
54867
|
+
onApply: (v) => this.runModelEdit(this.model.saveEpicCommand(id, { name: v.name.trim(), description: v.description ?? '' })),
|
|
54868
|
+
}).open();
|
|
54869
|
+
}
|
|
54870
|
+
|
|
54871
|
+
editRelease(id) {
|
|
54872
|
+
const release = id ? this.model.release(id) : null;
|
|
54873
|
+
if (id && !release) return null;
|
|
54874
|
+
return new FormDialog({
|
|
54875
|
+
title: release ? `Release: ${release.name}` : 'New release', document: this.document, submitLabel: release ? 'Save' : 'Add',
|
|
54876
|
+
fields: [{ key: 'name', label: 'Release', value: release?.name ?? '' }],
|
|
54877
|
+
validate: (v) => (String(v.name ?? '').trim() ? null : 'A release needs a name'),
|
|
54878
|
+
onApply: (v) => this.runModelEdit(this.model.saveReleaseCommand(id, { name: v.name.trim() })),
|
|
54879
|
+
}).open();
|
|
54880
|
+
}
|
|
54881
|
+
|
|
54882
|
+
dispose() {
|
|
54883
|
+
this.menuBar?.dispose?.();
|
|
54884
|
+
super.dispose();
|
|
54885
|
+
}
|
|
54886
|
+
|
|
54887
|
+
/** The spec again: the model, and the view shown. */
|
|
54888
|
+
serialize() {
|
|
54889
|
+
return { ...super.serialize(), ...this.model.toJSON(), view: this.view };
|
|
54890
|
+
}
|
|
53164
54891
|
}
|
|
53165
54892
|
|
|
53166
54893
|
const SVG_NS$1 = 'http://www.w3.org/2000/svg';
|
|
@@ -53872,6 +55599,7 @@ th { color: #555; font-weight: 500; }
|
|
|
53872
55599
|
customViewpoints,
|
|
53873
55600
|
// Canvas templates: placed on a canvas, saved from its shapes (the browser's library).
|
|
53874
55601
|
templateMenu: this.templateMenu,
|
|
55602
|
+
...this.editorHooks(),
|
|
53875
55603
|
// Version and distinct shapes (⚙ Settings) change the palette too.
|
|
53876
55604
|
onNodeOptionsChange: ({ archimateVersion, useDistinctShapes, viewpoint, viewpointMode, showDerived, showNestedLines }) => {
|
|
53877
55605
|
this.showDerived = !!showDerived;
|
|
@@ -53938,11 +55666,40 @@ th { color: #555; font-weight: 500; }
|
|
|
53938
55666
|
this.editor.render();
|
|
53939
55667
|
}
|
|
53940
55668
|
|
|
55669
|
+
/**
|
|
55670
|
+
* A host's live status for an element — `{ text, color }`, drawn as a pill
|
|
55671
|
+
* on its boxes — or null. None here: a host subclass says (the workbench:
|
|
55672
|
+
* a component's lifecycle state). Asked whenever the view is drawn.
|
|
55673
|
+
*/
|
|
55674
|
+
elementStatus(_element) {
|
|
55675
|
+
return null;
|
|
55676
|
+
}
|
|
55677
|
+
|
|
55678
|
+
/** A host's menu items for an element, after the editor's own on a box and in the tree. None here. */
|
|
55679
|
+
elementMenuExtras(_element) {
|
|
55680
|
+
return [];
|
|
55681
|
+
}
|
|
55682
|
+
|
|
55683
|
+
/** What the editor asks this part per element: its live status and its menu items (a host's). */
|
|
55684
|
+
editorHooks() {
|
|
55685
|
+
return {
|
|
55686
|
+
elementStatus: (element) => this.elementStatus(element),
|
|
55687
|
+
elementMenuExtras: (element) => this.elementMenuExtras(element),
|
|
55688
|
+
};
|
|
55689
|
+
}
|
|
55690
|
+
|
|
53941
55691
|
/**
|
|
53942
55692
|
* A model element's menu in the tree (elementMenuItems): its properties,
|
|
53943
|
-
* the views it is on, adding it to this one, a view generated for it
|
|
55693
|
+
* the views it is on, adding it to this one, a view generated for it; then
|
|
55694
|
+
* the host's (elementMenuExtras).
|
|
53944
55695
|
*/
|
|
53945
55696
|
elementMenu(element) {
|
|
55697
|
+
const extras = this.elementMenuExtras(element) ?? [];
|
|
55698
|
+
return [...this._ownElementMenu(element), ...(extras.length ? [{ separator: true }, ...extras] : [])];
|
|
55699
|
+
}
|
|
55700
|
+
|
|
55701
|
+
/** @private */
|
|
55702
|
+
_ownElementMenu(element) {
|
|
53946
55703
|
const model = this.model;
|
|
53947
55704
|
return elementMenuItems(element, {
|
|
53948
55705
|
model, view: model.currentView,
|
|
@@ -54048,6 +55805,7 @@ th { color: #555; font-weight: 500; }
|
|
|
54048
55805
|
exports.BUILTIN_THEMES = BUILTIN_THEMES;
|
|
54049
55806
|
exports.BackgroundCommand = BackgroundCommand;
|
|
54050
55807
|
exports.BackgroundGroup = BackgroundGroup;
|
|
55808
|
+
exports.Benchmark = Benchmark;
|
|
54051
55809
|
exports.BianActionTerm = BianActionTerm;
|
|
54052
55810
|
exports.BianFunctionalPattern = BianFunctionalPattern;
|
|
54053
55811
|
exports.BianLandscapeReader = BianLandscapeReader;
|
|
@@ -54134,6 +55892,7 @@ th { color: #555; font-weight: 500; }
|
|
|
54134
55892
|
exports.EditHintCommand = EditHintCommand;
|
|
54135
55893
|
exports.EditPropertyCommand = EditPropertyCommand;
|
|
54136
55894
|
exports.EditViewItemCommand = EditViewItemCommand;
|
|
55895
|
+
exports.EditableModel = EditableModel;
|
|
54137
55896
|
exports.EditorPart = EditorPart;
|
|
54138
55897
|
exports.EditorShell = EditorShell;
|
|
54139
55898
|
exports.ElementFilterRule = ElementFilterRule;
|
|
@@ -54189,7 +55948,9 @@ th { color: #555; font-weight: 500; }
|
|
|
54189
55948
|
exports.Mat4 = Mat4;
|
|
54190
55949
|
exports.MeasuredCategory = MeasuredCategory;
|
|
54191
55950
|
exports.MenuBar = MenuBar;
|
|
55951
|
+
exports.ModelEditCommand = ModelEditCommand;
|
|
54192
55952
|
exports.ModelFileFormat = ModelFileFormat;
|
|
55953
|
+
exports.ModelHosted = ModelHosted;
|
|
54193
55954
|
exports.ModelScorecard = ModelScorecard;
|
|
54194
55955
|
exports.ModelTree = ModelTree;
|
|
54195
55956
|
exports.ModelsPart = ModelsPart;
|
|
@@ -54229,6 +55990,10 @@ th { color: #555; font-weight: 500; }
|
|
|
54229
55990
|
exports.RemoveLineCommand = RemoveLineCommand;
|
|
54230
55991
|
exports.RenderEngine = RenderEngine;
|
|
54231
55992
|
exports.ReorderCommand = ReorderCommand;
|
|
55993
|
+
exports.RequirementsChecker = RequirementsChecker;
|
|
55994
|
+
exports.RequirementsFileFormat = RequirementsFileFormat;
|
|
55995
|
+
exports.RequirementsModel = RequirementsModel;
|
|
55996
|
+
exports.RequirementsPart = RequirementsPart;
|
|
54232
55997
|
exports.ResizeCommand = ResizeCommand;
|
|
54233
55998
|
exports.RotateCommand = RotateCommand;
|
|
54234
55999
|
exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
|
|
@@ -54248,9 +56013,12 @@ th { color: #555; font-weight: 500; }
|
|
|
54248
56013
|
exports.SqlDdlFileFormat = SqlDdlFileFormat;
|
|
54249
56014
|
exports.StadiumTemplate = StadiumTemplate;
|
|
54250
56015
|
exports.StarTemplate = StarTemplate;
|
|
56016
|
+
exports.Storyboard = Storyboard;
|
|
56017
|
+
exports.StoryboardFileFormat = StoryboardFileFormat;
|
|
54251
56018
|
exports.StructuredType = StructuredType;
|
|
54252
56019
|
exports.SwimlaneTemplate = SwimlaneTemplate;
|
|
54253
56020
|
exports.THEME_VARS = THEME_VARS;
|
|
56021
|
+
exports.TRACKERS = TRACKERS;
|
|
54254
56022
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
54255
56023
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
54256
56024
|
exports.TabsNode = TabsNode;
|
|
@@ -54265,6 +56033,8 @@ th { color: #555; font-weight: 500; }
|
|
|
54265
56033
|
exports.ToolbarSelect = ToolbarSelect;
|
|
54266
56034
|
exports.ToolbarSeparator = ToolbarSeparator;
|
|
54267
56035
|
exports.ToolbarText = ToolbarText;
|
|
56036
|
+
exports.TrackerExport = TrackerExport;
|
|
56037
|
+
exports.TrackerExportFileFormat = TrackerExportFileFormat;
|
|
54268
56038
|
exports.TrapezoidTemplate = TrapezoidTemplate;
|
|
54269
56039
|
exports.TreeModeRegistry = TreeModeRegistry;
|
|
54270
56040
|
exports.TriangleTemplate = TriangleTemplate;
|