Skip to content

refactor(analytics): migrate dashboard store to @ngrx/signals/events plugin #35457

@oidacra

Description

@oidacra

Description

Migrate the dotCMS Analytics Dashboard (libs/portlets/dot-analytics/) from the current withMethods + rxMethod pattern to the @ngrx/signals/events plugin (available in @ngrx/signals@21.0.1, already installed).

The current store composes 4 feature slices (withFilters, withPageview, withConversions, withEngagement) with ~17 rxMethod loaders against /api/v1/analytics/content/_query/cube. User intents are handled by calling public store methods (store.setCurrentTabAndNavigate(), store.refreshAllData(), store.updateTimeRange()), and auto-load lives in an effect() inside withHooks.onInit reacting to currentTab/timeRange/currentSiteId.

Why migrate:

  • Decouple components from the store — UI only dispatches intents, doesn't orchestrate loaders
  • Make the flow declarative and auditable — a single event bus describes every interaction
  • Collapse ~17 rxMethod + 3 coordinators (loadAllPageviewData, loadConversionsData, loadEngagementData) into pure event handlers
  • Remove the switch (currentTab) in onInit and replace it with a withEventHandlers that reacts to tabChanged / timeRangeChanged / siteChanged

Target architecture: store exposes only state signals + computeds; every side effect flows through events.


Architecture reference

Implementation must follow the patterns documented in NgRx SignalStore Events: Architecture & Implementation Patterns by Arcadio Quintero. Key principles:

  1. Folder layout (per feature):

    data-access/src/lib/store/
    ├── events/
    │   ├── filters.events.ts           # UI intents
    │   ├── filters-api.events.ts       # Post-state + hydration facts
    │   ├── pageview.events.ts          # (intents, if any)
    │   ├── pageview-api.events.ts      # <metric>Requested / Loaded / Failed
    │   ├── conversions-api.events.ts
    │   ├── engagement-api.events.ts
    │   ├── ui.events.ts                # dialog / banner
    │   ├── external.events.ts          # siteChanged
    │   └── index.ts                    # barrel
    ├── features/
    │   ├── with-filters.feature.ts     # withReducer only (no HTTP)
    │   ├── with-pageview.feature.ts    # withReducer + withEventHandlers
    │   ├── with-conversions.feature.ts # withReducer + withEventHandlers
    │   └── with-engagement.feature.ts  # withReducer + withEventHandlers
    ├── handlers/
    │   ├── with-navigation.handlers.ts # URL sync + breadcrumb
    │   └── with-autoload.handlers.ts   # filtersChanged / tabChanged / siteChanged → *Requested fan-out
    └── dot-analytics-dashboard.store.ts
    
  2. Naming conventions:

    • User intent events = past tense of what the user did: tabSelected, timeRangeSelected, refreshRequested. Never loadData or buttonClicked.
    • API events = factual outcomes: totalPageViewsRequested / totalPageViewsLoaded / totalPageViewsFailed.
    • eventGroup({ source: 'Analytics Filters', events: { ... } }) — one group per concern.
  3. Reducer signature:

    withReducer(
      on(filtersEvents.tabSelected, ({ payload: { tab } }) => ({ currentTab: tab })),
      on(filtersEvents.timeRangeSelected, ({ payload: { timeRange } }) => {
        if (timeRange === TIME_RANGE_OPTIONS.custom) return {}; // bare 'custom' without dates: no-op
        return { timeRange };
      }),
    )

    Reducers are pure — no injects, no side effects.

  4. Event handlers use mapResponse from @ngrx/operators and MUST return event instances (no patchState inside handlers):

    withEventHandlers((_, events = inject(Events), api = inject(DotAnalyticsService), msg = inject(DotMessageService)) => ({
      loadTotalPageViews$: events.on(pageviewApiEvents.totalPageViewsRequested).pipe(
        switchMap(({ payload }) =>
          api.cubeQuery<TotalPageViewsEntity>(buildTotalPageViewsQuery(payload)).pipe(
            mapResponse({
              next: (data) => pageviewApiEvents.totalPageViewsLoaded({ data }),
              error: (e: HttpErrorResponse) => pageviewApiEvents.totalPageViewsFailed({
                error: e.message ?? msg.get('analytics.error.loading.total-pageviews')
              }),
            })
          )
        )
      ),
    }))
  5. Components use injectDispatch(EventGroup) — cleaner than raw Dispatcher:

    export default class DotAnalyticsDashboardComponent {
      protected readonly store = inject(DotAnalyticsDashboardStore);
      private readonly dispatch = injectDispatch(filtersEvents);
      onTabChange(tab: DashboardTab) { this.dispatch.tabSelected({ tab }); }
      onRefresh() { this.dispatch.refreshRequested(); }
      onTimeRangeChange(timeRange: TimeRangeInput) { this.dispatch.timeRangeSelected({ timeRange }); }
    }
  6. Scope: default 'self' — this portlet has its own store with providers: [DotAnalyticsDashboardStore]; no need for global scope.


