@webpieces/nx-webpieces-rules 0.4.884 → 0.4.886

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +41 -8
  2. package/package.json +7 -7
  3. package/src/executors/visualize-runtime/executor.js +13 -1
  4. package/src/executors/visualize-runtime/executor.js.map +1 -1
  5. package/src/lib/dot-syntax.js +26 -0
  6. package/src/lib/dot-syntax.js.map +1 -1
  7. package/src/lib/graph-color-modes.d.ts +2 -1
  8. package/src/lib/graph-color-modes.js +5 -2
  9. package/src/lib/graph-color-modes.js.map +1 -1
  10. package/src/lib/graph-impact.js +1 -0
  11. package/src/lib/graph-impact.js.map +1 -1
  12. package/src/lib/graph-legend.d.ts +1 -1
  13. package/src/lib/graph-legend.js.map +1 -1
  14. package/src/lib/graph-mode-state.client.d.ts +32 -0
  15. package/src/lib/graph-mode-state.client.js +141 -0
  16. package/src/lib/graph-mode-state.client.js.map +1 -0
  17. package/src/lib/graph-page-shell.d.ts +5 -1
  18. package/src/lib/graph-page-shell.js +31 -12
  19. package/src/lib/graph-page-shell.js.map +1 -1
  20. package/src/lib/graph-render-model.d.ts +7 -5
  21. package/src/lib/graph-render-model.js +11 -8
  22. package/src/lib/graph-render-model.js.map +1 -1
  23. package/src/lib/graph-visualizer.client.d.ts +11 -46
  24. package/src/lib/graph-visualizer.client.js +103 -160
  25. package/src/lib/graph-visualizer.client.js.map +1 -1
  26. package/src/lib/graph-visualizer.js +2 -1
  27. package/src/lib/graph-visualizer.js.map +1 -1
  28. package/src/lib/runtime-details.d.ts +4 -1
  29. package/src/lib/runtime-details.js +102 -55
  30. package/src/lib/runtime-details.js.map +1 -1
  31. package/src/lib/runtime-html-page.d.ts +13 -40
  32. package/src/lib/runtime-html-page.js +96 -129
  33. package/src/lib/runtime-html-page.js.map +1 -1
  34. package/src/lib/runtime-viewer-adapter.d.ts +16 -0
  35. package/src/lib/runtime-viewer-adapter.js +58 -0
  36. package/src/lib/runtime-viewer-adapter.js.map +1 -0
  37. package/src/lib/runtime-visualizer.client.d.ts +9 -19
  38. package/src/lib/runtime-visualizer.client.js +75 -75
  39. package/src/lib/runtime-visualizer.client.js.map +1 -1
  40. package/src/lib/runtime-visualizer.d.ts +2 -16
  41. package/src/lib/runtime-visualizer.js +22 -44
  42. package/src/lib/runtime-visualizer.js.map +1 -1
  43. package/src/lib/runtime-viz-options.d.ts +19 -0
  44. package/src/lib/runtime-viz-options.js +22 -0
  45. package/src/lib/runtime-viz-options.js.map +1 -0
@@ -1,150 +1,4 @@
1
1
  "use strict";
