Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/src/__tests__/router-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RouteLint } from '../pages/route-lint';
import { RouteTimeline } from '../pages/route-timeline';
import { RouteTree } from '../pages/route-tree';
import type { NavigationRecord, RouteNode, RouterPage } from '../pages/router-types';
import type { ComponentFocus } from '../types/component-focus.types';

type Call = (name: string, arg: Record<string, unknown>) => Promise<unknown>;

Expand Down Expand Up @@ -476,4 +477,50 @@ describe('RouteCurrent', () => {
expect(rows[0].textContent).toContain('routerOutletData {"user":"Ada"}');
expect(rows[1].textContent).not.toContain('routerOutletData');
});

const routed = page({
outlets: [
{
outlet: 'primary',
activated: true,
component: 'Shell',
route: '/shell',
devtoolsId: 'c7',
children: [
{ outlet: 'side', activated: true, component: 'Filters', route: '/side' },
{ outlet: 'modal', activated: false, devtoolsId: 'c9' },
],
},
],
});

function showButtons(fixture: ComponentFixture<unknown>) {
return Array.from(el(fixture).querySelectorAll<HTMLButtonElement>('.outlets li button'));
}

it('offers Show in Components only on activated outlets with a component id', async () => {
const fixture = mount(RouteCurrent, routed, offline);
await settle(fixture);
const buttons = showButtons(fixture);
expect(buttons).toHaveLength(1);
expect(buttons[0].textContent?.replace(/\s+/g, ' ').trim()).toBe('Show in Components: Shell');
expect(buttons[0].closest('li')?.textContent).toContain('Shell');
});

it('emits the component id and its page when Show in Components is clicked', async () => {
const fixture = mount(RouteCurrent, routed, offline);
const shown: ComponentFocus[] = [];
fixture.componentInstance.showComponent.subscribe((focus) => shown.push(focus));
await settle(fixture);
showButtons(fixture)[0].click();
expect(shown).toEqual([{ pageId: 'p1', id: 'c7' }]);
});

it('hides Show in Components when the Components inspector is off', async () => {
const fixture = mount(RouteCurrent, routed, offline, {
configs: { pangular: { inspectors: { components: false } } },
});
await settle(fixture);
expect(showButtons(fixture)).toHaveLength(0);
});
});
230 changes: 230 additions & 0 deletions app/src/__tests__/router-show-component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

const connection = vi.hoisted(() => ({
resolve: (_client: unknown) => {},
}));

vi.mock('devframe/client', () => ({
connectDevframe: () =>
new Promise((resolve) => {
connection.resolve = resolve;
}),
}));

const { App } = await import('../app');
const { ComponentTree } = await import('../pages/component-tree');

const singlePage: Record<string, unknown> = {
'component-tree': {
pages: {
p1: {
pageId: 'p1',
title: 'Demo',
url: '/shell',
roots: [
{
id: 'c1',
name: 'App',
tag: 'app-root',
children: [{ id: 'c7', name: 'Shell', tag: 'app-shell', children: [] }],
},
],
count: 2,
detail: null,
reportedAt: 1,
},
},
},
router: {
pages: [
{
pageId: 'p1',
reportedAt: 1,
snapshot: {
url: '/shell',
queryParams: {},
fragment: null,
root: { path: '', url: '', outlet: 'primary', params: {}, data: {}, children: [] },
},
navigations: [],
outlets: [
{
outlet: 'primary',
activated: true,
component: 'Shell',
route: '/shell',
devtoolsId: 'c7',
},
],
},
],
},
};

function treePage(pageId: string, reportedAt: number, routedId: string) {
return {
pageId,
title: `Demo ${pageId}`,
url: '/shell',
roots: [
{
id: `${pageId}-root`,
name: 'App',
tag: 'app-root',
children: [{ id: routedId, name: 'Shell', tag: 'app-shell', children: [] }],
},
],
count: 2,
detail: null,
reportedAt,
};
}