Implementation plan (10 steps, incremental commits)

Full plan (Spanish) in the planning doc; condensed here:

Step Scope Files touched
1 Create filters.events.ts + filters-api.events.ts; migrate with-filters to withReducer; add with-navigation.handlers.ts (URL sync); update dot-analytics-dashboard.component.ts to use injectDispatch ~5 files
2 Create helper build-query-events.ts + pageview-api.events.ts, conversions-api.events.ts, engagement-api.events.ts new files
3 Migrate with-pageview to withReducer + withEventHandlers (6 HTTP handlers, switchMap per metric) 1 file
4 Migrate with-conversions (7 metrics, includes forkJoin in trafficVsConversions) 1 file
5 Migrate with-engagement (4 metrics, forkJoin in kpis/sparkline/platforms for current+previous period) 1 file
6 Create with-autoload.handlers.ts; listen to filtersHydrated / tabSelected / timeRangeSelected / siteChanged → fan-out *Requested events per active tab 1 file
7 Delete the switch effect in withHooks.onInit; hydration dispatches filtersApiEvents.filtersHydrated once dot-analytics-dashboard.store.ts
8 Dialog + banner events (uiEvents.calculationDialogOpened/Closed, messageBannerDismissed) → migrate engagement-report and dashboard-root components 2 files
9 Remove all public store methods (setCurrentTabAndNavigate, refreshAllData, updateTimeRange, loadAllPageviewData, loadConversionsData, loadEngagementData, _load*) — store exposes signals + computeds only store + features
10 Update all .spec.ts files — dispatch events instead of calling methods; mock Dispatcher / Events where needed; add reducer unit tests specs

Each step must build + lint + test green before moving to the next.


Acceptance Criteria

Events & architecture

  • @ngrx/signals/events used throughout — store imports withReducer, withEventHandlers, eventGroup, on, injectDispatch, Dispatcher, Events from @ngrx/signals/events
  • Events organized into dedicated files under data-access/src/lib/store/events/ with a barrel index.ts, split into *.events.ts (UI intents) and *-api.events.ts (API/system facts)
  • Every event follows past-tense / fact naming (tabSelected, totalPageViewsLoaded) — no command-style names (loadData, fetchTopPages)
  • Each eventGroup has a descriptive source ('Analytics Filters', 'Analytics Pageview Api', etc.)

State & reducers

  • Each feature slice (with-filters, with-pageview, with-conversions, with-engagement) uses withReducer(on(...), ...) and NO withMethods
  • Reducers are pure — no injects, no patchState calls outside reducers, no dispatch from reducers
  • Reducers destructure payloads as ({ payload: { ... } }) => ({ ... })
  • Bare 'custom' time range (dropdown picked, no dates yet) does NOT mutate state — reducer returns {}

Event handlers (side effects)

  • All HTTP is in withEventHandlers, using mapResponse from @ngrx/operators — next emits *Loaded, error emits *Failed
  • Each metric has its own handler with switchMap (cancels stale requests when filters change)
  • forkJoin preserved for engagementKpis / engagementSparkline / engagementPlatforms (current + previous period) — merges into a single Loaded payload
  • Event handlers never call patchState directly — only dispatch events or perform pure side effects (URL sync, breadcrumb)
  • with-autoload.handlers.ts reacts to filtersHydrated / tabSelected / timeRangeSelected / siteChanged and dispatches the right *Requested fan-out for the active tab
  • with-navigation.handlers.ts handles URL sync (silentNavigate) and breadcrumb updates

