You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
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
Description
Migrate the dotCMS Analytics Dashboard (
libs/portlets/dot-analytics/) from the currentwithMethods+rxMethodpattern to the@ngrx/signals/eventsplugin (available in@ngrx/signals@21.0.1, already installed).The current store composes 4 feature slices (
withFilters,withPageview,withConversions,withEngagement) with ~17rxMethodloaders 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 aneffect()insidewithHooks.onInitreacting tocurrentTab/timeRange/currentSiteId.Why migrate:
rxMethod+ 3 coordinators (loadAllPageviewData,loadConversionsData,loadEngagementData) into pure event handlersswitch (currentTab)inonInitand replace it with awithEventHandlersthat reacts totabChanged/timeRangeChanged/siteChangedTarget 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:
Folder layout (per feature):
Naming conventions:
tabSelected,timeRangeSelected,refreshRequested. NeverloadDataorbuttonClicked.totalPageViewsRequested/totalPageViewsLoaded/totalPageViewsFailed.eventGroup({ source: 'Analytics Filters', events: { ... } })— one group per concern.Reducer signature:
Reducers are pure — no injects, no side effects.
Event handlers use
mapResponsefrom@ngrx/operatorsand MUST return event instances (nopatchStateinside handlers):Components use
injectDispatch(EventGroup)— cleaner than rawDispatcher:Scope: default
'self'— this portlet has its own store withproviders: [DotAnalyticsDashboardStore]; no need forglobalscope.Implementation plan (10 steps, incremental commits)
Full plan (Spanish) in the planning doc; condensed here:
filters.events.ts+filters-api.events.ts; migratewith-filterstowithReducer; addwith-navigation.handlers.ts(URL sync); updatedot-analytics-dashboard.component.tsto useinjectDispatchbuild-query-events.ts+pageview-api.events.ts,conversions-api.events.ts,engagement-api.events.tswith-pageviewtowithReducer+withEventHandlers(6 HTTP handlers,switchMapper metric)with-conversions(7 metrics, includesforkJoinintrafficVsConversions)with-engagement(4 metrics,forkJoinin kpis/sparkline/platforms for current+previous period)with-autoload.handlers.ts; listen tofiltersHydrated/tabSelected/timeRangeSelected/siteChanged→ fan-out*Requestedevents per active tabswitcheffect inwithHooks.onInit; hydration dispatchesfiltersApiEvents.filtersHydratedoncedot-analytics-dashboard.store.tsuiEvents.calculationDialogOpened/Closed,messageBannerDismissed) → migrate engagement-report and dashboard-root componentssetCurrentTabAndNavigate,refreshAllData,updateTimeRange,loadAllPageviewData,loadConversionsData,loadEngagementData,_load*) — store exposes signals + computeds only.spec.tsfiles — dispatch events instead of calling methods; mockDispatcher/Eventswhere needed; add reducer unit testsEach step must build + lint + test green before moving to the next.
Acceptance Criteria
Events & architecture
@ngrx/signals/eventsused throughout — store importswithReducer,withEventHandlers,eventGroup,on,injectDispatch,Dispatcher,Eventsfrom@ngrx/signals/eventsdata-access/src/lib/store/events/with a barrelindex.ts, split into*.events.ts(UI intents) and*-api.events.ts(API/system facts)tabSelected,totalPageViewsLoaded) — no command-style names (loadData,fetchTopPages)eventGrouphas a descriptivesource('Analytics Filters','Analytics Pageview Api', etc.)State & reducers
with-filters,with-pageview,with-conversions,with-engagement) useswithReducer(on(...), ...)and NOwithMethodspatchStatecalls outside reducers, no dispatch from reducers({ payload: { ... } }) => ({ ... })'custom'time range (dropdown picked, no dates yet) does NOT mutate state — reducer returns{}Event handlers (side effects)
withEventHandlers, usingmapResponsefrom@ngrx/operators— next emits*Loaded, error emits*FailedswitchMap(cancels stale requests when filters change)forkJoinpreserved forengagementKpis/engagementSparkline/engagementPlatforms(current + previous period) — merges into a singleLoadedpayloadpatchStatedirectly — only dispatch events or perform pure side effects (URL sync, breadcrumb)with-autoload.handlers.tsreacts tofiltersHydrated/tabSelected/timeRangeSelected/siteChangedand dispatches the right*Requestedfan-out for the active tabwith-navigation.handlers.tshandles URL sync (silentNavigate) and breadcrumb updatesComponents
dot-analytics-dashboard.component.tsusesinjectDispatch(filtersEvents)and dispatchestabSelected/timeRangeSelected/refreshRequested— no direct store method callsdot-analytics-engagement-report.component.tsdispatchesuiEvents.calculationDialogOpened/Closedand reads dialog state from the store signaluiEvents.messageBannerDismissed; localStorage persistence moved to a handlerStore surface
DotAnalyticsDashboardStoreexposes ONLY state signals and computeds — zero public methodssetCurrentTabAndNavigate,refreshAllData,updateTimeRange,loadAllPageviewData,loadConversionsData,loadEngagementData,_loadTotalPageViews,_loadUniqueVisitors,_loadTopPagePerformance,_loadPageViewTimeLine,_loadPageViewDeviceBrowsers,_loadTopPagesTable,setTimeRange,setCurrentTab, and all other_load*methodswithHooks.onInitonly dispatchesfiltersApiEvents.filtersHydratedfrom query params and wires thesiteChangedcross-store effect; noswitch (currentTab)presentTesting
Dispatcher/injectDispatch— no method calls on the storeDotAnalyticsServiceand assert both the outbound query and the dispatched result event (*Loaded/*Failed)filtersApiEvents.tabChanged({ tab: 'pageview' })with a mockedcurrentSiteId→ assert the 6 pageview*Requestedevents fireDispatcherand assert correct events/payloads on user interactionQuality gates
yarn nx lint portlets-dot-analytics-data-access portlets-dot-analytics— greenyarn nx test portlets-dot-analytics-data-access portlets-dot-analytics— green, coverage not regressingyarn nx build portlets-dot-analytics-data-access portlets-dot-analytics— greengrep -r "store.setCurrentTabAndNavigate\|store.loadAll\|store.updateTimeRange\|store.refreshAllData\|store._load"incore-web/libs/portlets/dot-analytics/returns 0 hits?tab=engagement&time_range=custom&from=...&to=...— all behave identically to current productionPriority
Medium
Additional Context
Out of scope
dot-analytics-search(separate store, separate feature)Known edge cases / risks
timeRange === 'custom'without dates should not trigger reload{}for bare'custom'; URL side-effect runs regardlessengagementusesforkJoin(current, previous)Loadedevent receives merged payloadtabChanged+timeRangeChangedswitchMapcancels stale requests; autoload usesmergeMapfiltersHydratedinonInitcould fire autoload twice (hydration + siteChanged)filtersHydratedonly aftercurrentSiteIdis available, ordistinctUntilChangedin autoloadDialogService/ breadcrumbglobalStorein reducerswithEventHandlers— reducers remain pureReferences
core-web/libs/portlets/dot-analytics/data-access/,core-web/libs/portlets/dot-analytics/portlet/src/lib/dot-analytics-dashboard/