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
153 changes: 153 additions & 0 deletions app/src/__tests__/forms-timeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { FormsTimeline } from '../pages/forms-timeline';
import type { FormEvent } from '../pages/forms-types';

function event(seq: number, path: string, type: string, origin = 'user'): FormEvent {
return { formId: 'signup@p1', path, type, origin, seq, timestamp: 1_700_000_000_000 + seq };
}

const EVENTS: FormEvent[] = [
event(1, 'email', 'value'),
event(2, 'email', 'status', 'code'),
event(3, 'address.city', 'value'),
event(4, 'address.zip', 'touched'),
event(5, 'name', 'value', 'agent'),
event(6, '', 'submit'),
];

async function setup(events: FormEvent[]) {
const fixture = TestBed.createComponent(FormsTimeline);
fixture.componentRef.setInput('events', events);
document.body.append(fixture.nativeElement);
await fixture.whenStable();
return fixture;
}

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

function paths(fixture: ComponentFixture<FormsTimeline>): string[] {
return Array.from(root(fixture).querySelectorAll('.events .path')).map(
(el) => el.textContent?.trim() ?? '',
);
}

function total(fixture: ComponentFixture<FormsTimeline>): string {
return root(fixture).querySelector('.total')?.textContent?.trim() ?? '';
}

async function typePath(fixture: ComponentFixture<FormsTimeline>, value: string) {
const input = root(fixture).querySelector<HTMLInputElement>('input[type="search"]')!;
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
return input;
}

afterEach(() => {
document.body.innerHTML = '';
});

describe('FormsTimeline filters', () => {
it('labels the path filter and the event type select', async () => {
const fixture = await setup(EVENTS);
const input = root(fixture).querySelector('input[type="search"]')!;
expect(input.getAttribute('aria-label')).toBe('Filter by field path');
const select = root(fixture).querySelector('button[role="combobox"]')!;
expect(select.getAttribute('aria-label')).toBe('Event type');
expect(total(fixture)).toBe('6 events');
});

it('offers All plus only the types present, in the agent tool order', async () => {
const fixture = await setup(EVENTS);
expect(fixture.componentInstance.typeOptions().map((o) => o.value)).toEqual([
'all',
'value',
'status',
'touched',
'submit',
]);
});

it('matches a path substring, ignoring case, and clears it on Escape', async () => {
const fixture = await setup(EVENTS);
const input = await typePath(fixture, 'ADDRESS');
expect(paths(fixture)).toEqual(['address.zip', 'address.city']);
expect(total(fixture)).toBe('2 of 6 events');

input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await fixture.whenStable();
expect(fixture.componentInstance.pathFilter()).toBe('');
expect(paths(fixture)).toHaveLength(6);
});

it('combines path, type and origin', async () => {
const fixture = await setup(EVENTS);
await typePath(fixture, 'e');
fixture.componentInstance.typeFilter.set('value');
await fixture.whenStable();
expect(paths(fixture)).toEqual(['name', 'address.city', 'email']);

fixture.componentInstance.filter.set('agent');
await fixture.whenStable();
expect(paths(fixture)).toEqual(['name']);
expect(total(fixture)).toBe('1 of 6 events');
});

it('says when the list is cut to the latest 100', async () => {
const many = Array.from({ length: 240 }, (_, i) =>
event(i + 1, i % 2 ? 'email' : 'name', 'value'),
);
const fixture = await setup(many);
expect(root(fixture).querySelectorAll('.events li')).toHaveLength(100);
expect(total(fixture)).toBe('Showing the latest 100 of 240 events');

await typePath(fixture, 'email');
expect(total(fixture)).toBe('Showing the latest 100 of 120 matching events');

fixture.componentInstance.filter.set('code');
await fixture.whenStable();
expect(root(fixture).querySelector('.events')).toBeNull();
});

it('announces the count when a filter changes, not on every new event', async () => {
const fixture = await setup(EVENTS);
const status = () => root(fixture).querySelector('[role="status"]')?.textContent?.trim();
expect(root(fixture).querySelector('.total')?.hasAttribute('aria-live')).toBe(false);
await typePath(fixture, 'name');
const filtered = status();
expect(filtered).toBe(total(fixture));
fixture.componentRef.setInput('events', [...EVENTS, event(7, 'name', 'value')]);
await fixture.whenStable();
expect(total(fixture)).not.toBe(filtered);
expect(status()).toBe(filtered);
});

it('shows Clear filters when the filters hide everything and resets all three', async () => {
const fixture = await setup(EVENTS);
await typePath(fixture, 'nothing');
fixture.componentInstance.typeFilter.set('status');
fixture.componentInstance.filter.set('code');
await fixture.whenStable();
expect(root(fixture).textContent).toContain('No events match these filters');

const clear = Array.from(root(fixture).querySelectorAll('button')).find(
(b) => b.textContent?.trim() === 'Clear filters',
)!;
clear.click();
await fixture.whenStable();
expect(fixture.componentInstance.pathFilter()).toBe('');
expect(fixture.componentInstance.typeFilter()).toBe('all');
expect(fixture.componentInstance.filter()).toBe('all');
expect(paths(fixture)).toHaveLength(6);
expect(document.activeElement).toBe(root(fixture).querySelector('input[type="search"]'));
});

it('keeps the no-events state when nothing was recorded', async () => {
const fixture = await setup([]);
expect(root(fixture).textContent).toContain('No changes yet');
expect(root(fixture).textContent).not.toContain('Clear filters');
});
});
193 changes: 185 additions & 8 deletions app/src/pages/forms-timeline.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { Component, computed, input, output, signal } from '@angular/core';
import {
Component,
computed,
ElementRef,
input,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
import { time } from '../format';
import { Select, type SelectOption } from '../ui/select';
import { FORMS_STYLES, type FormEvent } from './forms-types';

const ORIGINS = ['all', 'user', 'code', 'agent', 'devtools'] as const;

/** How many events the list renders, newest first. */
export const TIMELINE_SHOWN = 100;

/** Event types in the order the `form-history` agent tool lists them. */
const EVENT_TYPES = [
'value',
'status',
'touched',
'dirty',
'submit',
'reset',
'added',
'removed',
'moved',
'validators',
];

@Component({
selector: 'app-forms-timeline',
imports: [Select],
template: `
<div class="toolbar">
<label class="record" [class.on]="recording()">
Expand Down Expand Up @@ -38,6 +66,28 @@ const ORIGINS = ['all', 'user', 'code', 'agent', 'devtools'] as const;
}
</fieldset>
</div>
<div class="filters">
<div class="search">
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
#pathInput
type="search"
placeholder="Filter by field path…"
aria-label="Filter by field path"
autocomplete="off"
spellcheck="false"
[value]="pathFilter()"
(input)="pathFilter.set($any($event.target).value)"
(keydown.escape)="pathFilter.set('')"
/>
</div>
<app-select ariaLabel="Event type" [options]="typeOptions()" [(value)]="typeFilter" />
<span class="total">{{ summary() }}</span>
<span class="sr-only" role="status">{{ filterStatus() }}</span>
</div>
@if (shown().length) {
<ol class="events">
@for (event of shown(); track event.formId + '#' + event.seq) {
Expand Down Expand Up @@ -88,8 +138,9 @@ const ORIGINS = ['all', 'user', 'code', 'agent', 'devtools'] as const;
</ol>
} @else if (events().length) {
<div class="empty-state">
<p class="empty-title">No changes from {{ filter() }}</p>
<p>Pick another source above, or interact with the form to record more.</p>
<p class="empty-title">No events match these filters</p>
<p>Change the path, type or origin, or interact with the form to record more.</p>
<button type="button" class="small" (click)="clearFilters()">Clear filters</button>
</div>
} @else {
<div class="empty-state">
Expand Down Expand Up @@ -232,6 +283,65 @@ const ORIGINS = ['all', 'user', 'code', 'agent', 'devtools'] as const;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
min-width: 0;
}
.search {
position: relative;
flex: 1 1 200px;
min-width: 0;
}
.search-icon {
position: absolute;
top: 50%;
left: 12px;
width: 14px;
height: 14px;
transform: translateY(-50%);
fill: none;
stroke: var(--text-3);
stroke-width: 2.2;
stroke-linecap: round;
pointer-events: none;
}
.search:focus-within .search-icon {
stroke: var(--accent);
}
input[type='search'] {
width: 100%;
height: var(--control-h);
padding: 0 12px 0 34px;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
transition:
border-color 150ms var(--ease),
box-shadow 150ms var(--ease);
}
input[type='search']::placeholder {
color: var(--text-3);
}
input[type='search']:focus-visible {
@include m.field-focus;
}
.filters app-select {
width: 150px;
}
.total {
flex: none;
color: var(--text-2);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.empty-state .small {
margin-top: 6px;
}
.events {
display: grid;
gap: 2px;
Expand Down Expand Up @@ -331,6 +441,13 @@ const ORIGINS = ['all', 'user', 'code', 'agent', 'devtools'] as const;
.chips {
width: 100%;
}
.filters app-select {
flex: 1 1 140px;
width: auto;
}
.total {
flex: 1 1 100%;
}
.chips label {
flex: 1;
justify-content: center;
Expand All @@ -353,14 +470,74 @@ export class FormsTimeline {
readonly origins = ORIGINS;
readonly filter = signal<(typeof ORIGINS)[number]>('all');

readonly shown = computed(() => {
readonly pathFilter = signal('');
readonly typeFilter = signal<string | null>('all');
private readonly pathInput = viewChild<ElementRef<HTMLInputElement>>('pathInput');

/** All types, then the types present in the events in the agent tool's order. */
readonly typeOptions = computed<SelectOption[]>(() => {
const present = new Set(this.events().map((e) => e.type));
const selected = this.typeFilter();
if (selected && selected !== 'all') present.add(selected);
const known = EVENT_TYPES.filter((type) => present.has(type));
const other = [...present].filter((type) => !EVENT_TYPES.includes(type)).sort();
return [
{ value: 'all', label: 'All types' },
...[...known, ...other].map((type) => ({ value: type, label: type })),
];
});

/** Events that pass the path, type and origin filters, oldest first. */
readonly matching = computed(() => {
const origin = this.filter();
return this.events()
.filter((e) => origin === 'all' || e.origin === origin)
.slice(-100)
.reverse();
const type = this.typeFilter() ?? 'all';
const path = this.pathFilter().trim().toLowerCase();
return this.events().filter(
(e) =>
(origin === 'all' || e.origin === origin) &&
(type === 'all' || e.type === type) &&
(!path || e.path.toLowerCase().includes(path)),
);
});

readonly shown = computed(() => this.matching().slice(-TIMELINE_SHOWN).reverse());

readonly filtered = computed(
() =>
this.filter() !== 'all' ||
(this.typeFilter() ?? 'all') !== 'all' ||
!!this.pathFilter().trim(),
);

readonly summary = computed(() => {
const total = this.events().length;
const matching = this.matching().length;
const noun = (n: number) => (n === 1 ? 'event' : 'events');
const scope = this.filtered() ? ` matching ${noun(matching)}` : ` ${noun(matching)}`;
if (matching > TIMELINE_SHOWN)
return `Showing the latest ${TIMELINE_SHOWN} of ${matching}${scope}`;
if (this.filtered()) return `${matching} of ${total} ${noun(total)}`;
return `${total} ${noun(total)}`;
});

/**
* The count, announced only when a filter changes. The visible count also changes with every
* recorded event, which would make a screen reader talk while the user types in the app.
*/
readonly filterStatus = computed(() => {
this.filter();
this.typeFilter();
this.pathFilter();
return untracked(() => this.summary());
});

clearFilters() {
this.pathFilter.set('');
this.typeFilter.set('all');
this.filter.set('all');
this.pathInput()?.nativeElement.focus();
}

iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
Expand Down
Loading
Loading