Skip to content
59 changes: 37 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,27 +88,38 @@ When embedded in Express, the MCP endpoint is also available over HTTP at `/__ng

MCP clients see these with an underscore, as `ng-devtools_get-routes`.

| Tool | Description |
| ---------------------------------- | ----------------------------------------------------------- |
| `ng-devtools:get-routes` | List Angular routes from source |
| `ng-devtools:get-components` | Discover components and directives, with inputs and outputs |
| `ng-devtools:get-signals` | Signal declarations from source |
| `ng-devtools:get-providers` | DI providers from source |
| `ng-devtools:build-meta` | Angular/TS versions, SSR status |
| `ng-devtools:highlight` | Highlight a component in the page |
| `ng-devtools:inspect-signals` | Signal graph a connected page reported |
| `ng-devtools:inspect-providers` | Injector tree a connected page reported |
| `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns |
| `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors |
| `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why |
| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers |
| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not |
| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection |
| `ng-devtools:lint-routes` | Route config mistakes, with fixes |
| `ng-devtools:router-config` | Router options, features and strategies in effect |
| `ng-devtools:export-navigation` | Markdown repro of a navigation |
| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL |
| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) |
| Tool | Description |
| ------------------------------------ | ----------------------------------------------------------- |
| `ng-devtools:get-routes` | List Angular routes from source |
| `ng-devtools:get-components` | Discover components and directives, with inputs and outputs |
| `ng-devtools:get-signals` | Signal declarations from source |
| `ng-devtools:get-providers` | DI providers from source |
| `ng-devtools:build-meta` | Angular/TS versions, SSR status |
| `ng-devtools:highlight` | Highlight a component in the page |
| `ng-devtools:inspect-signals` | Signal graph a connected page reported |
| `ng-devtools:inspect-providers` | Injector tree a connected page reported |
| `ng-devtools:get-ngrx-store` | Scan source for NgRx store patterns |
| `ng-devtools:inspect-forms` | Forms on the page with every field's state and errors |
| `ng-devtools:explain-form-invalid` | Which fields make a form invalid, and why |
| `ng-devtools:explain-field` | One field: error sources, skip reasons, binding, source |
| `ng-devtools:explain-submit` | What submit will do, and why it might do nothing |
| `ng-devtools:form-payload` | What the form sends: value vs raw value, unvalidated fields |
| `ng-devtools:form-history` | Change timeline with origin (user, code, devtools) |
| `ng-devtools:form-diff` | Net change since a marker |
| `ng-devtools:lint-forms` | Form bugs and model-aware accessibility checks |
| `ng-devtools:explain-custom-control` | How a field is bound, and what is wrong with the binding |
| `ng-devtools:export-form` | JSON snapshot or test fixture |
| `ng-devtools:wait-for-form` | Wait until settled, valid, not pending or submitted |
| `ng-devtools:form-action` | Set, touch, revalidate, reset, submit, focus, snapshot |
| `ng-devtools:fill-form` | Fill several fields through the inputs |
| `ng-devtools:inspect-route` | The current route with params, data, guards and resolvers |
| `ng-devtools:explain-navigation` | Recent navigations and why each succeeded or not |
| `ng-devtools:list-routes` | Live route config; match a URL; audit guard protection |
| `ng-devtools:lint-routes` | Route config mistakes, with fixes |
| `ng-devtools:router-config` | Router options, features and strategies in effect |
| `ng-devtools:export-navigation` | Markdown repro of a navigation |
| `ng-devtools:explain-render-mode` | ServerRoute and render mode for a URL |
| `ng-devtools:navigate` | Navigate, abort, replay, probe, instrument (dev only) |

#### Forms