Components

  • dot-analytics-dashboard.component.ts uses injectDispatch(filtersEvents) and dispatches tabSelected / timeRangeSelected / refreshRequested — no direct store method calls
  • dot-analytics-engagement-report.component.ts dispatches uiEvents.calculationDialogOpened/Closed and reads dialog state from the store signal
  • Message banner dismiss dispatches uiEvents.messageBannerDismissed; localStorage persistence moved to a handler

Store surface

  • DotAnalyticsDashboardStore exposes ONLY state signals and computeds — zero public methods
  • Removed: setCurrentTabAndNavigate, refreshAllData, updateTimeRange, loadAllPageviewData, loadConversionsData, loadEngagementData, _loadTotalPageViews, _loadUniqueVisitors, _loadTopPagePerformance, _loadPageViewTimeLine, _loadPageViewDeviceBrowsers, _loadTopPagesTable, setTimeRange, setCurrentTab, and all other _load* methods
  • withHooks.onInit only dispatches filtersApiEvents.filtersHydrated from query params and wires the siteChanged cross-store effect; no switch (currentTab) present

Testing

  • Store specs dispatch events via Dispatcher / injectDispatch — no method calls on the store
  • Reducer unit tests for filters / pageview / conversions / engagement (given event + payload → expected state patch)
  • Event-handler tests mock DotAnalyticsService and assert both the outbound query and the dispatched result event (*Loaded / *Failed)
  • Auto-load handler test: dispatch filtersApiEvents.tabChanged({ tab: 'pageview' }) with a mocked currentSiteId → assert the 6 pageview *Requested events fire
  • Component specs mock Dispatcher and assert correct events/payloads on user interaction
  • All existing smoke flows still pass (tab switch, time range change, refresh, site change, deep-link hydration)

Quality gates

  • yarn nx lint portlets-dot-analytics-data-access portlets-dot-analytics — green
  • yarn nx test portlets-dot-analytics-data-access portlets-dot-analytics — green, coverage not regressing
  • yarn nx build portlets-dot-analytics-data-access portlets-dot-analytics — green
  • grep -r "store.setCurrentTabAndNavigate\|store.loadAll\|store.updateTimeRange\|store.refreshAllData\|store._load" in core-web/libs/portlets/dot-analytics/ returns 0 hits
  • Manual smoke: open Analytics Dashboard, switch tabs (pageview / conversions / engagement), change time range (predefined + custom range), click Refresh, switch site from global selector, open/close "How it's calculated" dialog, hit deep-link ?tab=engagement&time_range=custom&from=...&to=... — all behave identically to current production

Priority

Medium


Additional Context

Out of scope

  • Migrating dot-analytics-search (separate store, separate feature)
  • Migrating other portlets to events
  • UX / design changes
  • New analytics metrics or endpoints

Known edge cases / risks

Risk Mitigation
timeRange === 'custom' without dates should not trigger reload Reducer returns {} for bare 'custom'; URL side-effect runs regardless
engagement uses forkJoin(current, previous) Keep inside the HTTP handler; Loaded event receives merged payload
Autoload may double-fetch on simultaneous tabChanged + timeRangeChanged Per-metric switchMap cancels stale requests; autoload uses mergeMap
filtersHydrated in onInit could fire autoload twice (hydration + siteChanged) Dispatch filtersHydrated only after currentSiteId is available, or distinctUntilChanged in autoload
DialogService / breadcrumb globalStore in reducers They stay in withEventHandlers — reducers remain pure

References

  • NgRx SignalStore events docs: https://ngrx.io/guide/signals/events
  • Target modules: core-web/libs/portlets/dot-analytics/data-access/, core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/

Metadata

Metadata

Assignees

Type

No fields configured for Task.

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions