M011: ngrx SignalStore Integration — Implementation Specification¶
Status: Draft — Pending Review Author: Claude (Senior Systems Architect) Date: 2026-04-08 Target Environment: Angular 21 / ngrx SignalStore 21 / TypeScript 5.9
Executive Summary¶
This specification covers Milestone M011 (QM v2 — ngrx SignalStore Integration), the final code milestone before production migration (M010). The goal is to wire the already-built v2 frontend views (Design, Assign, Preview) to the already-built v2 backend API, so the views render real data behind the newQuestionManagement feature flag.
Currently, the 16 QM v2 Angular components and 3 SignalStores (DesignV2Store, AssignV2Store, PreviewV2Store) are isolated — they have setQuestions() methods but nothing calls the backend. This milestone connects them using modern ngrx SignalStore patterns (D009): a project-scoped root store with withEntities named collections, resource signals for reads, rxMethod with debounce for autosave, withCallState, withUndoRedo, and withDevtools.
Additionally, this plan addresses 6 unresolved Sentry bot review comments on PR #2461 that identify real bugs in the existing implementation.
Table of Contents¶
- PR Review Comment Fixes
- High-Level Architecture
- Detailed Design — Slice S01: Root Store + Entity Layer
- Detailed Design — Slice S02: Autosave + Store Bridges
- Detailed Design — Slice S03: Undo/Redo Service
- Edge Cases & Mitigations
- Testing Strategy
- Implementation Checklist
- Open Questions
- References
1. PR Review Comment Fixes¶
6 unresolved Sentry bot comments on PR #2461 identify real bugs. These should be fixed first, before the SignalStore integration work, to establish a clean baseline.
1.1 DraftSnapshotService.ContentEquals Missing Fields¶
File: src/libs/project-management/SyRF.ProjectManagement.Core/Services/DraftSnapshotService.cs:150-171
Severity: HIGH
Issue: ContentEquals omits AnswerOptionFilters and ParentFilter, causing changes to conditional logic to be silently lost during snapshot deduplication.
Fix: Add comparisons for AnswerOptionFilters (top-level deep equality) and ParentFilter within the options loop.
1.2 OptionMapping Field Name Mismatch¶
File: src/libs/project-management/SyRF.ProjectManagement.Core/Model/QuestionVersioning/VersioningValueObjects.cs:74-78
Severity: HIGH
Issue: Backend OptionMapping uses OldValue/NewValue but frontend sends fromValue/toValue. Silent data loss on deserialisation.
Fix: Add [JsonPropertyName("fromValue")] and [JsonPropertyName("toValue")] attributes. Alternatively, rename the C# properties to match the frontend convention.
1.3 ImpactMappingPanel Effect Resets User Mappings¶
File: src/services/web/src/app/project/project-admin/question-management-v2/design/properties-panel/impact-mapping-panel/impact-mapping-panel.component.ts:161-167
Severity: HIGH
Issue: effect() unconditionally resets optionMappings when impact input changes, discarding user-configured mappings.
Fix: Preserve existing mappings when rebuilding — check if distribution keys changed; if a fromValue exists in old mappings, retain its toValue and requiresReview.
1.4 AnnotationImpactService GetAnswer Null-Coalescing¶
File: src/libs/project-management/SyRF.ProjectManagement.Core/Services/AnnotationImpactService.cs:53
Severity: MEDIUM
Issue: GetAnswer() ?? "(empty)" never triggers because GetAnswer() returns string.Empty, not null. Empty answers grouped under "" instead of "(empty)".
Fix: Use string.IsNullOrEmpty(answer) ? "(empty)" : answer.
1.5 UnitPanel Delete Confirmation Behind Collapsed Block¶
File: src/services/web/src/app/shared/annotation/annotation-form-v2/unit-panel/unit-panel.component.html:52-64
Severity: MEDIUM
Issue: Delete confirmation dialog is inside @if (!collapsed()) block. Clicking delete on collapsed panel fails silently.
Fix: Move the delete confirmation dialog outside the @if (!collapsed()) block, or expand the panel in confirmDelete().
1.6 CrossQuestionValidationService Redundant AQ005/AQ006 Errors¶
Already resolved in commit 22e9c65. No action needed.
2. High-Level Architecture¶
2.1 Overview¶
Per decisions D009 (SignalStore over legacy normalizr) and D005 (separate subscription with tiered DTOs), the architecture is:
┌──────────────────────────────────────────────────────────┐
│ Angular Frontend │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ QuestionManagementV2RootStore (providedIn: 'root') │ │
│ │ ─────────────────────────────────────────────────── │ │
│ │ withEntities({ entity: questionEntity, │ │
│ │ collection: 'questions' }) │ │
│ │ withEntities({ entity: versionEntity, │ │
│ │ collection: 'versions' }) │ │
│ │ withEntities({ entity: detailEntity, │ │
│ │ collection: 'details' }) │ │
│ │ withCallState({ collection: 'questions' }) │ │
│ │ withCallState({ collection: 'versions' }) │ │
│ │ withUndoRedo({ maxStackSize: 50 }) │ │
│ │ withDevtools('qm-v2') │ │
│ │ withMethods(store => ({ │ │
│ │ loadForProject: rxMethod<string>(pipe( │ │
│ │ switchMap(id => api.getProjectQuestionSet(id)) │ │
│ │ )), │ │
│ │ saveDraft: rxMethod<DraftSave>(pipe( │ │
│ │ debounceTime(1500), │ │
│ │ switchMap(...) │ │
│ │ )), │ │
│ │ publish: rxMethod<PublishRequest>(...) │ │
│ │ })) │ │
│ └─────────────┬───────────────┬──────────────┬─────────┘ │
│ │ │ │ │
│ ┌─────────────▼──┐ ┌────────▼────┐ ┌──────▼──────┐ │
│ │ DesignV2Store │ │ AssignV2Store│ │PreviewV2Store│ │
│ │ (view-local) │ │ (view-local) │ │ (view-local) │ │
│ │ │ │ │ │ │ │
│ │ selectedQId │ │ stageId │ │ stageId │ │
│ │ expandedNodes │ │ filterMode │ │ previewMode │ │
│ │ selectedCat │ │ checkboxState│ │ │ │
│ │ editState │ │ │ │ │ │
│ └─────────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ QuestionManagementV2HttpService (NSwag-generated) │ │
│ │ ─────────────────────────────────────────────────────│ │
│ │ getProjectQuestionSet() saveDraft() publishStage() │ │
│ │ getVersionHistory() applyVersion() validateStage() │ │
│ └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
▼ HTTP
┌──────────────────────────────────────────────────────────┐
│ QuestionManagementV2Controller (8 endpoints) │
│ GET/PUT /api/v2/projects/{id}/questions/{qid}/draft │
│ POST /api/v2/projects/{id}/stages/{sid}/publish │
│ GET /api/v2/projects/{id}/question-set/current │
│ etc. │
└──────────────────────────────────────────────────────────┘
2.2 Key Design Decisions (from GSD Knowledge Base)¶
| Decision | Choice | Rationale |
|---|---|---|
| D009 | Root-level ngrx SignalStore with withEntities, NOT legacy normalizr |
v2 is a new subsystem; @ngrx/signals 21 + toolkit available |
| D005 | Separate subscription with tiered DTOs | Avoids coupling question changes to full project notifications |
| D003 | Signal stores bridge from root store; local edits temporary | ngrx root store is source of truth; component signals are typing buffers |
| D002 | Operations-based undo stack, server-side GFS snapshots | Session undo via stack, persistent recovery via DraftSnapshotService |
| D008 | Structural props on aggregate root, content props on AQVersion | Structural immutable after publish; content versioned |
2.3 Dependencies¶
@ngrx/signals^21.0.1 — SignalStore,withEntities,withCallState,withMethods@angular-architects/ngrx-toolkit^21.0.1 —withUndoRedo,withDevtoolsQuestionManagementV2HttpService— already NSwag-generated from v2 controller- Existing v2 components (Design, Assign, Preview) — already built, need wiring
3. Detailed Design — Slice S01: Root Store + Entity Layer¶
3.1 Entity Types¶
Three named entity collections in the root store:
// questions — core question data (enough for annotation form)
interface QuestionV2Entity {
id: string; // AQ GUID
text: string;
controlType: string;
dataType: string;
options: QuestionOptionV2[];
optional: boolean;
multiple: boolean;
helpText: string;
parentQuestionId: string | null;
category: string;
root: boolean;
system: boolean;
groupAsSingle: boolean;
isDraft: boolean; // true if DraftQuestion, false if published AQ
currentVersionId: string | null;
versionIds: string[];
}
// versions — AQVersion snapshots (for version history, pinning)
interface AQVersionV2Entity {
id: string; // AQVersion GUID
questionId: string; // FK to QuestionV2Entity
versionNumber: number;
text: string;
options: QuestionOptionV2[];
helpText: string;
controlType: string;
optional: boolean;
multiple: boolean;
breakingChange: boolean;
changeReason: string | null;
createdBy: string;
createdAt: string;
}
// details — admin-only extras (impact data, draft state)
interface QuestionV2DetailEntity {
id: string; // same GUID as QuestionV2Entity
hasDraftChanges: boolean;
versionCount: number;
annotationCount: number;
studyCount: number;
draftContent: DraftContentDto | null;
publishDecision: DraftPublishDecisionDto | null;
}
3.2 Root Store Definition¶
export const QuestionManagementV2Store = signalStore(
{ providedIn: 'root' },
withEntities({ entity: type<QuestionV2Entity>(), collection: 'questions' }),
withEntities({ entity: type<AQVersionV2Entity>(), collection: 'versions' }),
withEntities({ entity: type<QuestionV2DetailEntity>(), collection: 'details' }),
withCallState({ collection: 'questions' }),
withCallState({ collection: 'versions' }),
withUndoRedo({ maxStackSize: 50 }),
withDevtools('qm-v2'),
withMethods((store, api = inject(QuestionManagementV2HttpService)) => ({
loadForProject: rxMethod<string>(pipe(
tap(() => patchState(store, setCallState({ collection: 'questions', callState: 'loading' }))),
switchMap(projectId => api.getProjectQuestionSet(projectId).pipe(
tapResponse(
response => {
// Normalise response into three entity collections
const { questions, versions, details } = normaliseProjectQuestionSetResponse(response);
patchState(store,
setAllEntities(questions, { collection: 'questions' }),
setAllEntities(versions, { collection: 'versions' }),
setAllEntities(details, { collection: 'details' }),
setCallState({ collection: 'questions', callState: 'loaded' }),
);
},
error => patchState(store, setCallState({ collection: 'questions', callState: { error } })),
),
)),
)),
// ... saveDraft, publish, etc. defined in S02
})),
);
3.3 Component Store Bridges¶
Each view-level store injects the root store and derives its view:
// DesignV2Store
export const DesignV2Store = signalStore(
withState({
selectedQuestionId: null as string | null,
expandedNodeIds: new Set<string>(),
selectedCategory: 'Treatment',
}),
withComputed((state, rootStore = inject(QuestionManagementV2Store)) => ({
questionTree: computed(() => {
const questions = rootStore.questionsEntities();
const details = rootStore.detailsEntityMap();
const category = state.selectedCategory();
return buildQuestionTree(questions, details, category);
}),
selectedQuestion: computed(() => {
const id = state.selectedQuestionId();
return id ? rootStore.questionsEntityMap()[id] ?? null : null;
}),
selectedQuestionDetail: computed(() => {
const id = state.selectedQuestionId();
return id ? rootStore.detailsEntityMap()[id] ?? null : null;
}),
callState: rootStore.questionsCallState,
})),
);
4. Detailed Design — Slice S02: Autosave + Store Bridges¶
4.1 Autosave Flow (K002)¶
Admin types in properties panel
→ Component local signal updates immediately
→ draftChange event emitted to parent
→ Parent calls rootStore.saveDraft({ projectId, questionId, draftContent })
→ rxMethod debounces (1500ms)
→ API call: PUT /api/v2/projects/{pid}/questions/{qid}/draft
→ On success: patchState updates detail entity (hasDraftChanges: true)
→ On failure: error state set, local edits preserved
4.2 Autosave rxMethod¶
saveDraft: rxMethod<{ projectId: string; questionId: string; content: DraftContentDto }>(pipe(
debounceTime(1500),
switchMap(({ projectId, questionId, content }) =>
api.saveDraft(projectId, questionId, content).pipe(
tapResponse(
() => patchState(store, updateEntity(
{ id: questionId, changes: { hasDraftChanges: true } },
{ collection: 'details' },
)),
error => console.error('Autosave failed', error),
),
),
),
)),
4.3 Assign and Preview Store Bridges¶
Same pattern as DesignV2Store — inject root store, derive view-specific computeds. AssignV2Store adds stageId, filterMode, checkboxStates. PreviewV2Store adds previewMode (published/draft toggle).
5. Detailed Design — Slice S03: Undo/Redo Service¶
Using @angular-architects/ngrx-toolkit's withUndoRedo on the root store. Per D002:
- Records entity state changes (not individual field keystrokes)
- Ctrl+Z / Ctrl+Shift+Z keybindings registered in DesignV2Component
- Undo triggers a new autosave with the reverted content
- Stack resets on publish (published versions are immutable)
- Stack is session-scoped (lost on navigation away or browser close)
- Undo cancels any pending autosave debounce and restarts the timer
6. Edge Cases & Mitigations¶
| # | Edge Case | Impact | Mitigation |
|---|---|---|---|
| 1 | Autosave fails (network error) | Edits appear lost | Local signal retains edits; retry with exponential backoff; show "Saving failed" status |
| 2 | Concurrent admin edits same question | Last write wins, losing changes | 409 conflict detection (D010); re-fetch on conflict; SignalR push for near-real-time sync |
| 3 | Navigation away during pending autosave | Unsaved changes lost | canDeactivate guard warns user; flush pending save on navigation |
| 4 | Large project (2000+ questions) | Slow initial load | Lazy load per category; virtual scrolling already in tree component |
| 5 | Feature flag toggled mid-session | UI switches between v1/v2 | Route guard checks flag on activation; full reload on flag change |
| 6 | Backend returns 404 for non-existent project | Error state | CallState error handling; redirect to project list |
7. Testing Strategy¶
7.1 Unit Tests¶
- Root store:
loadForProjectnormalises response into three collections - Root store:
loadForProjectsets callState loading → loaded → error - Root store:
saveDraftdebounces and calls API - Root store:
saveDraftupdates detail entity on success - Root store:
saveDraftpreserves error state on failure - DesignV2Store:
questionTreederives from root store entities - DesignV2Store:
selectedQuestiontracks selection - AssignV2Store: bridges stage-filtered questions from root store
- PreviewV2Store: bridges published vs draft toggle
- UndoRedo: undo reverts entity state and triggers autosave
- UndoRedo: stack resets on publish
- Normalisation helper: maps API response to entity collections
7.2 Integration Tests¶
- Design view loads real data from mock API and renders tree
- Properties panel edit triggers autosave after debounce
- Assign view loads stage-filtered questions
- Publish wizard sends correct payload
7.3 Bug Fix Tests¶
- DraftSnapshotService.ContentEquals includes AnswerOptionFilters and ParentFilter
- OptionMapping deserialises fromValue/toValue correctly
- ImpactMappingPanel preserves existing mappings on impact signal change
- AnnotationImpactService groups empty answers under "(empty)"
- UnitPanel delete works on collapsed panels
CrossQuestionValidation AQ005 doesn't fire when parent doesn't exist(already resolved)
8. Implementation Checklist¶
Phase 0: PR Review Bug Fixes (do first)¶
- Fix DraftSnapshotService.ContentEquals (add AnswerOptionFilters + ParentFilter)
- Fix OptionMapping field names (add JsonPropertyName attributes)
- Fix ImpactMappingPanel effect (preserve existing mappings)
- Fix AnnotationImpactService GetAnswer (use IsNullOrEmpty)
- Fix UnitPanel delete confirmation (move outside collapsed block)
Fix CrossQuestionValidation AQ005 guard(already resolved in22e9c65)- Add/update tests for each fix
Phase 1: Root Store + Entity Layer (S01)¶
- Define entity interfaces (QuestionV2Entity, AQVersionV2Entity, QuestionV2DetailEntity)
- Create normalisation helper (API response → entity collections)
- Create QuestionManagementV2RootStore with withEntities, withCallState, withDevtools
- Add
loadForProjectrxMethod - Wire DesignV2Store to bridge from root store
- Wire AssignV2Store to bridge from root store
- Wire PreviewV2Store to bridge from root store
- Update DesignV2Component to call
rootStore.loadForProject(projectId)on init - Verify Design view renders real data from API (feature flag on)
- Unit tests for root store and bridges
Phase 2: Autosave + Assign/Preview Bridges (S02)¶
- Add
saveDraftrxMethod with debounceTime - Wire PropertiesPanelComponent draftChange → rootStore.saveDraft
- Add autosave status signal (idle/saving/saved/error)
- Add canDeactivate guard for unsaved changes
- Wire AssignV2Component to load stage-filtered questions
- Wire PreviewV2Component to load published vs draft view
- Add
publishStagerxMethod - Wire PublishWizardComponent to rootStore.publishStage
- Unit tests for autosave and publish
Phase 3: Undo/Redo (S03)¶
- Add
withUndoRedoto root store - Register Ctrl+Z / Ctrl+Shift+Z keybindings in DesignV2Component
- Ensure undo triggers autosave with reverted content
- Reset stack on publish
- Unit tests for undo/redo integration
9. Open Questions — Resolved¶
-
Root store scope: Route-scoped, provided at the project route level. Destroyed when navigating away from the project, re-created on return. This avoids stale data for switched projects and matches the project-scoped lifecycle.
-
Summary DTO endpoint: Does NOT exist yet. The existing
GetStageQuestionSetreturns only assigned question IDs (no content). A new lightweight endpoint is needed:GET /api/v2/projects/{id}/stages/{sid}/questions/summaryreturningAnnotationQuestionV2SummaryDto[]with question content (text, controlType, options, optional, multiple, parentQuestionId, system) but NO admin-only data (draft content, version history, annotation counts). This endpoint must be created as part of S01. -
SignalR push notifications: Yes, included in this milestone. SignalR notifications for question changes will provide near-real-time sync for concurrent admin editing (D010).
10. References¶
- GSD Knowledge Base: K001-K004 (revised), K005-K019 (in git at HEAD:.gsd/KNOWLEDGE.md)
- GSD Decisions: D001-D012 (in git at HEAD:.gsd/DECISIONS.md)
- M006 Roadmap: 3 slices (S01 entity layer, S02 autosave, S03 undo/redo)
- ngrx SignalStore docs: withEntities, withCallState, rxMethod
- @angular-architects/ngrx-toolkit: withUndoRedo, withDevtools
- Mental model review decisions: docs/features/question-management/mental-model-review-decisions.md
- Implementation plan Phase 8 component architecture: docs/features/question-management/implementation-plan.md
Document End
This document must be reviewed and approved before implementation begins.