2
- /**
3
- * The four color modes (graph-color-modes.ts), the viewer's remembered choice, and Impact's data.
4
- *
5
- * The mode is deep-linkable (`#runtime`, `#architecture`, `#impact`, `#product`, which win over the
6
- * remembered one) and remembered per viewer in localStorage. Product is unavailable on a page whose
7
- * workspace declares no `product:` tag. Storage can be missing or throw (a private window,
8
- * blocked site data, a file:// page in some browsers), so every access is wrapped and the page works
9
- * without it.
10
- *
11
- * Impact can carry two scans (#1163): "Changed on this branch" (kind `branch`) and "Last commit"
12
- * (kind `commit`). The ACTIVE one starts as the sidecar's `defaultKind` (or the viewer's remembered
13
- * choice, when that scan exists and is available) and is what every Impact color, note and Filter
14
- * set reads.
15
- */
16
- class WpModeState {
17
- hasProducts;
18
- static MODES = ['runtime', 'architecture', 'impact', 'product'];
19
- static NAMES = ['Runtime', 'Architecture', 'Impact', 'Product'];
20
- static SUBTITLES = [
21
- 'where the code can run',
22
- 'servers · clients · APIs',
23
- 'what changed',
24
- 'which products it belongs to',
25
- ];
26
- static STORAGE_KEY = 'wp-architecture-graph-mode';
27
- static KIND_STORAGE_KEY = 'wp-architecture-graph-impact-kind';
28
- scans = window.__WP_IMPACT__?.scans ?? [];
29
- kind = '';
30
- mode = 'runtime';
31
- /** @param hasProducts some box belongs to a product, so the Product mode has something to show */
32
- constructor(hasProducts) {
33
- this.hasProducts = hasProducts;
34
- this.kind = this.initialKind();
35
- this.mode = this.initial();
36
- }
37
- /** '' when Product can be shown, else why not. */
38
- productReason() {
39
- return this.hasProducts
40
- ? ''
41
- : 'No products here: tag servers, clients and apps product:<name> in their project.json, then regenerate.';
42
- }
43
- /** The active scan; null when the page has no Impact data. */
44
- get impact() {
45
- return this.scans.find((scan) => scan.kind === this.kind) ?? null;
46
- }
47
- /** '' when Impact can be shown, else the one line the drawer and the Filter popover say instead. */
48
- impactReason() {
49
- const impact = this.impact;
50
- if (impact === null)
51
- return 'No impact data here. Run pnpm arch:visualize (nx run architecture:visualize) to compute it — no full regenerate needed.';
52
- return impact.available ? '' : `Impact unavailable (${impact.label}): ${impact.reason}.`;
53
- }
54
- /** Switch the active scan; false (and nothing changes) for a missing or unavailable one. */
55
- setKind(kind) {
56
- if (!this.kindAvailable(kind))
57
- return false;
58
- this.kind = kind;
59
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- storage is a per-viewer convenience; a blocked store must not break the page
60
- try {
61
- localStorage.setItem(WpModeState.KIND_STORAGE_KEY, kind);
62
- // webpieces-disable no-any-unknown -- browsers may throw any value from blocked storage
63
- }
64
- catch (err) {
65
- //const error = toError(err);
66
- void err;
67
- }
68
- return true;
69
- }
70
- kindAvailable(kind) {
71
- return this.scans.some((scan) => scan.kind === kind && scan.available);
72
- }
73
- initialKind() {
74
- const stored = this.read(WpModeState.KIND_STORAGE_KEY);
75
- if (stored !== null && this.kindAvailable(stored))
76
- return stored;
77
- const preferred = window.__WP_IMPACT__?.defaultKind ?? '';
78
- if (this.scans.some((scan) => scan.kind === preferred))
79
- return preferred;
80
- return this.scans[0]?.kind ?? '';
81
- }
82
- available(mode) {
83
- if (!WpModeState.MODES.includes(mode))
84
- return false;
85
- if (mode === 'impact')
86
- return this.impactReason() === '';
87
- if (mode === 'product')
88
- return this.productReason() === '';
89
- return true;
90
- }
91
- name(mode) {
92
- return WpModeState.NAMES[WpModeState.MODES.indexOf(mode)] ?? mode;
93
- }
94
- subtitle(mode) {
95
- return WpModeState.SUBTITLES[WpModeState.MODES.indexOf(mode)] ?? '';
96
- }
97
- /** False (and nothing changes) for an unknown or unavailable mode. */
98
- set(mode) {
99
- if (!this.available(mode))
100
- return false;
101
- this.mode = mode;
102
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- storage is a per-viewer convenience; a blocked store must not break the page
103
- try {
104
- localStorage.setItem(WpModeState.STORAGE_KEY, mode);
105
- if (location.hash !== `#${mode}`)
106
- history.replaceState(null, '', `#${mode}`);
107
- // webpieces-disable no-any-unknown -- browsers may throw any value from blocked storage
108
- }
109
- catch (err) {
110
- //const error = toError(err);
111
- void err;
112
- }
113
- return true;
114
- }
115
- /** Which impact shade a box gets, from the ACTIVE scan. */
116
- status(id) {
117
- const impact = this.impact;
118
- if (impact?.touched.includes(id))
119
- return 'touched';
120
- if (impact?.affected.includes(id))
121
- return 'affected';
122
- if (impact?.buildInputs.includes(id))
123
- return 'buildInput';
124
- return 'untouched';
125
- }
126
- initial() {
127
- const hashed = location.hash.slice(1);
128
- if (this.available(hashed))
129
- return hashed;
130
- const stored = this.read(WpModeState.STORAGE_KEY);
131
- if (stored !== null && this.available(stored))
132
- return stored;
133
- return 'runtime';
134
- }
135
- read(key) {
136
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- storage is a per-viewer convenience; a blocked store reads as "nothing stored"
137
- try {
138
- return localStorage.getItem(key);
139
- // webpieces-disable no-any-unknown -- browsers may throw any value from blocked storage
140
- }
141
- catch (err) {
142
- //const error = toError(err);
143
- void err;
144
- return null;
145
- }
146
- }
147
- }
148
2
  /**
149
3
  * One state of the Filter popover: a change scope plus the selected Runtime, Role and Product chips.
150
4
  * The page holds the APPLIED one; the popover edits a copy until "Show N projects".
@@ -207,14 +61,14 @@ class WpProjectFilter {
207
61
  /** How many drawn projects a scope keeps on its own: the count beside each option. */
