Skip to content

Commit 2a2d8d6

Browse files
authored
feat(components): open nested values on demand, show hydration and list directive hosts (#388)
Refs #341.
1 parent 788a985 commit 2a2d8d6

45 files changed

Lines changed: 6160 additions & 293 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import type { DevframeRpcClient } from 'devframe/client';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { ComponentTree } from '../pages/component-tree';
5+
import {
6+
countText,
7+
directiveHostLabel,
8+
filterAnnouncement,
9+
filterTree,
10+
withoutDirectiveHosts,
11+
type TreeNode,
12+
} from '../pages/component-tree-state';
13+
14+
function client(pages: Record<string, unknown>) {
15+
const calls: { name: string; args: unknown[] }[] = [];
16+
const rpc = {
17+
call: (name: string, ...args: unknown[]) => {
18+
calls.push({ name, args });
19+
return Promise.resolve([]);
20+
},
21+
callEvent: () => Promise.resolve(),
22+
sharedState: (name: string) =>
23+
Promise.resolve({
24+
value: () => (name === 'component-tree' ? { pages } : null),
25+
on: () => () => {},
26+
}),
27+
};
28+
return {
29+
calls,
30+
client: { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient,
31+
};
32+
}
33+
34+
const roots: TreeNode[] = [
35+
{
36+
id: 'c1',
37+
name: 'App',
38+
tag: 'app-root',
39+
hydration: 'hydrated',
40+
children: [
41+
{
42+
id: 'c2',
43+
name: 'RouterLink',
44+
tag: 'a',
45+
kind: 'directive',
46+
directives: ['RouterLink'],
47+
attributes: ['routerLink'],
48+
children: [
49+
{ id: 'c3', name: 'Badge', tag: 'app-badge', hydration: 'hydrated', children: [] },
50+
],
51+
},
52+
{
53+
id: 'c4',
54+
name: 'TripMap',
55+
tag: 'app-map',
56+
hydration: 'skipped',
57+
children: [
58+
{
59+
id: 'c5',
60+
name: 'MatTooltip',
61+
tag: 'button',
62+
kind: 'directive',
63+
directives: ['MatTooltip'],
64+
attributes: ['matTooltip'],
65+
children: [],
66+
},
67+
],
68+
},
69+
{ id: 'c6', name: 'Price', tag: 'app-price', hydration: 'mismatched', children: [] },
70+
],
71+
},
72+
];
73+
74+
const hydration = {
75+
hydrated: true,
76+
counts: { hydrated: 2, skipped: 1, mismatched: 1, client: 0, dehydratedBlocks: 1 },
77+
mismatches: [{ id: 'c6', name: 'Price', tag: 'app-price', expected: '<div>', actual: '<span>' }],
78+
};
79+
80+
const page = (extra: Record<string, unknown> = {}) => ({
81+
pageId: 'p1',
82+
roots,
83+
count: 4,
84+
directiveHostCount: 2,
85+
detail: null,
86+
reportedAt: 1000,
87+
hydration,
88+
...extra,
89+
});
90+
91+
async function render(value: unknown) {
92+
const fixture = TestBed.createComponent(ComponentTree);
93+
const rpc = client({ p1: value });
94+
fixture.componentRef.setInput('rpc', rpc.client);
95+
for (let i = 0; i < 3; i++) {
96+
await new Promise((resolve) => setTimeout(resolve));
97+
await fixture.whenStable();
98+
}
99+
const el = fixture.nativeElement as HTMLElement;
100+
const settle = async () => {
101+
fixture.detectChanges();
102+
await fixture.whenStable();
103+
};
104+
return { fixture, el, calls: rpc.calls, settle };
105+
}
106+
107+
const rowIds = (el: HTMLElement) =>
108+
Array.from(el.querySelectorAll<HTMLElement>('.row')).map((row) => row.dataset['id']);
109+
110+
describe('tree search and directive hosts (state)', () => {
111+
it('filters with tag, directive and text tokens, keeping ancestors', () => {
112+
const { nodes, matches } = filterTree(roots, '<button> [matTooltip]');
113+
expect(matches).toBe(1);
114+
expect(nodes[0].children.map((n) => n.id)).toEqual(['c4']);
115+
expect(nodes[0].children[0].children.map((n) => n.id)).toEqual(['c5']);
116+
expect(filterTree(roots, '<app-map>').matches).toBe(1);
117+
expect(filterTree(roots, '[routerLink] badge').matches).toBe(0);
118+
expect(filterTree(roots, 'price').matches).toBe(1);
119+
});
120+
121+
it('lifts the children of hidden directive hosts to their parent', () => {
122+
const shown = withoutDirectiveHosts(roots);
123+
expect(shown[0].children.map((n) => n.id)).toEqual(['c3', 'c4', 'c6']);
124+
expect(shown[0].children[1].children).toEqual([]);
125+
});
126+
127+
it('labels a directive host by its attributes, else its classes, and counts hosts apart', () => {
128+
expect(directiveHostLabel(roots[0].children[0])).toBe('[routerLink]');
129+
expect(
130+
directiveHostLabel({
131+
id: 'x',
132+
name: 'Foo',
133+
tag: 'div',
134+
directives: ['Highlight'],
135+
children: [],
136+
}),
137+
).toBe('[Highlight]');
138+
expect(countText(4, 0, false, false, 2)).toBe('4 instances · 2 directive hosts');
139+
expect(countText(4, 1, true, false, 2)).toBe('1 of 6');
140+
// With directive hosts shown, the filter result counts rows, not instances.
141+
expect(filterAnnouncement(4, 1, 'tip', false, 2)).toBe('1 of 6 rows match.');
142+
expect(filterAnnouncement(4, 1, 'tip')).toBe('1 of 4 instances match.');
143+
});
144+
});
145+
146+
describe('component tree hydration and directive hosts', () => {
147+
beforeEach(() => {
148+
vi.stubGlobal('CSS', { escape: (value: string) => value });
149+
Element.prototype.scrollIntoView ??= () => {};
150+
try {
151+
localStorage.clear();
152+
} catch {
153+
// no storage in this environment
154+
}
155+
});
156+
afterEach(() => {
157+
vi.unstubAllGlobals();
158+
document.body.innerHTML = '';
159+
});
160+
161+
it('shows a hydration badge with text on each component row', async () => {
162+
const { el } = await render(page());
163+
const badges = Array.from(el.querySelectorAll('.row .hyd')).map((b) =>
164+
b.textContent!.replace(/\s+/g, ' ').trim(),
165+
);
166+
expect(badges).toEqual([
167+
'Hydration: Hydrated',
168+
'Hydration: Hydrated',
169+
'Hydration: Skipped',
170+
'Hydration: Mismatch',
171+
]);
172+
expect(el.querySelector('.hyd-mismatched')).not.toBeNull();
173+
});
174+
175+
it('shows directive hosts as their own rows, hidden by the toolbar toggle', async () => {
176+
const { el, settle } = await render(page());
177+
const host = el.querySelector('.row[data-id="c2"]')!;
178+
expect(host.querySelector('.host-tag')!.textContent).toBe('<a>');
179+
expect(host.querySelector('.host-dirs')!.textContent!.trim()).toBe('[routerLink]');
180+
expect(host.querySelector('.sr-only')!.textContent).toBe('directive host');
181+
expect(el.querySelectorAll('.row.dir-host')).toHaveLength(2);
182+
expect(el.querySelector('.count')!.textContent).toContain('4 instances · 2 directive hosts');
183+
184+
const toggle = Array.from(el.querySelectorAll<HTMLButtonElement>('.tree-action')).find((b) =>
185+
b.textContent!.includes('Directive hosts'),
186+
)!;
187+
expect(toggle.getAttribute('aria-pressed')).toBe('true');
188+
toggle.click();
189+
await settle();
190+
expect(toggle.getAttribute('aria-pressed')).toBe('false');
191+
expect(el.querySelectorAll('.row.dir-host')).toHaveLength(0);
192+
expect(rowIds(el)).toEqual(['c1', 'c3', 'c4', 'c6']);
193+
expect(localStorage.getItem('pangular:components:directive-hosts')).toBe('hidden');
194+
});
195+
196+
it('filters the tree with the search syntax', async () => {
197+
const { el, fixture, settle } = await render(page());
198+
fixture.componentInstance.setFilter('<button> [matTooltip]');
199+
await settle();
200+
expect(rowIds(el)).toEqual(['c1', 'c4', 'c5']);
201+
expect(el.querySelector('.count')!.textContent!.trim()).toBe('1 of 6');
202+
expect(fixture.componentInstance.announcement()).toBe('1 of 6 rows match.');
203+
});
204+
205+
it('selects a directive host locally and offers its owning component', async () => {
206+
const { el, calls, settle } = await render(page());
207+
const row = el.querySelector<HTMLElement>('.row[data-id="c5"]')!;
208+
row.click();
209+
await settle();
210+
expect(calls.some((c) => c.name === 'select-component')).toBe(false);
211+
const detail = el.querySelector('.detail')!;
212+
expect(detail.querySelector('.badge')!.textContent).toBe('directive host');
213+
expect(detail.textContent).toContain('MatTooltip');
214+
const owner = detail.querySelector<HTMLButtonElement>('.owner-action button')!;
215+
expect(owner.textContent!.trim()).toBe('Show TripMap');
216+
owner.focus();
217+
owner.click();
218+
await settle();
219+
expect(el.querySelector('.row.selected')!.getAttribute('data-id')).toBe('c4');
220+
// The button goes away with the host's detail, so focus moves to the owner's row.
221+
expect(document.activeElement).not.toBe(document.body);
222+
expect(document.activeElement?.getAttribute('data-id')).toBe('c4');
223+
expect(document.activeElement?.getAttribute('tabindex')).toBe('0');
224+
});
225+
226+
it('reaches directive host rows with the arrow keys', async () => {
227+
const { el, settle } = await render(page());
228+
const tree = el.querySelector<HTMLElement>('[role="tree"]')!;
229+
el.querySelector<HTMLElement>('.row[data-id="c1"]')!.focus();
230+
tree.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
231+
await new Promise((resolve) => queueMicrotask(() => resolve(null)));
232+
await settle();
233+
expect(document.activeElement?.getAttribute('data-id')).toBe('c2');
234+
expect(document.activeElement?.getAttribute('role')).toBe('treeitem');
235+
});
236+
237+
it('summarizes hydration and asks the page to outline it', async () => {
238+
const { el, calls, settle } = await render(page());
239+
const section = el.querySelector('app-hydration-section')!;
240+
const counts = Array.from(section.querySelectorAll('.counts li')).map((li) =>
241+
li.textContent!.replace(/\s+/g, ' ').trim(),
242+
);
243+
expect(counts).toEqual([
244+
'2 hydrated',
245+
'1 skipped',
246+
'1 mismatched',
247+
'0 created on the client',
248+
'1 dehydrated @defer block',
249+
]);
250+
expect(section.textContent).toContain('Expected <div>');
251+
const outline = section.querySelector<HTMLButtonElement>('.outline')!;
252+
expect(outline.getAttribute('aria-pressed')).toBe('false');
253+
outline.click();
254+
await settle();
255+
expect(outline.getAttribute('aria-pressed')).toBe('true');
256+
const call = calls.find((c) => c.name === 'request-hydration-overlay')!;
257+
expect(call.args[0]).toEqual({ pageId: 'p1', on: true });
258+
259+
section.querySelector<HTMLButtonElement>('.mismatch')!.click();
260+
await settle();
261+
expect(el.querySelector('.row.selected')!.getAttribute('data-id')).toBe('c6');
262+
});
263+
264+
it('says hydration does not apply to a page rendered in the browser only', async () => {
265+
const plain = (nodes: TreeNode[]): TreeNode[] =>
266+
nodes.map(({ hydration: _, ...node }) => ({ ...node, children: plain(node.children) }));
267+
const { el } = await render(page({ hydration: undefined, roots: plain(roots) }));
268+
const section = el.querySelector('app-hydration-section')!;
269+
expect(section.textContent).toContain('not rendered on the server');
270+
expect(section.querySelector('.outline')).toBeNull();
271+
expect(el.querySelector('.row .hyd')).toBeNull();
272+
});
273+
274+
it('says client hydration is off when the server HTML was not hydrated', async () => {
275+
const { el } = await render(page({ hydration: { ...hydration, hydrated: false } }));
276+
expect(el.querySelector('app-hydration-section')!.textContent).toContain(
277+
'no component was hydrated',
278+
);
279+
});
280+
});

0 commit comments

Comments
 (0)