Skip to content

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

  1. PR Review Comment Fixes
  2. High-Level Architecture
  3. Detailed Design — Slice S01: Root Store + Entity Layer
  4. Detailed Design — Slice S02: Autosave + Store Bridges
  5. Detailed Design — Slice S03: Undo/Redo Service
  6. Edge Cases & Mitigations
  7. Testing Strategy
  8. Implementation Checklist
  9. Open Questions
  10. 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, withDevtools
  • QuestionManagementV2HttpService — 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: loadForProject normalises response into three collections
  • Root store: loadForProject sets callState loading → loaded → error
  • Root store: saveDraft debounces and calls API
  • Root store: saveDraft updates detail entity on success
  • Root store: saveDraft preserves error state on failure
  • DesignV2Store: questionTree derives from root store entities
  • DesignV2Store: selectedQuestion tracks 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 in 22e9c65)
  • 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 loadForProject rxMethod
  • 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 saveDraft rxMethod 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 publishStage rxMethod
  • Wire PublishWizardComponent to rootStore.publishStage
  • Unit tests for autosave and publish

Phase 3: Undo/Redo (S03)

  • Add withUndoRedo to 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

  1. 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.

  2. Summary DTO endpoint: Does NOT exist yet. The existing GetStageQuestionSet returns only assigned question IDs (no content). A new lightweight endpoint is needed: GET /api/v2/projects/{id}/stages/{sid}/questions/summary returning AnnotationQuestionV2SummaryDto[] 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.

  3. 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.