Expand All @@ -118,8 +129,12 @@ The Forms tab and the forms tools read Signal Forms, reactive forms and template
- `ng-devtools:explain-form-invalid` is the tool to reach for first: without arguments it lists every form that is invalid or waiting on async validation, with each failing field's current value, the validator that failed, its message and whether it was touched. Pass `form` (an id like `form-1`, or part of a label like `SignupComponent`) to explain one form.
- `ng-devtools:inspect-forms` lists the forms with their status and error counts. Pass `form` for a field tree, plus `path` (e.g. `address.city`), `onlyInvalid` or `includeValues: false` to narrow it down.
- Both tools note when the page last reported, so an agent can tell when the data is stale.
- Each error says where it comes from: a validator, a template attribute, a cross-field rule (and on which ancestor), async, parse, a server/submission error, or `setErrors()`. `explain-field` adds why validation is skipped (hidden, disabled, readonly), typed-but-uncommitted values (`updateOn`, `debounce`), stale validity after validator changes, the binding, whether the error text is visible, and the file and line of the form and its rules.
- Agents can loop: inspect, act (`form-action`, `fill-form`), `wait-for-form`, then `form-diff` from the marker they had. Writes need a development build; `reset`, `submit` and `restore` need `confirm: true`.
- The Forms tab has Fields (with filters and per-field actions), Timeline, Submit and Lint views. Pick a field on the page to select it, or open a form from its component in the Components tab.
- Timeline recording (a checkbox in the Timeline view, or `form-action` with `instrument`) adds the calling code of each change, validator changes, async validation times and component renders per keystroke. Array items are tracked by identity, so moves show as moves.

Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, and fields whose name looks secret (password, token, card, cvv and similar) are replaced with `[redacted]`. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost.
Form values leave the page: they are sent to the devtools server, shown in the Forms tab and returned to agents. Values of password fields, fields with a password, one-time-code or credit-card `autocomplete`, fields inside `.sentry-mask`, `.rr-mask`, `[data-private]` or `[data-ng-devtools="mask"]`, and fields whose name contains a secret word (password, token, card, cvv, apiKey and similar) are replaced with `[redacted]`, and those values are also removed from error messages. `[data-ng-devtools="unmask"]` opts a field back in; `window.__NG_DEVTOOLS_FORMS__ = { mask: ['iban'], unmask: ['passport'] }` does the same by key. DevTools never writes secret fields. Other values are sent as they are, so keep real credentials out of forms you inspect, and don't expose the dev server beyond localhost.

#### Router

Expand Down
15 changes: 13 additions & 2 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ type Tab = 'dashboard' | 'components' | 'routes' | 'signals' | 'injectors' | 'st
<app-dashboard [rpc]="rpc()" (navigate)="switchTab($event)" />
}
@case ('components') {
<app-component-tree [rpc]="rpc()" />
<app-component-tree [rpc]="rpc()" (showForm)="showForm($event)" />
}
@case ('routes') {
<app-route-inspector [rpc]="rpc()" />
Expand All @@ -79,7 +79,11 @@ type Tab = 'dashboard' | 'components' | 'routes' | 'signals' | 'injectors' | 'st
<app-store-inspector [rpc]="rpc()" />
}
@case ('forms') {
<app-forms-inspector [rpc]="rpc()" />
<app-forms-inspector
[rpc]="rpc()"
[focus]="formFocus()"
(focusHandled)="formFocus.set(null)"
/>
}
}
</main>
Expand Down Expand Up @@ -199,6 +203,13 @@ export class App implements OnInit, OnDestroy {
// cleanup handled by devframe client
}

formFocus = signal<{ id: string } | null>(null);

showForm(formId: string) {
this.formFocus.set({ id: formId });
this.switchTab('forms');
}

switchTab(id: Tab) {
this.tab.set(id);
history.replaceState(history.state, '', `#tab=${id}`);
Expand Down
31 changes: 30 additions & 1 deletion app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Component, DestroyRef, computed, effect, inject, input, signal } from '@angular/core';
import {
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { JsonPipe } from '@angular/common';
import type { DevframeRpcClient } from 'devframe/client';

Expand Down Expand Up @@ -68,6 +77,11 @@ interface ProviderEntry {
<dt>Standalone</dt>
<dd>{{ comp.isStandalone ? 'Yes' : 'No' }}</dd>
</dl>
@for (form of formsIn(comp.file); track form.formId) {
<button type="button" class="show-form" (click)="showForm.emit(form.formId)">
Show {{ form.label }} in Forms
</button>
}
@if (comp.inputs.length) {
<h4>Inputs</h4>
<ul class="prop-list" role="list">
Expand Down Expand Up @@ -192,6 +206,9 @@ interface ProviderEntry {
.io .label {
color: #71717a;
}
.show-form {
margin: 0 8px 8px 0;
}
.inline-detail {
padding: 0 16px 12px;
border-top: 1px solid #27272a;
Expand Down Expand Up @@ -291,6 +308,8 @@ interface ProviderEntry {
})
export class ComponentTree {
rpc = input<DevframeRpcClient | null>(null);
readonly showForm = output<string>();
formOwners = signal<{ formId: string; label: string; file: string | null }[]>([]);

components = signal<ComponentInfo[]>([]);
allProviders = signal<ProviderEntry[]>([]);
Expand Down Expand Up @@ -367,6 +386,12 @@ export class ComponentTree {
my.rpc.call('get-providers') as Promise<ProviderEntry[]>,
]);
this.components.set(comps);
const owners = (await my.rpc.call('forms-owners').catch(() => [])) as {
formId: string;
label: string;
file: string | null;
}[];
this.formOwners.set(owners ?? []);
this.allProviders.set(providers);
const sel = this.selected();
if (sel) {
Expand All @@ -384,6 +409,10 @@ export class ComponentTree {
}
}

formsIn(file: string) {
return this.formOwners().filter((form) => form.file === file);
}

isSelected(comp: ComponentInfo): boolean {
return this.selected()?.selector === comp.selector;
}
Expand Down
124 changes: 124 additions & 0 deletions app/src/pages/forms-field-detail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Component, computed, effect, input, linkedSignal, signal, untracked } from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import {
FORMS_STYLES,
actionMessage,
formAction,
formsCall,
plain,
type CollectedForm,
type FormFieldNode,
} from './forms-types';

@Component({
selector: 'app-forms-field-detail',
template: `
<h3>{{ node().path || '(form)' }}</h3>
<pre class="explain">{{ text() || 'Loading…' }}</pre>
<div class="row">
@if (node().type === 'control' && !node().redacted) {
<label class="sr-only" for="field-value">New value for {{ node().path }}</label>
<input
id="field-value"
class="field-input"
type="text"
placeholder="New value (JSON or text)"
[value]="draft()"
(input)="draft.set($any($event.target).value)"
(keydown.enter)="setValue()"
/>
<button type="button" class="small" (click)="setValue()">Set</button>
}
<button type="button" class="small" (click)="act('focus')">Focus</button>
<button type="button" class="small" (click)="act('mark-touched')">Touch</button>
<button type="button" class="small" (click)="act('revalidate')">Revalidate</button>
<button type="button" class="small" (click)="act('store-as-global')">Store as global</button>
</div>
<p class="status" role="status">{{ message() }}</p>
`,
styles: `
${FORMS_STYLES}
:host {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #3f3f46;
border-radius: 8px;
}
h3 {
margin: 0;
color: #e4e4e7;
font-size: 14px;
font-family: ui-monospace, monospace;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
`,
})
export class FormsFieldDetail {
form = input.required<CollectedForm>();
node = input.required<FormFieldNode>();
version = input(0);
rpc = input<DevframeRpcClient | null>(null);

readonly text = signal('');
private readonly target = computed(() => `${this.form().id}|${this.node().path}`);
readonly draft = linkedSignal({ source: this.target, computation: () => '' });
readonly message = linkedSignal({ source: this.target, computation: () => '' });

constructor() {
effect(() => {
const form = this.form().id;
const path = this.node().path;
this.version();
const client = this.rpc();
untracked(() => this.load(client, form, path));
});
}

private async load(client: DevframeRpcClient | null, form: string, path: string) {
const text = await formsCall<string>(client, 'forms-explain', { kind: 'field', form, path });
if (this.form().id === form && this.node().path === path) this.text.set(plain(text));
}

async act(action: string) {
const result = await formAction(this.rpc(), {
action,
formId: this.form().id,
path: this.node().path,
});
this.message.set(
result.expression ? `${actionMessage(result)} ${result.expression}` : actionMessage(result),
);
}

async setValue() {
const raw = this.draft();
let value: unknown = raw;
try {
value = JSON.parse(raw);
} catch {
value = raw;
}
const result = await formAction(this.rpc(), {
action: 'set-value',
formId: this.form().id,
path: this.node().path,
value,
mode: 'user',
});
this.message.set(actionMessage(result));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading