Geography facts admin: editing a fact with no source crashes the edit form (TypeError: this.field(...) is not a function) #902

Open
opened 2026-07-30 04:36:52 +00:00 by spikerj · 0 comments
Owner

Found while adding render coverage for geography-facts-management.component.html (wave-5 coverage loop, angular branch test/component-coverage-wave5).

Symptom

Admin → Geography facts → pick a country → Edit on any fact whose source is empty throws during render and the inline edit form fails to appear:

TypeError: this.field(...) is not a function
  at FormField.state (@angular/forms/fesm2022/signals.mjs:1250)
  at FormField.ɵngControlUpdate (@angular/forms/fesm2022/signals.mjs:1113)
  at _GeographyFactsManagementComponent_ng_container_0_Conditional_2_Conditional_42_For_2_Conditional_2_Template

Editing a fact that does have a source works normally.

Root cause

toggleEdit() (geography-facts-management.component.ts:215) seeds the edit draft with undefined when the fact has no source:

this.editDraft.set({
  title: fact.title,
  content: fact.content,
  source: fact.source ?? undefined,   // <-- fact.source is `string | null`
});

The draft is a Partial<SubmitFactRequest> behind a Signal Forms FieldTree:

editDraft = signal<Partial<SubmitFactRequest>>({});
protected readonly editDraftForm = form(this.editDraft);

and the template binds [formField]="editDraftForm.source" (line 271). When the underlying value for that path is undefined, the FieldTree node is not a callable field, so ɵngControlUpdatethis.field(...) throws.

Confirmed with a three-way probe against the real component (map child stubbed so the template could mount at all):

draft source result
"USGS" (string) renders fine
null?? undefined TypeError
key omitted entirely TypeError

So it is the undefined value, not the null specifically — the same crash occurs for any Partial key that is missing.

GeographicFactDto.source is string | null, and the create form treats source as optional, so facts with no source are the normal case — this is not an edge case.

Impact

Admins cannot edit any fact that was submitted without a source URL. The edit panel simply fails to render (the click appears to do nothing, with an error in the console).

Fix sketch

Two options, both small:

  1. Seed empty strings instead of undefinedsource: fact.source ?? "". Matches what the input expects and keeps the FieldTree fully populated. Check saveEdit() so an empty string is normalised back to null/omitted on submit rather than persisting "".
  2. Make the draft a full (non-Partial) model with an EMPTY_EDIT_FACT() factory, mirroring how the create form already does it with EMPTY_CREATE_FACT(). This is the more robust fix — a Partial model behind form() is a latent trap for every optional key, not just source.

Option 2 matches the existing pattern in this same component and the .cursor/rules/signal-forms.md guidance (model signal + schema validators).

Coverage note

geography-facts-management.render.spec.ts contains a characterization test naming this ticket which asserts the current throwing behavior, plus normal-path edit tests that pass a non-null source. Update the characterization test as part of the fix — it is written to fail once editing a source-less fact works.

Found while adding render coverage for `geography-facts-management.component.html` (wave-5 coverage loop, angular branch `test/component-coverage-wave5`). ## Symptom **Admin → Geography facts → pick a country → Edit** on any fact whose **source is empty** throws during render and the inline edit form fails to appear: ``` TypeError: this.field(...) is not a function at FormField.state (@angular/forms/fesm2022/signals.mjs:1250) at FormField.ɵngControlUpdate (@angular/forms/fesm2022/signals.mjs:1113) at _GeographyFactsManagementComponent_ng_container_0_Conditional_2_Conditional_42_For_2_Conditional_2_Template ``` Editing a fact that **does** have a source works normally. ## Root cause `toggleEdit()` (`geography-facts-management.component.ts:215`) seeds the edit draft with `undefined` when the fact has no source: ```ts this.editDraft.set({ title: fact.title, content: fact.content, source: fact.source ?? undefined, // <-- fact.source is `string | null` }); ``` The draft is a `Partial<SubmitFactRequest>` behind a Signal Forms FieldTree: ```ts editDraft = signal<Partial<SubmitFactRequest>>({}); protected readonly editDraftForm = form(this.editDraft); ``` and the template binds `[formField]="editDraftForm.source"` (line 271). When the underlying value for that path is `undefined`, the FieldTree node is not a callable field, so `ɵngControlUpdate` → `this.field(...)` throws. Confirmed with a three-way probe against the real component (map child stubbed so the template could mount at all): | draft `source` | result | | --- | --- | | `"USGS"` (string) | renders fine | | `null` → `?? undefined` | **TypeError** | | key omitted entirely | **TypeError** | So it is the `undefined` value, not the `null` specifically — the same crash occurs for any `Partial` key that is missing. `GeographicFactDto.source` is `string | null`, and the create form treats source as optional, so **facts with no source are the normal case** — this is not an edge case. ## Impact Admins cannot edit any fact that was submitted without a source URL. The edit panel simply fails to render (the click appears to do nothing, with an error in the console). ## Fix sketch Two options, both small: 1. **Seed empty strings instead of `undefined`** — `source: fact.source ?? ""`. Matches what the input expects and keeps the FieldTree fully populated. Check `saveEdit()` so an empty string is normalised back to `null`/omitted on submit rather than persisting `""`. 2. **Make the draft a full (non-`Partial`) model with an `EMPTY_EDIT_FACT()` factory**, mirroring how the create form already does it with `EMPTY_CREATE_FACT()`. This is the more robust fix — a `Partial` model behind `form()` is a latent trap for every optional key, not just `source`. Option 2 matches the existing pattern in this same component and the `.cursor/rules/signal-forms.md` guidance (model signal + schema validators). ## Coverage note `geography-facts-management.render.spec.ts` contains a characterization test naming this ticket which asserts the current throwing behavior, plus normal-path edit tests that pass a non-null source. **Update the characterization test as part of the fix** — it is written to fail once editing a source-less fact works.
Sign in to join this conversation.