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
64 changes: 64 additions & 0 deletions app/src/__tests__/forms-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,70 @@ describe('FormsInspector fields', () => {
});
});

describe('FormsInspector field filters', () => {
async function mounted() {
const fixture = TestBed.createComponent(FormsInspector);
fixture.componentRef.setInput(
'rpc',
fakeClient(() => Promise.resolve(''), [form]),
);
document.body.append(fixture.nativeElement);
await settle(fixture);
return fixture;
}

function search(fixture: ComponentFixture<FormsInspector>): HTMLInputElement {
return (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>(
'input.filter[type="search"]',
)!;
}

function status(fixture: ComponentFixture<FormsInspector>): string {
const region = (fixture.nativeElement as HTMLElement).querySelector('.panel p[role="status"]');
return region?.textContent?.trim() ?? '';
}

it('offers Clear filters when the search and chips hide every field, and resets both', async () => {
const fixture = await mounted();
const input = search(fixture);
input.value = 'nothing';
input.dispatchEvent(new Event('input'));
fixture.componentInstance.active.set(new Set(['dirty']));
await settle(fixture);
expect(text(fixture)).toContain('No field path matches "nothing".');
expect(status(fixture)).toBe('');

button(fixture, 'Clear filters').click();
await settle(fixture);
expect(fixture.componentInstance.filter()).toBe('');
expect(fixture.componentInstance.active().size).toBe(0);
expect(search(fixture).value).toBe('');
expect(document.activeElement).toBe(search(fixture));
expect(button(fixture, 'Show details for email')).toBeDefined();
expect(status(fixture)).toBe('Filters cleared. Showing 3 fields.');
});

it('offers Clear filters when only a chip hides every field', async () => {
const fixture = await mounted();
const dirty = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLLabelElement>('.chips label'),
).find((label) => label.textContent?.trim() === 'Dirty')!;
dirty.querySelector('input')!.click();
await settle(fixture);
expect(text(fixture)).toContain('No field matches the selected filters.');

button(fixture, 'Clear filters').click();
await settle(fixture);
expect(fixture.componentInstance.active().size).toBe(0);
expect(dirty.querySelector('input')!.checked).toBe(false);
});

it('shows no Clear filters while no filter is set', async () => {
const fixture = await mounted();
expect(() => button(fixture, 'Clear filters')).toThrow();
});
});

