@webpieces/nx-webpieces-rules 0.4.873 → 0.4.874
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 +31 -18
- package/package.json +7 -7
- package/src/executors/visualize/executor.d.ts +17 -3
- package/src/executors/visualize/executor.js +53 -33
- package/src/executors/visualize/executor.js.map +1 -1
- package/src/lib/graph-color-modes.d.ts +56 -34
- package/src/lib/graph-color-modes.js +111 -100
- package/src/lib/graph-color-modes.js.map +1 -1
- package/src/lib/graph-filter.client.d.ts +13 -2
- package/src/lib/graph-filter.client.js +27 -7
- package/src/lib/graph-filter.client.js.map +1 -1
- package/src/lib/graph-impact.d.ts +37 -3
- package/src/lib/graph-impact.js +42 -11
- package/src/lib/graph-impact.js.map +1 -1
- package/src/lib/graph-legend.js +1 -1
- package/src/lib/graph-legend.js.map +1 -1
- package/src/lib/graph-level-bands.d.ts +8 -0
- package/src/lib/graph-level-bands.js +13 -1
- package/src/lib/graph-level-bands.js.map +1 -1
- package/src/lib/graph-node-menu.js +10 -108
- package/src/lib/graph-node-menu.js.map +1 -1
- package/src/lib/graph-page-shell.d.ts +31 -1
- package/src/lib/graph-page-shell.js +75 -7
- package/src/lib/graph-page-shell.js.map +1 -1
- package/src/lib/graph-page-styles.d.ts +14 -3
- package/src/lib/graph-page-styles.js +89 -18
- package/src/lib/graph-page-styles.js.map +1 -1
- package/src/lib/graph-render-model.d.ts +14 -6
- package/src/lib/graph-render-model.js +22 -8
- package/src/lib/graph-render-model.js.map +1 -1
- package/src/lib/graph-visualizer.client.d.ts +82 -5
- package/src/lib/graph-visualizer.client.js +351 -23
- package/src/lib/graph-visualizer.client.js.map +1 -1
- package/src/lib/graph-visualizer.js +1 -1
- package/src/lib/graph-visualizer.js.map +1 -1
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
class WpModeState {
|
|
11
11
|
static MODES = ['runtime', 'architecture', 'impact'];
|
|
12
12
|
static NAMES = ['Runtime', 'Architecture', 'Impact'];
|
|
13
|
+
static SUBTITLES = ['where the code can run', 'servers · clients · APIs', 'what this branch touches'];
|
|
13
14
|
static STORAGE_KEY = 'wp-architecture-graph-mode';
|
|
14
15
|
impact = window.__WP_IMPACT__ ?? null;
|
|
15
16
|
mode = 'runtime';
|
|
16
17
|
constructor() {
|
|
17
18
|
this.mode = this.initial();
|
|
18
19
|
}
|
|
19
|
-
/** '' when Impact can be shown, else the one line the drawer and the
|
|
20
|
+
/** '' when Impact can be shown, else the one line the drawer and the Filter popover say instead. */
|
|
20
21
|
impactReason() {
|
|
21
22
|
if (this.impact === null)
|
|
22
|
-
return 'No impact data for this branch. Run pnpm nx run architecture:
|
|
23
|
+
return 'No impact data for this branch. Run pnpm arch:visualize (nx run architecture:visualize) to compute it — no full regenerate needed.';
|
|
23
24
|
return this.impact.available ? '' : `Impact unavailable: ${this.impact.reason}.`;
|
|
24
25
|
}
|
|
25
26
|
available(mode) {
|
|
@@ -28,6 +29,9 @@ class WpModeState {
|
|
|
28
29
|
name(mode) {
|
|
29
30
|
return WpModeState.NAMES[WpModeState.MODES.indexOf(mode)] ?? mode;
|
|
30
31
|
}
|
|
32
|
+
subtitle(mode) {
|
|
33
|
+
return WpModeState.SUBTITLES[WpModeState.MODES.indexOf(mode)] ?? '';
|
|
34
|
+
}
|
|
31
35
|
/** False (and nothing changes) for an unknown or unavailable mode. */
|
|
32
36
|
set(mode) {
|
|
33
37
|
if (!this.available(mode))
|
|
@@ -78,6 +82,87 @@ class WpModeState {
|
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* One state of the Filter popover: a change scope plus the selected Runtime and Role chips. The page
|
|
87
|
+
* holds the APPLIED one; the popover edits a copy until "Show N projects".
|
|
88
|
+
*/
|
|
89
|
+
class WpFilterChoice {
|
|
90
|
+
scope = 'everything';
|
|
91
|
+
runtimes = new Set();
|
|
92
|
+
roles = new Set();
|
|
93
|
+
copy() {
|
|
94
|
+
const copy = new WpFilterChoice();
|
|
95
|
+
copy.scope = this.scope;
|
|
96
|
+
for (const runtime of this.runtimes)
|
|
97
|
+
copy.runtimes.add(runtime);
|
|
98
|
+
for (const role of this.roles)
|
|
99
|
+
copy.roles.add(role);
|
|
100
|
+
return copy;
|
|
101
|
+
}
|
|
102
|
+
/** How many filters are active: the drawer button's badge. */
|
|
103
|
+
count() {
|
|
104
|
+
return (this.scope === 'everything' ? 0 : 1) + this.runtimes.size + this.roles.size;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The Filter popover's three groups, INTERSECTED: a box stays when it is in the change scope, AND has
|
|
109
|
+
* any selected runtime chip (when one is selected), AND has any selected role chip (likewise).
|
|
110
|
+
*/
|
|
111
|
+
class WpProjectFilter {
|
|
112
|
+
model;
|
|
113
|
+
modes;
|
|
114
|
+
static SCOPE_NAMES = new Map([
|
|
115
|
+
['everything', 'Everything'],
|
|
116
|
+
['changed', 'Changed'],
|
|
117
|
+
['dependents', 'Changed + what uses them'],
|
|
118
|
+
['dependencies', 'Changed + what they use'],
|
|
119
|
+
['build', 'Whole build of this branch'],
|
|
120
|
+
]);
|
|
121
|
+
constructor(model, modes) {
|
|
122
|
+
this.model = model;
|
|
123
|
+
this.modes = modes;
|
|
124
|
+
}
|
|
125
|
+
/** The node ids a change scope keeps; null for "everything" (and whenever Impact is unavailable). */
|
|
126
|
+
scopeSet(scope) {
|
|
127
|
+
const impact = this.modes.impact;
|
|
128
|
+
if (scope === 'everything' || impact === null || this.modes.impactReason() !== '')
|
|
129
|
+
return null;
|
|
130
|
+
const ids = [...impact.touched];
|
|
131
|
+
if (scope === 'dependents' || scope === 'build')
|
|
132
|
+
ids.push(...impact.affected);
|
|
133
|
+
if (scope === 'dependencies')
|
|
134
|
+
ids.push(...impact.dependencies);
|
|
135
|
+
if (scope === 'build')
|
|
136
|
+
ids.push(...impact.buildInputs);
|
|
137
|
+
return new Set(ids);
|
|
138
|
+
}
|
|
139
|
+
/** How many drawn projects a scope keeps on its own: the count beside each option. */
|
|
140
|
+
scopeCount(scope) {
|
|
141
|
+
const set = this.scopeSet(scope);
|
|
142
|
+
return this.model.nodes.filter((node) => set === null || set.has(node.id)).length;
|
|
143
|
+
}
|
|
144
|
+
/** `base` (everything, or the Hide-unconnected chain) narrowed by `choice`. */
|
|
145
|
+
apply(base, choice) {
|
|
146
|
+
const scope = this.scopeSet(choice.scope);
|
|
147
|
+
const kept = new Set();
|
|
148
|
+
for (const node of this.model.nodes) {
|
|
149
|
+
if (!base.has(node.id))
|
|
150
|
+
continue;
|
|
151
|
+
if (scope !== null && !scope.has(node.id))
|
|
152
|
+
continue;
|
|
153
|
+
if (!this.anyOf(choice.runtimes, node.tags?.frameworks ?? []))
|
|
154
|
+
continue;
|
|
155
|
+
if (!this.anyOf(choice.roles, node.tags === null ? [] : [node.tags.role]))
|
|
156
|
+
continue;
|
|
157
|
+
kept.add(node.id);
|
|
158
|
+
}
|
|
159
|
+
return kept;
|
|
160
|
+
}
|
|
161
|
+
/** An empty chip group matches every box; otherwise the box must carry one of the chips. */
|
|
162
|
+
anyOf(selected, carried) {
|
|
163
|
+
return selected.size === 0 || carried.some((value) => selected.has(value));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
81
166
|
/** Architecture interactions bind to each new SVG; logical Lock/Filter state belongs to the page. */
|
|
82
167
|
class GraphHighlighter {
|
|
83
168
|
svg;
|
|
@@ -160,24 +245,121 @@ class GraphHighlighter {
|
|
|
160
245
|
const locked = this.page.lockSelection() === name;
|
|
161
246
|
items.push(new WpNodeMenuItem(locked ? 'Unlock' : 'Lock', () => this.page.setLock(locked ? null : name)));
|
|
162
247
|
items.push(this.page.filterItem(name));
|
|
163
|
-
items.push(this.page.modeItem());
|
|
164
248
|
return items;
|
|
165
249
|
});
|
|
166
250
|
}
|
|
167
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* The Filter popover: edits a COPY of the page's applied filter, shows what each choice would keep,
|
|
254
|
+
* and hands the copy to the page on "Show N projects". Closing it any other way discards the edit.
|
|
255
|
+
*/
|
|
256
|
+
class GraphFilterPopover {
|
|
257
|
+
page;
|
|
258
|
+
draft = new WpFilterChoice();
|
|
259
|
+
constructor(page) {
|
|
260
|
+
this.page = page;
|
|
261
|
+
}
|
|
262
|
+
wire() {
|
|
263
|
+
this.byId('wp-filter-open')?.addEventListener('click', (ev) => {
|
|
264
|
+
ev.stopPropagation();
|
|
265
|
+
if (this.isOpen())
|
|
266
|
+
this.close();
|
|
267
|
+
else
|
|
268
|
+
this.open();
|
|
269
|
+
});
|
|
270
|
+
this.byId('wp-filter-close')?.addEventListener('click', () => this.close());
|
|
271
|
+
this.byId('wp-filter-clear')?.addEventListener('click', () => {
|
|
272
|
+
this.draft = new WpFilterChoice();
|
|
273
|
+
this.paint();
|
|
274
|
+
});
|
|
275
|
+
this.byId('wp-filter-apply')?.addEventListener('click', () => {
|
|
276
|
+
this.page.applyFilter(this.draft.copy());
|
|
277
|
+
this.close();
|
|
278
|
+
});
|
|
279
|
+
document.querySelectorAll('input[name="wp-scope"]').forEach((radio) => {
|
|
280
|
+
radio.addEventListener('change', () => {
|
|
281
|
+
if (!radio.checked)
|
|
282
|
+
return;
|
|
283
|
+
this.draft.scope = radio.value;
|
|
284
|
+
this.paint();
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
document.querySelectorAll('.wp-chip[data-wp-chip]').forEach((chip) => {
|
|
288
|
+
chip.addEventListener('click', () => {
|
|
289
|
+
const set = chip.dataset['wpChipGroup'] === 'role' ? this.draft.roles : this.draft.runtimes;
|
|
290
|
+
const value = chip.dataset['wpChip'] ?? '';
|
|
291
|
+
if (set.has(value))
|
|
292
|
+
set.delete(value);
|
|
293
|
+
else
|
|
294
|
+
set.add(value);
|
|
295
|
+
this.paint();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
isOpen() {
|
|
300
|
+
return this.byId('wp-filter-pop')?.hidden === false;
|
|
301
|
+
}
|
|
302
|
+
open() {
|
|
303
|
+
const pop = this.byId('wp-filter-pop');
|
|
304
|
+
if (pop === null)
|
|
305
|
+
return;
|
|
306
|
+
this.draft = this.page.appliedFilter().copy();
|
|
307
|
+
this.paint();
|
|
308
|
+
pop.hidden = false;
|
|
309
|
+
this.byId('wp-filter-open')?.setAttribute('aria-expanded', 'true');
|
|
310
|
+
}
|
|
311
|
+
close() {
|
|
312
|
+
const pop = this.byId('wp-filter-pop');
|
|
313
|
+
if (pop === null || pop.hidden)
|
|
314
|
+
return;
|
|
315
|
+
pop.hidden = true;
|
|
316
|
+
this.byId('wp-filter-open')?.setAttribute('aria-expanded', 'false');
|
|
317
|
+
}
|
|
318
|
+
/** Repaint the controls from the draft: checked scope, pressed chips, counts and the footer. */
|
|
319
|
+
paint() {
|
|
320
|
+
const reason = this.page.scopeReason();
|
|
321
|
+
const group = this.byId('wp-scope-group');
|
|
322
|
+
if (group !== null)
|
|
323
|
+
group.disabled = reason !== '';
|
|
324
|
+
const reasonEl = this.byId('wp-scope-reason');
|
|
325
|
+
if (reasonEl !== null) {
|
|
326
|
+
reasonEl.textContent = reason;
|
|
327
|
+
reasonEl.hidden = reason === '';
|
|
328
|
+
}
|
|
329
|
+
document.querySelectorAll('input[name="wp-scope"]').forEach((radio) => {
|
|
330
|
+
radio.checked = radio.value === this.draft.scope;
|
|
331
|
+
});
|
|
332
|
+
document.querySelectorAll('[data-wp-scope-count]').forEach((count) => {
|
|
333
|
+
count.textContent = reason === '' ? String(this.page.scopeCount(count.dataset['wpScopeCount'] ?? '')) : '';
|
|
334
|
+
});
|
|
335
|
+
document.querySelectorAll('.wp-chip[data-wp-chip]').forEach((chip) => {
|
|
336
|
+
const set = chip.dataset['wpChipGroup'] === 'role' ? this.draft.roles : this.draft.runtimes;
|
|
337
|
+
chip.setAttribute('aria-pressed', String(set.has(chip.dataset['wpChip'] ?? '')));
|
|
338
|
+
});
|
|
339
|
+
const apply = this.byId('wp-filter-apply');
|
|
340
|
+
if (apply !== null) {
|
|
341
|
+
const shown = this.page.preview(this.draft);
|
|
342
|
+
apply.textContent = `Show ${shown} project${shown === 1 ? '' : 's'}`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
byId(id) {
|
|
346
|
+
return document.getElementById(id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
168
349
|
/**
|
|
169
350
|
* The drawer and its floating panels: every control outside the graph itself. Holds no graph state —
|
|
170
351
|
* each control calls back into the page, and `sync()` repaints the controls from the page's state.
|
|
171
352
|
*/
|
|
172
353
|
class GraphDrawer {
|
|
173
354
|
page;
|
|
355
|
+
popover;
|
|
174
356
|
constructor(page) {
|
|
175
357
|
this.page = page;
|
|
358
|
+
this.popover = new GraphFilterPopover(page);
|
|
176
359
|
}
|
|
177
360
|
wire() {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
361
|
+
this.wireModeMenu();
|
|
362
|
+
this.popover.wire();
|
|
181
363
|
this.wireLock();
|
|
182
364
|
this.byId('wp-filter-toggle')?.addEventListener('click', () => this.page.toggleFilter());
|
|
183
365
|
this.toggles('wp-collapse', 'wp-shell', true);
|
|
@@ -190,6 +372,45 @@ class GraphDrawer {
|
|
|
190
372
|
this.toggles('wp-snapshot-open', 'wp-snapshot', false);
|
|
191
373
|
window.addEventListener('keydown', (ev) => this.onKey(ev), true);
|
|
192
374
|
window.addEventListener('hashchange', () => this.page.setMode(location.hash.slice(1)));
|
|
375
|
+
document.addEventListener('click', (ev) => {
|
|
376
|
+
const target = ev.target;
|
|
377
|
+
if (target instanceof Node && this.byId('wp-mode-menu')?.parentElement?.contains(target))
|
|
378
|
+
return;
|
|
379
|
+
this.closeModeMenu(false);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
/** "Color by": the pill trigger opens the menu of three; picking one switches and closes it. */
|
|
383
|
+
wireModeMenu() {
|
|
384
|
+
this.byId('wp-mode-trigger')?.addEventListener('click', (ev) => {
|
|
385
|
+
ev.stopPropagation();
|
|
386
|
+
const menu = this.byId('wp-mode-menu');
|
|
387
|
+
if (menu === null)
|
|
388
|
+
return;
|
|
389
|
+
if (!menu.hidden) {
|
|
390
|
+
this.closeModeMenu(false);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
menu.hidden = false;
|
|
394
|
+
this.byId('wp-mode-trigger')?.setAttribute('aria-expanded', 'true');
|
|
395
|
+
menu.querySelector('.wp-mode[aria-checked="true"]')?.focus({ preventScroll: true });
|
|
396
|
+
});
|
|
397
|
+
document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
|
|
398
|
+
button.addEventListener('click', (ev) => {
|
|
399
|
+
ev.stopPropagation();
|
|
400
|
+
this.closeModeMenu(true);
|
|
401
|
+
this.page.setMode(button.dataset['wpMode'] ?? 'runtime');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
closeModeMenu(returnFocus) {
|
|
406
|
+
const menu = this.byId('wp-mode-menu');
|
|
407
|
+
if (menu === null || menu.hidden)
|
|
408
|
+
return;
|
|
409
|
+
menu.hidden = true;
|
|
410
|
+
const trigger = this.byId('wp-mode-trigger');
|
|
411
|
+
trigger?.setAttribute('aria-expanded', 'false');
|
|
412
|
+
if (returnFocus)
|
|
413
|
+
trigger?.focus({ preventScroll: true });
|
|
193
414
|
}
|
|
194
415
|
/** Type-to-search lock: an exact project id (or a unique short name) locks, empty or Esc unlocks. */
|
|
195
416
|
wireLock() {
|
|
@@ -221,11 +442,20 @@ class GraphDrawer {
|
|
|
221
442
|
}
|
|
222
443
|
/**
|
|
223
444
|
* Page keys, in the CAPTURE phase so the open/closed state of the node menu is read before the
|
|
224
|
-
* menu's own Escape handler closes it: Esc with a menu open only closes the menu.
|
|
445
|
+
* menu's own Escape handler closes it: Esc with a menu open only closes the menu. Esc closes an
|
|
446
|
+
* open Color-by menu or Filter popover first, too.
|
|
225
447
|
*/
|
|
226
448
|
onKey(ev) {
|
|
227
449
|
if (ev.altKey || ev.ctrlKey || ev.metaKey)
|
|
228
450
|
return;
|
|
451
|
+
if (ev.key === 'Escape' && this.byId('wp-mode-menu')?.hidden === false) {
|
|
452
|
+
this.closeModeMenu(true);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (ev.key === 'Escape' && this.popover.isOpen()) {
|
|
456
|
+
this.popover.close();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
229
459
|
const target = ev.target;
|
|
230
460
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
231
461
|
return;
|
|
@@ -263,15 +493,17 @@ class GraphDrawer {
|
|
|
263
493
|
});
|
|
264
494
|
}
|
|
265
495
|
/** Repaint every control from the page's state. */
|
|
266
|
-
sync(modes, locked, filtering) {
|
|
496
|
+
sync(modes, locked, filtering, filter) {
|
|
267
497
|
const reason = modes.impactReason();
|
|
268
498
|
document.querySelectorAll('.wp-mode[data-wp-mode]').forEach((button) => {
|
|
269
499
|
const mode = button.dataset['wpMode'] ?? '';
|
|
270
|
-
button.setAttribute('aria-
|
|
500
|
+
button.setAttribute('aria-checked', String(mode === modes.mode));
|
|
271
501
|
button.disabled = !modes.available(mode);
|
|
272
502
|
if (mode === 'impact')
|
|
273
503
|
button.title = reason === '' ? 'Key 3' : reason;
|
|
274
504
|
});
|
|
505
|
+
this.text('wp-mode-current', modes.name(modes.mode));
|
|
506
|
+
this.text('wp-mode-current-sub', modes.subtitle(modes.mode));
|
|
275
507
|
const reasonEl = this.byId('wp-impact-reason');
|
|
276
508
|
if (reasonEl !== null) {
|
|
277
509
|
reasonEl.textContent = reason;
|
|
@@ -290,6 +522,7 @@ class GraphDrawer {
|
|
|
290
522
|
if (input !== null && document.activeElement !== input)
|
|
291
523
|
input.value = locked ?? '';
|
|
292
524
|
this.syncFilterToggle(locked, filtering);
|
|
525
|
+
this.syncFilter(filter);
|
|
293
526
|
this.crumb(modes.name(modes.mode), locked);
|
|
294
527
|
}
|
|
295
528
|
syncFilterToggle(locked, filtering) {
|
|
@@ -300,13 +533,67 @@ class GraphDrawer {
|
|
|
300
533
|
toggle.disabled = !filtering && locked === null;
|
|
301
534
|
toggle.title = toggle.disabled ? 'Lock a project first' : '';
|
|
302
535
|
}
|
|
536
|
+
/** The drawer button's count badge, and one removable pill per active filter in the top bar. */
|
|
537
|
+
syncFilter(filter) {
|
|
538
|
+
const badge = this.byId('wp-filter-badge');
|
|
539
|
+
if (badge !== null) {
|
|
540
|
+
badge.textContent = String(filter.count());
|
|
541
|
+
badge.hidden = filter.count() === 0;
|
|
542
|
+
}
|
|
543
|
+
const pills = this.byId('wp-filter-pills');
|
|
544
|
+
if (pills === null)
|
|
545
|
+
return;
|
|
546
|
+
const items = [];
|
|
547
|
+
if (filter.scope !== 'everything')
|
|
548
|
+
items.push(this.pill(WpProjectFilter.SCOPE_NAMES.get(filter.scope) ?? filter.scope, 'scope', filter.scope));
|
|
549
|
+
for (const runtime of filter.runtimes)
|
|
550
|
+
items.push(this.pill(runtime, 'runtime', runtime));
|
|
551
|
+
for (const role of filter.roles)
|
|
552
|
+
items.push(this.pill(role, 'role', role));
|
|
553
|
+
pills.replaceChildren(...items);
|
|
554
|
+
}
|
|
555
|
+
/** A pill, built with text nodes so no value is parsed as HTML. */
|
|
556
|
+
pill(label, group, value) {
|
|
557
|
+
const pill = document.createElement('span');
|
|
558
|
+
pill.className = 'wp-pill';
|
|
559
|
+
pill.dataset['wpPill'] = `${group}:${value}`;
|
|
560
|
+
pill.append(label);
|
|
561
|
+
const remove = document.createElement('button');
|
|
562
|
+
remove.type = 'button';
|
|
563
|
+
remove.textContent = '✕';
|
|
564
|
+
remove.setAttribute('aria-label', `Remove filter ${label}`);
|
|
565
|
+
remove.addEventListener('click', (ev) => {
|
|
566
|
+
ev.stopPropagation();
|
|
567
|
+
this.page.removeFilter(group, value);
|
|
568
|
+
});
|
|
569
|
+
pill.append(remove);
|
|
570
|
+
return pill;
|
|
571
|
+
}
|
|
303
572
|
impactNote(modes) {
|
|
304
573
|
const impact = modes.impact;
|
|
305
574
|
if (impact === null || !impact.available)
|
|
306
575
|
return '';
|
|
307
|
-
|
|
576
|
+
const stats = `${impact.touched.length} touched · ${impact.affected.length} affected · ` +
|
|
308
577
|
`${impact.buildInputs.length} build inputs — nx affected since fork point ${impact.base}, ` +
|
|
309
|
-
`${impact.changedFiles} changed files
|
|
578
|
+
`${impact.changedFiles} changed files.`;
|
|
579
|
+
const cause = this.globalCause(impact);
|
|
580
|
+
return cause === '' ? stats : `${cause}. ${stats}`;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* When files changed but no project owns one, everything nx affected is affected by a
|
|
584
|
+
* workspace-global input (a lockfile, the workspace manifest). That is correct for CI, and
|
|
585
|
+
* baffling on a graph, so the note names the cause.
|
|
586
|
+
*/
|
|
587
|
+
globalCause(impact) {
|
|
588
|
+
if (impact.touched.length > 0 || impact.changedFiles === 0)
|
|
589
|
+
return '';
|
|
590
|
+
const affected = impact.affected.length;
|
|
591
|
+
const who = affected >= this.page.projectCount() ? 'Every project affected' : `${affected} projects affected`;
|
|
592
|
+
const files = impact.globalFiles;
|
|
593
|
+
if (files.length === 0)
|
|
594
|
+
return `${who}: ${impact.changedFiles} changed files that no project owns (workspace-global input)`;
|
|
595
|
+
const named = files.slice(0, 2).join(', ') + (files.length > 2 ? ` and ${files.length - 2} more` : '');
|
|
596
|
+
return `${who}: ${named} changed (workspace-global input${files.length > 1 ? 's' : ''})`;
|
|
310
597
|
}
|
|
311
598
|
/** "Mode: <b>Runtime</b> · Locked: <b>x</b>", built with text nodes so no name is parsed as HTML. */
|
|
312
599
|
crumb(mode, locked) {
|
|
@@ -323,6 +610,11 @@ class GraphDrawer {
|
|
|
323
610
|
parts.push(' · Locked: ', bold(locked));
|
|
324
611
|
crumb.replaceChildren(...parts);
|
|
325
612
|
}
|
|
613
|
+
text(id, value) {
|
|
614
|
+
const el = this.byId(id);
|
|
615
|
+
if (el !== null)
|
|
616
|
+
el.textContent = value;
|
|
617
|
+
}
|
|
326
618
|
lockInput() {
|
|
327
619
|
return document.getElementById('wp-lock');
|
|
328
620
|
}
|
|
@@ -333,6 +625,8 @@ class GraphDrawer {
|
|
|
333
625
|
class GraphPage extends WpFilterPage {
|
|
334
626
|
highlighter = null;
|
|
335
627
|
modes = new WpModeState();
|
|
628
|
+
projects = new WpProjectFilter(this.model, this.modes);
|
|
629
|
+
applied = new WpFilterChoice();
|
|
336
630
|
drawer = new GraphDrawer(this);
|
|
337
631
|
captureBinding(_svg) {
|
|
338
632
|
const previous = this.highlighter;
|
|
@@ -357,7 +651,14 @@ class GraphPage extends WpFilterPage {
|
|
|
357
651
|
return modes.runtime;
|
|
358
652
|
}
|
|
359
653
|
usesFullDot() {
|
|
360
|
-
return this.modes.mode === 'runtime';
|
|
654
|
+
return this.modes.mode === 'runtime' && this.applied.count() === 0;
|
|
655
|
+
}
|
|
656
|
+
narrow(retained) {
|
|
657
|
+
return this.projects.apply(retained, this.applied);
|
|
658
|
+
}
|
|
659
|
+
/** Filtering keeps every box in its own L-row: an emptied level stays as a thin labeled band. */
|
|
660
|
+
keepsEmptyLevels() {
|
|
661
|
+
return true;
|
|
361
662
|
}
|
|
362
663
|
wireSvg(svg) {
|
|
363
664
|
this.highlighter = new GraphHighlighter(svg, this.chain, this.model, this);
|
|
@@ -375,6 +676,9 @@ class GraphPage extends WpFilterPage {
|
|
|
375
676
|
hasNode(id) {
|
|
376
677
|
return this.model.nodes.some((node) => node.id === id);
|
|
377
678
|
}
|
|
679
|
+
projectCount() {
|
|
680
|
+
return this.model.nodes.length;
|
|
681
|
+
}
|
|
378
682
|
/** An exact id, else the one id whose short name (after any scope) is `text`; null if none or several. */
|
|
379
683
|
resolveName(text) {
|
|
380
684
|
if (text === '')
|
|
@@ -403,19 +707,43 @@ class GraphPage extends WpFilterPage {
|
|
|
403
707
|
else if (this.locked !== null)
|
|
404
708
|
this.filter(this.locked);
|
|
405
709
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
710
|
+
appliedFilter() {
|
|
711
|
+
return this.applied;
|
|
712
|
+
}
|
|
713
|
+
/** '' when the change-scope group can be used, else Impact's reason (the group is disabled). */
|
|
714
|
+
scopeReason() {
|
|
715
|
+
return this.modes.impactReason();
|
|
716
|
+
}
|
|
717
|
+
scopeCount(scope) {
|
|
718
|
+
return this.projects.scopeCount(scope);
|
|
719
|
+
}
|
|
720
|
+
/** How many boxes `choice` would show, with the current Hide-unconnected chain. */
|
|
721
|
+
preview(choice) {
|
|
722
|
+
const base = this.anchor === null
|
|
723
|
+
? new Set(this.model.nodes.map((node) => node.id))
|
|
724
|
+
: this.chain.nodes(this.anchor);
|
|
725
|
+
return this.projects.apply(base, choice).size;
|
|
726
|
+
}
|
|
727
|
+
applyFilter(choice) {
|
|
728
|
+
if (this.scopeReason() !== '')
|
|
729
|
+
choice.scope = 'everything';
|
|
730
|
+
this.applied = choice;
|
|
731
|
+
this.syncControls();
|
|
732
|
+
this.redraw();
|
|
733
|
+
}
|
|
734
|
+
/** A top-bar pill's ✕. */
|
|
735
|
+
removeFilter(group, value) {
|
|
736
|
+
const next = this.applied.copy();
|
|
737
|
+
if (group === 'scope')
|
|
738
|
+
next.scope = 'everything';
|
|
739
|
+
else if (group === 'role')
|
|
740
|
+
next.roles.delete(value);
|
|
741
|
+
else
|
|
742
|
+
next.runtimes.delete(value);
|
|
743
|
+
this.applyFilter(next);
|
|
416
744
|
}
|
|
417
745
|
syncControls() {
|
|
418
|
-
this.drawer.sync(this.modes, this.locked, this.anchor !== null);
|
|
746
|
+
this.drawer.sync(this.modes, this.locked, this.anchor !== null, this.applied);
|
|
419
747
|
}
|
|
420
748
|
filterCards() {
|
|
421
749
|
const lit = this.locked === null ? this.retained : this.chain.nodes(this.locked);
|