function routerPage(pageId: string, reportedAt: number, devtoolsId: string) {
return {
pageId,
reportedAt,
snapshot: {
url: '/shell',
queryParams: {},
fragment: null,
root: { path: '', url: '', outlet: 'primary', params: {}, data: {}, children: [] },
},
navigations: [],
outlets: [
{ outlet: 'primary', activated: true, component: 'Shell', route: '/shell', devtoolsId },
],
};
}

// Two app tabs: the older one reports a router snapshot first, so the Router tab
// shows it, while the Components tab shows the newest page.
const twoPages: Record<string, unknown> = {
'component-tree': {
pages: { old: treePage('old', 1, 'old-shell'), new: treePage('new', 2, 'new-shell') },
},
router: { pages: [routerPage('old', 1, 'old-shell'), routerPage('new', 2, 'new-shell')] },
};

// The router still reports a component the tree no longer has.
const gone: Record<string, unknown> = {
'component-tree': { pages: { p1: treePage('p1', 1, 'c7') } },
router: { pages: [routerPage('p1', 1, 'c9')] },
};

let state = singlePage;

function fakeClient(): DevframeRpcClient {
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }),
};
return {
connectionMeta: { configs: {} },
scope: () => ({ rpc }),
events: { on: () => () => {} },
} as unknown as DevframeRpcClient;
}

async function settle(fixture: ComponentFixture<unknown>) {
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
}

function el(fixture: ComponentFixture<unknown>) {
return fixture.nativeElement as HTMLElement;
}

function activeTab(fixture: ComponentFixture<unknown>) {
return el(fixture).querySelector('nav button.active')?.textContent?.trim();
}

async function openRoutes(data: Record<string, unknown>) {
state = data;
location.hash = '#tab=routes';
const fixture = TestBed.createComponent(App);
document.body.append(el(fixture));
await settle(fixture);
connection.resolve(fakeClient());
await settle(fixture);
return fixture;
}

function tree(fixture: ComponentFixture<unknown>) {
return fixture.debugElement.query((node) => node.componentInstance instanceof ComponentTree)
.componentInstance as InstanceType<typeof ComponentTree>;
}

afterEach(() => {
state = singlePage;
document.body.innerHTML = '';
location.hash = '';
sessionStorage.clear();
});

describe('Show in Components from the Router Current view', () => {
it('opens the Components tab with the routed component selected', async () => {
location.hash = '#tab=routes';
const fixture = TestBed.createComponent(App);
document.body.append(el(fixture));
await settle(fixture);
connection.resolve(fakeClient());
await settle(fixture);
expect(activeTab(fixture)).toBe('Routes');

const show = el(fixture).querySelector<HTMLButtonElement>('.outlets li button');
expect(show?.textContent?.replace(/\s+/g, ' ').trim()).toBe('Show in Components: Shell');
show!.click();
await settle(fixture);

expect(activeTab(fixture)).toBe('Components');
const tree = fixture.debugElement.query(
(node) => node.componentInstance instanceof ComponentTree,
);
expect(tree.componentInstance.selectedId()).toBe('c7');
expect(el(fixture).querySelector('[data-id="c7"]')?.getAttribute('aria-selected')).toBe('true');
// The tree took the focus, so it is not applied again on a later visit.
expect(fixture.componentInstance.componentFocus()).toBeNull();
});

it('applies the focus after the tab switch so the switch does not clear it', () => {
const fixture = TestBed.createComponent(App);
fixture.componentInstance.showComponent({ id: 'c7' });
expect(fixture.componentInstance.tab()).toBe('components');
expect(fixture.componentInstance.componentFocus()).toEqual({ id: 'c7' });
});

it('selects the component on the page the Router tab shows', async () => {
const fixture = await openRoutes(twoPages);
const show = el(fixture).querySelector<HTMLButtonElement>('.outlets li button');
show!.click();
await settle(fixture);

expect(activeTab(fixture)).toBe('Components');
expect(tree(fixture).page()?.pageId).toBe('old');
expect(tree(fixture).selectedId()).toBe('old-shell');
expect(el(fixture).querySelector('[data-id="old-shell"]')?.getAttribute('aria-selected')).toBe(
'true',
);
});

it('says so when the component is no longer on the page', async () => {
const fixture = await openRoutes(gone);
el(fixture).querySelector<HTMLButtonElement>('.outlets li button')!.click();
await settle(fixture);

expect(activeTab(fixture)).toBe('Components');
expect(tree(fixture).selectedId()).toBeNull();
const status = [...el(fixture).querySelectorAll('[role="status"]')].map((n) =>
n.textContent?.trim(),
);
expect(status).toContain('That component is no longer on the page.');
expect(fixture.componentInstance.componentFocus()).toBeNull();
});
});
12 changes: 10 additions & 2 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { StoreInspector } from './pages/store-inspector';
import { FormsInspector } from './pages/forms-inspector';
import { PipesInspector } from './pages/pipes-inspector';
import type { Tab, Tabs } from './types/tab.types';
import type { ComponentFocus } from './types/component-focus.types';
import { AnalogInspector } from './pages/analog-inspector';
import { NetworkInspector } from './pages/network-inspector';
import { ComingSoon, type ComingSoonInfo } from './pages/coming-soon';
Expand Down Expand Up @@ -409,7 +410,7 @@ function readView(): View | null {
/>
}
@case ('routes') {
<app-route-inspector [rpc]="rpc()" />
<app-route-inspector [rpc]="rpc()" (showComponent)="showComponent($event)" />
}
@case ('signals') {
<app-signal-inspector [rpc]="rpc()" />
Expand Down Expand Up @@ -985,7 +986,7 @@ export class App implements OnInit, OnDestroy {

formFocus = signal<{ id: string } | null>(null);
// Belongs to the Components tab that received it, so leaving the tab drops it.
readonly componentFocus = linkedSignal<Tab, { id: string } | null>({
readonly componentFocus = linkedSignal<Tab, ComponentFocus | null>({
source: this.tab,
computation: () => null,
});
Expand All @@ -995,6 +996,13 @@ export class App implements OnInit, OnDestroy {
this.switchTab('forms');
}

showComponent(focus: ComponentFocus) {
if (!this.config().inspectors.components) return;
// Switch first: the focus belongs to the tab, so the switch would clear it.
this.switchTab('components');
this.componentFocus.set(focus);
}

inspectFromPanel({ source, origin, data }: MessageEvent<unknown>) {
if (source !== window.parent || origin !== location.origin) return;
const message = data as { type?: unknown; id?: unknown } | null;
Expand Down
16 changes: 13 additions & 3 deletions app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
reconcileSelection,
} from './component-tree-state';
import { CdRecording, type CdPage } from './cd-recording';
import type { ComponentFocus } from '../types/component-focus.types';

interface SourceComponent {
selector: string;
Expand Down Expand Up @@ -1371,7 +1372,7 @@ function bare(name: string): string {
export class ComponentTree {
readonly rpc = input<DevframeRpcClient | null>(null);
readonly staticReport = computed(() => isStaticReport(this.rpc()));
readonly focus = input<{ id: string } | null>(null);
readonly focus = input<ComponentFocus | null>(null);
readonly showForm = output<string>();
readonly focusHandled = output<void>();

Expand Down Expand Up @@ -1568,9 +1569,18 @@ export class ComponentTree {
});
effect(() => {
const focus = this.focus();
if (!focus || !this.live()) return;
if (!focus) return;
const shown = this.page()?.pageId ?? null;
// The focus names the page it came from, which may not be the one shown.
if (focus.pageId && shown !== focus.pageId && this.pages()[focus.pageId]) {
untracked(() => this.selectPage(focus.pageId!));
return;
}
if (!this.live()) return;
untracked(() => {
if (this.index().map.has(focus.id)) this.reveal(focus.id);
const onPage = !focus.pageId || shown === focus.pageId;
if (onPage && this.index().map.has(focus.id)) this.reveal(focus.id);
else this.announcement.set('That component is no longer on the page.');
this.focusHandled.emit();
});
});
Expand Down
Loading
Loading