describe('FormsInspector status pills', () => {
// Backgrounds of the stylesheet rules that apply to an element, in source order.
function backgrounds(element: Element): string[] {
Expand Down
99 changes: 97 additions & 2 deletions app/src/__tests__/router-panels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,13 @@ describe('RouteTree page switch', () => {
field.dispatchEvent(new Event('input'));
}

it('keeps one empty row status region in the page before any row reports', async () => {
it('keeps the row and filter status regions empty before anything reports', async () => {
const fixture = mount(RouteTree, pageA, offline);
await settle(fixture);
expect(liveRegion(fixture).textContent?.trim()).toBe('');
expect(el(fixture).querySelectorAll('[role="status"]')).toHaveLength(1);
const regions = el(fixture).querySelectorAll('[role="status"]');
expect(regions).toHaveLength(2);
expect(Array.from(regions).map((r) => r.textContent?.trim())).toEqual(['', '']);
});

it("drops page A's results and typed params after switching to page B", async () => {
Expand Down Expand Up @@ -250,6 +252,99 @@ describe('RouteTree page switch', () => {
});
});

describe('RouteTree filter count', () => {
const routes: RouteNode[] = [
{
id: 'r1',
path: 'users',
fullPath: 'users',
kind: 'component',
component: 'Users',
children: [
{ id: 'r2', path: ':id', fullPath: 'users/:id', kind: 'component', component: 'User' },
],
},
{ id: 'r3', path: 'about', fullPath: 'about', kind: 'component', component: 'About' },
];

function filterInput(fixture: ComponentFixture<unknown>): HTMLInputElement {
return el(fixture).querySelector<HTMLInputElement>('input.filter')!;
}

function type(fixture: ComponentFixture<unknown>, value: string) {
const input = filterInput(fixture);
input.value = value;
input.dispatchEvent(new Event('input'));
}

function summary(fixture: ComponentFixture<unknown>): string {
return el(fixture).querySelector('.summary')?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
}

function filterStatus(fixture: ComponentFixture<unknown>): string {
const regions = el(fixture).querySelectorAll('p.visually-hidden[role="status"]');
return regions[1]?.textContent?.trim() ?? '';
}

it('shows "N of M routes" while a search filters the tree and says it once', async () => {
const fixture = mount(RouteTree, page({ config: routes }), offline);
await settle(fixture);
expect(summary(fixture)).toContain('3 route(s).');
expect(el(fixture).querySelector('.summary .count')).toBeNull();

type(fixture, 'users');
await settle(fixture);
expect(el(fixture).querySelector('.summary .count')?.textContent).toBe('2 of 3 routes');
expect(filterStatus(fixture)).toBe('2 of 3 routes match.');

type(fixture, 'zzz');
await settle(fixture);
expect(el(fixture).querySelector('.summary .count')?.textContent).toBe('0 of 3 routes');
expect(filterStatus(fixture)).toBe('No routes match.');
});

it('speaks again when a new query has the same count', async () => {
const fixture = mount(RouteTree, page({ config: routes }), offline);
await settle(fixture);
const raw = () =>
el(fixture).querySelectorAll('p.visually-hidden[role="status"]')[1]?.textContent ?? '';
type(fixture, 'about');
await settle(fixture);
const first = raw();
type(fixture, 'abou');
await settle(fixture);
expect(filterStatus(fixture)).toBe('1 of 3 routes match.');
expect(raw()).not.toBe(first);
});

it('updates the count on a poll without speaking', async () => {
const fixture = mount(RouteTree, page({ config: routes }), offline);
await settle(fixture);
type(fixture, 'about');
await settle(fixture);
expect(filterStatus(fixture)).toBe('1 of 3 routes match.');

const more: RouteNode = { id: 'r4', path: 'contact', fullPath: 'contact', kind: 'component' };
fixture.componentRef.setInput('page', page({ config: [...routes, more], reportedAt: 2 }));
await settle(fixture);
expect(el(fixture).querySelector('.summary .count')?.textContent).toBe('1 of 4 routes');
expect(filterStatus(fixture)).toBe('1 of 3 routes match.');
});

it('clears the filter and the spoken count from the empty state', async () => {
const fixture = mount(RouteTree, page({ config: routes }), offline);
await settle(fixture);
type(fixture, 'zzz');
await settle(fixture);
button(fixture, 'Clear filter').click();
await settle(fixture);
expect(filterInput(fixture).value).toBe('');
expect(document.activeElement).toBe(filterInput(fixture));
expect(summary(fixture)).toContain('3 route(s).');
expect(filterStatus(fixture)).toBe('');
});
});

describe('RouteTree edge cases', () => {
async function submitUrl(fixture: ComponentFixture<unknown>, url: string) {
const input = el(fixture).querySelector<HTMLInputElement>('#test-url')!;
Expand Down
41 changes: 37 additions & 4 deletions app/src/pages/forms-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ function countFields(node: FormFieldNode): number {
@case ('fields') {
<div class="filters">
<input
#fieldFilter
class="filter"
type="search"
placeholder="Filter fields by path"
Expand All @@ -376,6 +377,7 @@ function countFields(node: FormFieldNode): number {
}
</fieldset>
</div>
<p class="sr-only" role="status">{{ filterStatus() }}</p>

<div class="table-scroll" role="region" aria-label="Fields" tabindex="0">
<table class="fields">
Expand Down Expand Up @@ -532,10 +534,21 @@ function countFields(node: FormFieldNode): number {
} @empty {
<tr>
<td colspan="5" class="no-match">
@if (filter()) {
No field path matches "{{ filter() }}".
} @else {
No field matches the selected filters.
<p>
@if (filter()) {
No field path matches "{{ filter() }}".
} @else {
No field matches the selected filters.
}
</p>
@if (filtering()) {
<button
type="button"
class="small"
(click)="clearFilters(fieldFilter)"
>
Clear filters
</button>
}
</td>
</tr>
Expand Down Expand Up @@ -1228,6 +1241,12 @@ function countFields(node: FormFieldNode): number {
color: var(--text-2);
text-align: center;
}
.fields .no-match p {
margin: 0 0 10px;
}
.fields .no-match p:last-child {
margin-bottom: 0;
}
.empty {
display: grid;
justify-items: center;
Expand Down Expand Up @@ -1312,6 +1331,10 @@ export class FormsInspector {
readonly chips = CHIPS;
readonly tab_ = signal<Tab>('fields');
readonly active = signal(new Set<Chip>());
/** True while the path search or a chip hides fields, the same test `rows` applies. */
readonly filtering = computed(() => !!this.filter() || this.active().size > 0);
/** Spoken only after Clear filters, never on a poll. */
readonly filterStatus = signal('');
readonly version = signal(0);
readonly message = signal('');

Expand Down Expand Up @@ -1607,6 +1630,16 @@ export class FormsInspector {
return error.from !== undefined ? `${label} on ${error.from || 'the form'}` : label;
}

/** Resets the path search and the chips, then hands focus back to the search field. */
clearFilters(search: HTMLInputElement) {
this.filter.set('');
this.active.set(new Set());
const count = this.rows().length;
const message = `Filters cleared. Showing ${count} ${count === 1 ? 'field' : 'fields'}.`;
this.filterStatus.update((current) => (current === message ? `${message}\u00a0` : message));
search.focus();
}

onFilter(event: Event) {
this.filter.set((event.target as HTMLInputElement).value);
}
Expand Down
53 changes: 48 additions & 5 deletions app/src/pages/route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
type SourceRoute,
} from './router-types';

/** "3 of 12 routes": the filtered count, worded like the other panel pages. */
export function routeCountText(matches: number, total: number): string {
return `${matches} of ${total} ${total === 1 ? 'route' : 'routes'}`;
}

interface NodeRow {
node: RouteNode;
depth: number;
Expand Down Expand Up @@ -109,20 +114,27 @@ interface TestedUrl {
aria-label="Filter routes"
placeholder="Filter by path, component or file"
[value]="filter()"
(input)="filter.set($any($event.target).value)"
(keydown.escape)="filter.set('')"
(input)="setFilter($any($event.target).value)"
(keydown.escape)="setFilter('')"
/>
@if (page().config) {
<p class="muted summary">
Generation {{ page().generation }} · {{ rows().length }} route(s). Lazy routes show their
children once loaded.
Generation {{ page().generation }} ·
@if (filter()) {
<span class="count">{{ countText() }}</span
>.
} @else {
{{ rows().length }} route(s).
}
Lazy routes show their children once loaded.
@if (page().configTruncated; as left) {
{{ left }} route(s) left out: the page lists at most 200 routes per level and 1000 in
total.
}
</p>
}
</div>
<p class="visually-hidden" role="status">{{ filterAnnouncement() }}</p>
@if (!page().config) {
<div class="empty">
<p class="empty-title">
Expand All @@ -144,7 +156,7 @@ interface TestedUrl {
<div class="empty">
<p class="empty-title">No routes match the filter.</p>
<p class="muted">Try a shorter path or a component name.</p>
<button type="button" class="small" (click)="filter.set(''); filterInput.focus()">
<button type="button" class="small" (click)="setFilter(''); filterInput.focus()">
Clear filter
</button>
</div>
Expand Down Expand Up @@ -364,6 +376,10 @@ interface TestedUrl {
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.summary .count {
color: var(--text);
font-weight: 600;
}
td {
vertical-align: middle;
}
Expand Down Expand Up @@ -454,7 +470,13 @@ export class RouteTree {
source: this.pageId,
computation: () => null,
});
/** Spoken only from the filter input, so a poll that changes the routes stays quiet. */
readonly filterAnnouncement = linkedSignal<string, string>({
source: this.pageId,
computation: () => '',
});
private predictSeq = 0;
private announceFlip = false;
private probeSeq = 0;
private readonly urlInput = viewChild<ElementRef<HTMLInputElement>>('urlInput');

Expand Down Expand Up @@ -483,6 +505,27 @@ export class RouteTree {
return rows;
});

readonly total = computed(() => {
const count = (nodes: RouteNode[]): number =>
nodes.reduce((sum, node) => sum + 1 + count(node.children ?? []), 0);
return count(this.page().config ?? []);
});

readonly countText = computed(() => routeCountText(this.rows().length, this.total()));

setFilter(value: string) {
this.filter.set(value);
const matches = this.rows().length;
const text = !value
? ''
: matches
? `${routeCountText(matches, this.total())} match.`
: 'No routes match.';
// A new query with the same count still changes the text, so screen readers say it again.
this.announceFlip = !this.announceFlip;
this.filterAnnouncement.set(text && (this.announceFlip ? `${text}\u00a0` : text));
}

hasKeys(value: Record<string, unknown>) {
return Object.keys(value).length > 0;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Each field shows its value, status, touched and dirty state, and errors. Extra f
- **Signal Forms**: constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`), `required`, `readonly` and `hidden`, a pending `debounce`, and disabled reasons.
- **Reactive and template-driven**: whether validators and async validators are attached, the value `reset()` goes back to, `updateOn`, and the bound `ControlValueAccessor`.

Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. Hover a field to highlight its input in the page.
Filter by path, or with the **Invalid**, **Dirty**, **Touched**, **Disabled** and **Error not shown** chips. If the filters hide every field, click **Clear filters** to reset the path filter and the chips. Focus returns to the path filter. Hover a field to highlight its input in the page.

### Error sources

Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/content/inspectors/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Each navigation in a loop gets a **loop** badge and a red edge. Its details gain

The live route config. The tab merges lazy children in once they load, and marks the active branch.

- Filter the routes by path, component or declaring file. While the filter is set, the summary shows the count, for example "2 of 14 routes", and a screen reader announces it as you type. If nothing matches, click **Clear filter**.
- **Test a URL** and click **Predict** to see which route matches it, or the nearest ones.
- **Probe in app** runs the real matcher without navigating.
- Fill in the params of a route and click **Go** to navigate to it. With a param left empty, **Go** marks the empty field and names the params to fill in.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading