diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index 1fa00ee9..e6caad2b 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -177,7 +177,7 @@ You can also open a form from its component in the [Components tab](./components ## Agent tools -`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. A literal dot in a control key is written `\.` and a literal backslash `\\`, so the control keyed `a.b` has the path `a\.b`. The panel and the tool output show the same escaped form. +`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. A literal dot in a control key is written `\.` and a literal backslash `\\`, so the control keyed `a.b` has the path `a\.b`. An empty key is written `\e`, so it is never confused with the whole form, whose path is empty. The panel and the tool output show the same escaped form. ### Read tools diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts index aca2972c..df939ecd 100644 --- a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts +++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts @@ -368,3 +368,66 @@ describe('stored-global expressions with backslashes in keys', () => { expect(result.expression).toBe(String.raw`$form['a\\b']`); }); }); + +class EmptyKey { + form = new FormGroup({ + '': new FormControl('blank'), + other: new FormControl('other'), + }); +} +Component({ + selector: 'empty-key-form', + imports: [ReactiveFormsModule], + template: `
`, +})(EmptyKey); + +describe('a control with an empty key at the root', () => { + it('gets a path of its own instead of the root path', async () => { + const fixture = await render(EmptyKey); + const [collected] = collect(fixture.nativeElement); + const [rootPath, emptyPath, otherPath] = paths(collected.root); + expect(rootPath).toBe(''); + expect(emptyPath).not.toBe(''); + expect(otherPath).toBe('other'); + const found = findForms(ngApi(), fixture.nativeElement.querySelectorAll('*')); + const group = fixture.componentInstance.form; + expect(controlPathOf(group as any, group.controls[''] as any)).toBe(emptyPath); + expect(nodeAt(found.forms[0], emptyPath)).toBe(group.controls['']); + }); + + it('marks and sets only that control, not the whole form', async () => { + const fixture = await render(EmptyKey); + const ctx = contextFor(fixture.nativeElement); + const group = fixture.componentInstance.form; + const [, emptyPath] = paths(collect(fixture.nativeElement)[0].root); + + const touched = await runFormAction(ctx, { + action: 'mark-touched', + formId: 'form-1', + path: emptyPath, + }); + expect(touched.ok).toBe(true); + expect(group.controls[''].touched).toBe(true); + expect(group.controls.other.touched).toBe(false); + + const set = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: emptyPath, + value: 'changed', + }); + expect(set.ok).toBe(true); + expect(group.controls[''].value).toBe('changed'); + expect(group.controls.other.value).toBe('other'); + + const stored = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: emptyPath, + }); + expect(stored.expression).toBe("$form.get('')"); + expect((window as any).$control).toBe(group.controls['']); + }); +}); diff --git a/packages/devtools/src/__tests__/forms-path.test.ts b/packages/devtools/src/__tests__/forms-path.test.ts index 7307dfc6..763567c4 100644 --- a/packages/devtools/src/__tests__/forms-path.test.ts +++ b/packages/devtools/src/__tests__/forms-path.test.ts @@ -51,4 +51,14 @@ describe('form control paths', () => { expect(childPath('g', 'a.b')).toBe('g.a\\.b'); expect(childPath('g\\.h', 'c')).toBe('g\\.h.c'); }); + + it('tells a lone empty key apart from the root', () => { + expect(joinPath([''])).not.toBe(''); + expect(splitPath(joinPath(['']))).toEqual(['']); + expect(childPath('', '')).toBe(joinPath([''])); + expect(splitPath(childPath(childPath('', ''), 'a'))).toEqual(['', 'a']); + for (const segments of [[''], ['', ''], ['', '', 'a'], ['a', '', '']]) { + expect(splitPath(joinPath(segments))).toEqual(segments); + } + }); }); diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts index 5ccf568a..71fb426c 100644 --- a/packages/devtools/src/forms-actions.ts +++ b/packages/devtools/src/forms-actions.ts @@ -628,7 +628,7 @@ function expressionFor(found: FoundForm, path: string): string { if (found.kind === 'signal') { return `$form${keys .map((key) => - /^\d+$/.test(key) ? `[${key}]` : /[.\\]/.test(key) ? `[${quote(key)}]` : `.${key}`, + /^\d+$/.test(key) ? `[${key}]` : /^$|[.\\]/.test(key) ? `[${quote(key)}]` : `.${key}`, ) .join('')}`; } diff --git a/packages/devtools/src/forms-path.ts b/packages/devtools/src/forms-path.ts index 5452a12d..aaca8895 100644 --- a/packages/devtools/src/forms-path.ts +++ b/packages/devtools/src/forms-path.ts @@ -1,5 +1,8 @@ +// An empty key is written as \e so a lone one is not mistaken for the root path ''. +const EMPTY_KEY = '\\e'; + function escapeKey(key: string): string { - return key.replace(/[\\.]/g, '\\$&'); + return key ? key.replace(/[\\.]/g, '\\$&') : EMPTY_KEY; } export function joinPath(segments: readonly string[]): string { @@ -16,7 +19,8 @@ export function splitPath(path: string): string[] { let current = ''; for (let i = 0; i < path.length; i++) { const char = path[i]; - if (char === '\\' && i + 1 < path.length) current += path[++i]; + if (char === '\\' && path[i + 1] === 'e') i++; + else if (char === '\\' && i + 1 < path.length) current += path[++i]; else if (char === '.') { segments.push(current); current = '';