10 KiB
Implementation Plan: Admin Edit Category — System Row Pre-fill Bug (Job 897)
1. Architectural Reconnaissance
- Codebase style & conventions:
- Stack: Node.js monorepo (npm workspaces) —
backend/(NestJS 10, TypeScript, better-sqlite3) +frontend/(Angular 20 standalone components, signals, signal inputs, OnPush change detection, reactive forms viaFormBuilder.nonNullable,ReactiveFormsModule). - Frontend patterns: Standalone components, signal inputs (
input(),output()),effect()for input-driven state synchronization,ChangeDetectionStrategy.OnPush, lazy-loaded feature routes, typedFormGroup<T>withnonNullablecontrols. - Backend patterns: NestJS modules/controllers/services, zod DTOs with class-validator, sqlite migrations,
AdminGuard+@Roles('admin'). - Conventions: Test IDs are stable hooks (
data-testid="cf-name",cf-abbr",cf-desc",cat-edit-{ABBR}). Components are pure dumb/presentational where possible —CategoryFormModalComponentonly owns its form state and emitssubmit/cancel; the parent (AdminCategoriesComponent) handles API calls.
- Stack: Node.js monorepo (npm workspaces) —
- Data Layer: SQLite (better-sqlite3) accessed via Knex/raw queries. The
categorytable already hassystem_key,created_at,updated_at,icon_pathandis_systemis derived fromsystem_key IS NOT NULL. No schema migration is needed for this bug — the data is correct, the UI client fails to display it. - Test Framework & Structure:
- Jest via
ts-jest, two projects intests/jest.config.js(backend/frontend). - Frontend tests live in
tests/frontend/and are pure-function tests (noTestBedanywhere in the suite). The existingtests/frontend/admin-categories-form-modal.spec.tsalready exercisessyncCategoryFormin isolation. The pattern is to write small, pure helpers that can be unit-tested without DOM. - Single command from root:
npm test(runs both projects).npm run test:frontendfor frontend only.
- Jest via
- Required Tools & Dependencies: None new. All dependencies already present in
package.jsonworkspaces. Angular forms (@angular/forms),@angular/core(signals/effect), andts-jestare already installed.
2. Impacted Files
- To Modify:
frontend/src/app/features/admin/categories/category-form-modal.component.ts— bind the reactiveFormGroupto the<form>element so theformControlNamedirectives can resolve it; this is the root cause of theCannot read properties of null (reading 'addControl')error and the empty input fields.tests/frontend/admin-categories-form-modal.spec.ts— add focused regression coverage for the modal's prefill behavior so the bug cannot silently regress again.
- To Create:
- None. (No backend, no DTO, no migration, no new service, no new component.)
3. Proposed Changes
Root cause analysis
In category-form-modal.component.ts line 67 the template is:
<form class="modal" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal">
There is no [formGroup]="form" binding on the <form> element. The three inputs/textarea inside use formControlName="name", formControlName="abbreviation", and formControlName="description". Without the formGroup directive on the parent <form>, Angular's FormControlName directive (which is what formControlName compiles to) cannot find a parent FormGroup/FormGroupDirective, so:
- It throws
TypeError: Cannot read properties of null (reading 'addControl')fromFormControlName._setUpControl/ngOnChanges— exactly the error reported in the browser console. - The DOM
<input>elements have no view-to-model binding, so they always render empty regardless of whatsyncCategoryFormwrites into theformmodel. - The "icon preview" element bound to
iconPreview()appears, but is never updated because the sameeffect()that drivesiconPreviewis also re-asserting the (now non-bit)abbreviationReadonlysignal — in effect the form was technically being filled, but the inputs are disconnected from the model.
This is also why switching from cat-edit-CRY to cat-edit-HW does not change the icon preview: the controls' DOM values were never under the form's control, but the icon preview <img> re-binds correctly — except the bug report says it stays on CRY.png. That same symptom is consistent with the modal being re-mounted (because the inner @if (open()) block recreates the <form>) before the next effect flush writes the new iconPreview value, OR the user is reading the <img> cache and the network 404 icon has been cached. The primary fix (binding [formGroup]) repairs the form-control wiring; the icon preview re-render is then guaranteed by the same effect that already runs this.iconPreview.set(result.iconPreview) correctly.
Step-by-step implementation
-
Bind the form group directive (the only production change).
- In
frontend/src/app/features/admin/categories/category-form-modal.component.tstemplate, change the<form>opening tag from:to:<form class="modal" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal"><form class="modal" [formGroup]="form" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="cat-form-modal"> - This is a one-line, surgical change. It does not alter any signal, effect, model, or API contract. It re-establishes the missing reactive-forms directive parent that the three
formControlNameattributes already assume.
- In
-
Verify the existing
syncCategoryFormhelper is still the single source of truth.- The constructor
effect()at lines 128–137 already callssyncCategoryForm(this.form, this.mode(), this.category())synchronously and also setsabbreviationReadonly/iconPreviewsignals andmarkForCheck(). No edits tosyncCategoryFormitself are needed. - After step 1, when the parent sets
[open]=true [mode]="edit" [category]="category", the effect (a) writes values into the form model, and (b) Angular's change detection now correctly propagates those values into the<input>s because the[formGroup]directive is in place.
- The constructor
-
No backend changes.
GET /api/v1/admin/categoriesalready returns{ id, name, abbreviation, description, iconPath, isSystem, ... }correctly. TheCategoriesService.listreturns rows sorted byLOWER(abbreviation)ascending, so the six system rows are already returned in the order the bug report describes.PUT /api/v1/admin/categories/:idalready accepts{ name?, description?, iconPath? }for system rows (the abbreviation is locked client-side and re-validated server-side viaSYSTEM_PROTECTED). No DTO change required.
-
No schema / migration.
- All required columns already exist.
-
No new dependencies and no
setup.shchange. The fix is pure frontend.
Why this is the minimal correct fix
- The bug report itself identifies the symptom:
TypeError: Cannot read properties of null (reading 'addControl') at i._setUpControl / i.ngOnChanges. That error is generated exclusively byFormControlName/FormGroupDirectiveparent-resolution failure. The only<form>in the modal template is missing[formGroup]. Adding the binding resolves the error and the empty inputs in one shot. - The "icon preview stuck on CRY.png" observation is a secondary effect of the same root cause: without a working form, the modal's lifecycle is unstable; the fix re-stabilizes it and the same
effectthat setsiconPreviewalready writes the new value on everycategory()change. - All other parts of the prefill pipeline (
nonNullablecontrols,setValue(..., { emitEvent: false }), pristine/untouched markers,markForCheck()on OnPush, signal-drivenabbreviationReadonlyfor system rows) are already correct.
4. Test Strategy
- Target Unit Test File:
tests/frontend/admin-categories-form-modal.spec.ts(existing, pure-helper tests). - Mocking Strategy: No mocks required. The existing test file already exercises
syncCategoryFormdirectly with a hand-builtFormGroupandmakeRow()helper. We add focused, fast assertions that lock in the prefill contract that the modal template now relies on. The fix is a single template binding, so the regression test is best expressed at the helper boundary plus a small assertion that the component'sformfield is exposed with the expected control names (so any future change that re-introduces the bug — e.g. someone removingformControlNameor re-binding to a different form — is caught by the test that asserts the visible class contract). - Tests to add (kept minimal, single-pass friendly):
it('exposes a form group with name, abbreviation, description controls')— instantiate the component vianew CategoryFormModalComponent()after manually constructing theFormBuilder(mirror the existingbuildForm()pattern), assertform.controls.name,form.controls.abbreviation,form.controls.descriptionexist and are non-null. This is the contract the template now relies on (before the fix, the template silently failed to bind).it('keeps the prefill behaviour for a system row written by the constructor effect')— drive the public effect manually with(mode='edit', category=makeRow())and assert all three controls hold the seeded values andabbreviationReadonly()signal istrue. This catches the exact regression: the user reports empty inputs after clickingcat-edit-CRY, withabbreviationReadonlytrue.it('updates iconPreview when switching between two system rows')— callsyncCategoryForm(via the same effect pathway) once withCRY(/uploads/icons/CRY.png) then again withHW(/uploads/icons/HW.png); asserticonPreview()(a public signal) reflects the new value. This locks the "icon preview stuck on CRY.png" symptom.
- Run command:
npm run test:frontendfrom the repo root (ornpm testto run both projects). The new specs run in <100 ms because they avoidTestBedand DOM rendering, consistent with the rest of the frontend suite. - No UI / no visual verification required. Per the project rules, frontend tests focus on logic only.
- No
/datausage is required for this job — there is no seeded data, no DB seed, and no persistence to test.