208
62
  scopeCount(scope) {
209
63
  const set = this.scopeSet(scope);
210
- return this.model.nodes.filter((node) => set === null || set.has(node.id)).length;
64
+ return this.model.nodes.filter((node) => this.isService(node) && (set === null || set.has(node.id))).length;
211
65
  }
212
66
  /** `base` (everything, or the Hide-unconnected chain) narrowed by `choice`. */
213
67
  apply(base, choice) {
214
68
  const scope = this.scopeSet(choice.scope);
215
69
  const kept = new Set();
216
70
  for (const node of this.model.nodes) {
217
- if (!base.has(node.id))
71
+ if (!base.has(node.id) || !this.isService(node))
218
72
  continue;
219
73
  if (scope !== null && !scope.has(node.id))
220
74
  continue;
@@ -226,8 +80,17 @@ class WpProjectFilter {
226
80
  continue;
227
81
  kept.add(node.id);
228
82
  }
83
+ if (this.model.viewer === 'runtime') {
84
+ for (const node of this.model.nodes) {
85
+ if (node.tags !== null || !base.has(node.id))
86
+ continue;
87
+ if (this.model.edges.some(edge => (edge.from === node.id && kept.has(edge.to)) || (edge.to === node.id && kept.has(edge.from))))
88
+ kept.add(node.id);
89
+ }
90
+ }
229
91
  return kept;
230
92
  }
93
+ isService(node) { return this.model.viewer !== 'runtime' || node.tags !== null; }
231
94
  /** An empty chip group matches every box; otherwise the box must carry one of the chips. */
232
95
  anyOf(selected, carried) {
233
96
  return selected.size === 0 || carried.some((value) => selected.has(value));
@@ -307,14 +170,17 @@ class GraphHighlighter {
307
170
  const designs = new Map();
308
171
  for (const link of __DESIGN_LINKS__)
309
172
  designs.set(link.nodeId, link.href);
310
- WpNodeMenu.wire(this.svg, (name) => {
173
+ WpNodeMenu.wire(this.svg, (name, node) => {
311
174
  const items = [];
312
175
  const href = designs.get(name);
313
176
  if (href !== undefined)
314
177
  items.push(new WpNodeMenuItem('View Design', () => window.open(href, '_blank')));
315
178
  const locked = this.page.lockSelection() === name;
316
- items.push(new WpNodeMenuItem(locked ? 'Unlock' : 'Lock', () => this.page.setLock(locked ? null : name)));
179
+ items.push(new WpNodeMenuItem(locked ? 'Unlock' : 'Lock', () => locked ? this.page.clearSelection() : this.page.setLock(name)));
317
180
  items.push(this.page.filterItem(name));
181
+ const implemented = window.__WP_RUNTIME__?.implementsItem(name, node);
182
+ if (implemented)
183
+ items.push(implemented);
318
184
  return items;
319
185
  });
320
186
  }
@@ -414,7 +280,7 @@ class GraphFilterPopover {
414
280
  const apply = this.byId('wp-filter-apply');
415
281
  if (apply !== null) {
416
282
  const shown = this.page.preview(this.draft);
417
- apply.textContent = `Show ${shown} project${shown === 1 ? '' : 's'}`;
283
+ apply.textContent = `Show ${shown} ${this.page.noun()}${shown === 1 ? '' : 's'}`;
418
284
  }
419
285
  }
420
286
  byId(id) {
@@ -428,6 +294,7 @@ class GraphFilterPopover {
428
294
  class GraphDrawer {
429
295
  page;
430
296
  popover;
297
+ openers = new Map();
431
298
  constructor(page) {
432
299
  this.page = page;
433
300
  this.popover = new GraphFilterPopover(page);
@@ -437,6 +304,7 @@ class GraphDrawer {
437
304
  this.popover.wire();
438
305
  this.wireImpactKinds();
439
306
  this.wireLock();
307
+ this.byId('wp-lock-toggle')?.addEventListener('click', () => this.page.toggleLock());
440
308
  this.byId('wp-filter-toggle')?.addEventListener('click', () => this.page.toggleFilter());
441
309
  this.toggles('wp-collapse', 'wp-shell', true);
442
310
  this.toggles('wp-help-btn', 'wp-help', false);
@@ -453,6 +321,7 @@ class GraphDrawer {
453
321
  if (target instanceof Node && this.byId('wp-mode-menu')?.parentElement?.contains(target))
454
322
  return;
455
323
  this.closeModeMenu(false);
324
+ this.dismissOutside(target);
456
325
  });
457
326
  }
458
327
  /** "Color by": the pill trigger opens the menu of four; picking one switches and closes it. */
@@ -523,7 +392,7 @@ class GraphDrawer {
523
392
  input.addEventListener('input', () => {
524
393
  const value = input.value.trim();
525
394
  if (value === '')
526
- this.page.setLock(null);
395
+ this.page.clearSelection();
527
396
  else if (this.page.hasNode(value))
528
397
  this.page.setLock(value);
529
398
  });
@@ -539,7 +408,7 @@ class GraphDrawer {
539
408
  return;
540
409
  ev.preventDefault();
541
410
  input.value = '';
542
- this.page.setLock(null);
411
+ this.page.clearSelection();
543
412
  input.blur();
544
413
  });
545
414
  }
@@ -559,11 +428,15 @@ class GraphDrawer {
559
428
  this.popover.close();
560
429
  return;
561
430
  }
431
+ if (ev.key === 'Escape' && this.dismissOverlay())
432
+ return;
562
433
  const target = ev.target;
563
434
  if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
564
435
  return;
565
- const menuOpen = document.getElementById('wp-node-menu') !== null;
436
+ const menuOpen = document.getElementById('wp-node-menu') !== null || document.querySelector('.wp-graph-details:not([hidden])') !== null;
566
437
  if (ev.key === '/') {
438
+ this.byId('wp-shell')?.classList.remove('wp-collapsed');
439
+ this.byId('wp-collapse')?.setAttribute('aria-expanded', 'true');
567
440
  ev.preventDefault();
568
441
  this.lockInput()?.focus();
569
442
  return;
@@ -591,10 +464,42 @@ class GraphDrawer {
591
464
  else {
592
465
  target.hidden = !target.hidden;
593
466
  expanded = !target.hidden;
467
+ if (expanded)
468
+ this.openers.set(targetId, button);
469
+ else
470
+ this.openers.get(targetId)?.focus({ preventScroll: true });
594
471
  }
595
472
  document.querySelectorAll(`[aria-controls="${targetId}"], #${buttonId}[aria-expanded]`).forEach((el) => el.setAttribute('aria-expanded', String(expanded)));
596
473
  });
597
474
  }
475
+ dismissOverlay() {
476
+ for (const id of ['wp-help', 'wp-legend-popout', 'wp-snapshot', 'wp-resp-panel']) {
477
+ const panel = this.byId(id);
478
+ if (!panel || panel.hidden)
479
+ continue;
480
+ panel.hidden = true;
481
+ this.openers.get(id)?.focus({ preventScroll: true });
482
+ this.openers.get(id)?.setAttribute('aria-expanded', 'false');
483
+ return true;
484
+ }
485
+ return false;
486
+ }
487
+ dismissOutside(target) {
488
+ if (!(target instanceof Node))
489
+ return;
490
+ const filter = this.byId('wp-filter-pop');
491
+ if (this.popover.isOpen() && !filter?.contains(target) && !this.byId('wp-filter-open')?.contains(target))
492
+ this.popover.close();
493
+ for (const entry of this.openers) {
494
+ const id = entry[0];
495
+ const opener = entry[1];
496
+ const panel = this.byId(id);
497
+ if (panel && !panel.hidden && !panel.contains(target) && !opener.contains(target)) {
498
+ panel.hidden = true;
499
+ opener.setAttribute('aria-expanded', 'false');
500
+ }
501
+ }
502
+ }
598
503
  /** Repaint every control from the page's state. */
599
504
  sync(modes, locked, filtering, filter) {
600
505
  const reason = modes.impactReason();
@@ -626,7 +531,13 @@ class GraphDrawer {
626
531
  title.textContent = `Legend · ${modes.name(modes.mode)}`;
627
532
  const input = this.lockInput();
628
533
  if (input !== null && document.activeElement !== input)
629
- input.value = locked ?? '';
534
+ input.value = this.page.focusSelection() ?? '';
535
+ const lockToggle = this.byId('wp-lock-toggle');
536
+ if (lockToggle !== null) {
537
+ lockToggle.setAttribute('aria-pressed', String(locked !== null));
538
+ lockToggle.disabled = locked === null && this.page.focusSelection() === null;
539
+ lockToggle.title = lockToggle.disabled ? 'Select a node in Focus first' : '';
540
+ }
630
541
  this.syncFilterToggle(locked, filtering);
631
542
  this.syncFilter(filter);
632
543
  this.crumb(modes.name(modes.mode), locked);
@@ -738,7 +649,8 @@ class GraphDrawer {
738
649
  }
739
650
  class GraphPage extends WpFilterPage {
740
651
  highlighter = null;
741
- modes = new WpModeState(this.model.nodes.some((node) => node.products.length > 0));
652
+ selected = null;
653
+ modes = new WpModeState(this.model.nodes.some((node) => node.products.length > 0), this.model.viewer ?? 'architecture');
742
654
  projects = new WpProjectFilter(this.model, this.modes);
743
655
  applied = new WpFilterChoice();
744
656
  drawer = new GraphDrawer(this);
@@ -776,6 +688,7 @@ class GraphPage extends WpFilterPage {
776
688
  keepsEmptyLevels() {
777
689
  return true;
778
690
  }
691
+ prepareSvg(svg) { window.__WP_RUNTIME__?.prepare(svg); }
779
692
  wireSvg(svg) {
780
693
  this.highlighter = new GraphHighlighter(svg, this.chain, this.model, this);
781
694
  this.highlighter.wire();
@@ -792,8 +705,14 @@ class GraphPage extends WpFilterPage {
792
705
  hasNode(id) {
793
706
  return this.model.nodes.some((node) => node.id === id);
794
707
  }
795
- projectCount() {
796
- return this.model.nodes.length;
708
+ noun() { return this.model.viewer === 'runtime' ? 'service' : 'project'; }
709
+ projectCount() { return this.model.nodes.filter(node => this.projects.isService(node)).length; }
710
+ focusSelection() { return this.selected; }
711
+ toggleLock() {
712
+ if (this.locked !== null)
713
+ this.setLock(null);
714
+ else if (this.selected !== null)
715
+ this.setLock(this.selected);
797
716
  }
798
717
  /** An exact id, else the one id whose short name (after any scope) is `text`; null if none or several. */
799
718
  resolveName(text) {
@@ -806,10 +725,13 @@ class GraphPage extends WpFilterPage {
806
725
  }
807
726
  setLock(name) {
808
727
  this.locked = name;
728
+ if (name !== null)
729
+ this.selected = name;
809
730
  this.highlighter?.relight();
810
731
  this.filterCards();
811
732
  this.syncControls();
812
733
  }
734
+ clearSelection() { this.selected = null; this.setLock(null); }
813
735
  setMode(mode) {
814
736
  if (mode === this.modes.mode || !this.modes.set(mode))
815
737
  return;
@@ -838,14 +760,19 @@ class GraphPage extends WpFilterPage {
838
760
  return this.modes.impactReason();
839
761
  }
840
762
  scopeCount(scope) {
841
- return this.projects.scopeCount(scope);
763
+ if (this.model.viewer !== 'runtime')
764
+ return this.projects.scopeCount(scope);
765
+ const choice = new WpFilterChoice();
766
+ choice.scope = scope;
767
+ return this.preview(choice);
842
768
  }
843
769
  /** How many boxes `choice` would show, with the current Hide-unconnected chain. */
844
770
  preview(choice) {
845
771
  const base = this.anchor === null
846
772
  ? new Set(this.model.nodes.map((node) => node.id))
847
773
  : this.chain.nodes(this.anchor);
848
- return this.projects.apply(base, choice).size;
774
+ const kept = this.projects.apply(base, choice);
775
+ return this.model.nodes.filter(node => kept.has(node.id) && this.projects.isService(node)).length;
849
776
  }
850
777
  applyFilter(choice) {
851
778
  if (this.scopeReason() !== '')
@@ -865,6 +792,22 @@ class GraphPage extends WpFilterPage {
865
792
  }
866
793
  syncControls() {
867
794
  this.drawer.sync(this.modes, this.locked, this.anchor !== null, this.applied);
795
+ const count = this.model.nodes.filter(node => this.retained.has(node.id) && this.projects.isService(node)).length;
796
+ const empty = document.getElementById('wp-empty');
797
+ if (empty !== null)
798
+ empty.hidden = count !== 0;
799
+ const context = document.getElementById('wp-context-count');
800
+ if (context !== null)
801
+ context.textContent = `${count} services · ${this.retained.size - count} contextual nodes`;
802
+ this.syncLegend();
803
+ }
804
+ syncLegend() {
805
+ document.querySelectorAll('[data-wp-legend-value]').forEach(row => {
806
+ const group = row.dataset['wpLegendGroup'];
807
+ const value = row.dataset['wpLegendValue'] ?? '';
808
+ row.hidden = !this.model.nodes.some(node => this.retained.has(node.id) &&
809
+ (group === 'runtime' ? (value === 'unknown' ? node.tags !== null && node.tags.frameworks.length === 0 : node.tags?.frameworks.includes(value)) : group === 'role' ? node.tags?.role === value : node.products.includes(value)));
810
+ });
868
811
  }
869
812
  filterCards() {
870
813
  const lit = this.locked === null ? this.retained : this.chain.nodes(this.locked);