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
66 changes: 66 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,72 @@ describe('FormsInspector status pills', () => {
});
});

describe('FormsInspector error summary', () => {
it('opens the field of an entry on the Fields tab, clearing a filter that hides it', async () => {
const invalid: CollectedForm = {
...form,
root: {
...form.root,
children: [
field('email'),
field('name', { status: 'INVALID', errors: [{ kind: 'required', message: 'required' }] }),
],
},
errorSummary: [{ path: 'name', kind: 'required', message: 'is required' }],
};
const fixture = TestBed.createComponent(FormsInspector);
fixture.componentRef.setInput(
'rpc',
fakeClient(() => Promise.resolve('about name'), [invalid]),
);
document.body.append(fixture.nativeElement);
await settle(fixture);
const host = fixture.nativeElement as HTMLElement;
const inspector = fixture.componentInstance;
inspector.filter.set('email');
inspector.active.set(new Set(['dirty']));
inspector.tab_.set('timeline');
await settle(fixture);

const entry = Array.from(host.querySelectorAll<HTMLButtonElement>('.summary-link')).find(
(b) => b.textContent?.replace(/\s+/g, ' ').trim() === 'Show field name: is required required',
);
expect(entry).toBeDefined();
entry!.click();
await settle(fixture);

expect(inspector.tab_()).toBe('fields');
expect(inspector.filter()).toBe('');
expect(inspector.active().size).toBe(0);
expect(inspector.fieldPath()).toBe('name');
expect(button(fixture, 'Show details for name').getAttribute('aria-pressed')).toBe('true');
expect(host.querySelector('app-forms-field-detail')).not.toBeNull();
expect(document.activeElement?.id).toBe('forms-field-heading');
});

it('keeps a filter that already shows the field', async () => {
const fixture = TestBed.createComponent(FormsInspector);
fixture.componentRef.setInput(
'rpc',
fakeClient(
() => Promise.resolve(''),
[{ ...form, errorSummary: [{ path: 'name', kind: 'required', message: 'is required' }] }],
),
);
document.body.append(fixture.nativeElement);
await settle(fixture);
const inspector = fixture.componentInstance;
inspector.filter.set('nam');
await settle(fixture);
(fixture.nativeElement as HTMLElement)
.querySelector<HTMLButtonElement>('.summary-link')!
.click();
await settle(fixture);
expect(inspector.filter()).toBe('nam');
expect(inspector.fieldPath()).toBe('name');
});
});

describe('FormsInspector pick', () => {
it('turns into Cancel picking and cancels from the button and Escape', async () => {
const calls: Record<string, unknown>[] = [];
Expand Down
55 changes: 53 additions & 2 deletions app/src/pages/forms-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
Component,
DestroyRef,
ElementRef,
Injector,
afterNextRender,
computed,
effect,
inject,
Expand Down Expand Up @@ -233,8 +235,12 @@ function countFields(node: FormFieldNode): number {
<ul>
@for (entry of form.errorSummary!; track $index) {
<li>
<code>{{ entry.path || '(form)' }}</code> {{ entry.message }}
<code class="kind-tag">{{ entry.kind }}</code>
<button type="button" class="summary-link" (click)="showField(entry.path)">
<span class="sr-only">Show field </span
><code>{{ entry.path || '(form)' }}</code
><span class="sr-only">:</span> {{ entry.message }}
<code class="kind-tag">{{ entry.kind }}</code>
</button>
</li>
}
</ul>
Expand Down Expand Up @@ -809,6 +815,29 @@ function countFields(node: FormFieldNode): number {
color: var(--text);
overflow-wrap: anywhere;
}
.summary-link {
display: inline;
padding: 0;
border: none;
border-radius: 4px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.summary-link code:first-of-type {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.summary-link:hover code:first-of-type {
color: var(--accent-hover);
}
.summary-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.error-summary .kind-tag {
margin-left: 6px;
color: var(--text-2);
Expand Down Expand Up @@ -1313,6 +1342,7 @@ export class FormsInspector {
private unsubscribe: (() => void) | null = null;
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);

readonly counts = computed(
() =>
Expand Down Expand Up @@ -1526,6 +1556,27 @@ export class FormsInspector {
row?.focus();
}

/** Opens a field from the error summary: Fields tab, filters that hide it cleared, detail focused. */
showField(path: string) {
this.tab_.set('fields');
const listed = () => this.rows().some((row) => row.node.path === path);
if (!listed()) this.filter.set('');
if (!listed()) this.active.set(new Set());
this.fieldPath.set(path);
afterNextRender(
() => {
const host = this.host.nativeElement;
const row = host.querySelector<HTMLElement>('button.field[aria-pressed="true"]');
row?.scrollIntoView?.({ block: 'nearest' });
const target =
host.querySelector<HTMLElement>('#forms-field-heading') ??
host.querySelector<HTMLElement>('.table-scroll');
target?.focus();
},
{ injector: this.injector },
);
}

toggleChip(chip: Chip) {
this.active.update((set) => {
const next = new Set(set);
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/inspectors/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The sidebar lists each form with its label, its kind (**Signal Forms**, **Reacti

Select a form to see its status, whether it is dirty or touched, whether it was submitted or is submitting, and an **Error summary**.

Click an entry in the **Error summary** to open that field. The tab switches to the **Fields** view, opens the field's [details](#field-details) and moves focus to them. If the path filter or the chips hide the field, the tab clears them, the filter first and the chips only if the field is still hidden.

### WebMCP tool

A Signal Form that sets `experimentalWebMcpTool` (with `provideExperimentalWebMcpForms()`) shows a **WebMCP tool** block in its details. See [WebMCP](https://angular.dev/ai/webmcp) for the API itself.
Expand Down

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

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extension/ui/index.html

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

Loading