diff --git a/src/ui-spa/src/TelemetryLogger.test.ts b/src/ui-spa/src/TelemetryLogger.test.ts index 5d17d843..889e544c 100644 --- a/src/ui-spa/src/TelemetryLogger.test.ts +++ b/src/ui-spa/src/TelemetryLogger.test.ts @@ -37,14 +37,17 @@ describe("TelemetryService", () => { }); it("trackPageView sends a PageView payload with pageName and additional props", async () => { - await telemetryService.trackPageView("HomePage", [{ foo: "bar" } as any]); + await telemetryService.trackPageView("HomePage", [ + { foo: "bar" }, + { id: "123" }, + ]); expect(logTelemetryEvent).toHaveBeenCalledTimes(1); const sent = getSentPayload(); const expected = { telemetryType: TelemetryType.PageView, - properties: [{ pageName: "HomePage" }, { foo: "bar" }], + properties: [{ pageName: "HomePage" }, { foo: "bar" }, { id: "123" }], }; expect(sent).toEqual(expected); diff --git a/src/ui-spa/src/TelemetryLogger.ts b/src/ui-spa/src/TelemetryLogger.ts index 30737ccf..26e43c66 100644 --- a/src/ui-spa/src/TelemetryLogger.ts +++ b/src/ui-spa/src/TelemetryLogger.ts @@ -15,13 +15,14 @@ export type TelemetryPayload = { export type TelemetryEventPropsMap = { JourneyStarted: [{ journeyId: string }]; + JourneyCancelled: [{ journeyId: string }, { cancelledFrom: string }]; }; export type CustomEventName = keyof TelemetryEventPropsMap; export type TelemetryEventProps = TelemetryEventPropsMap[T]; -export type TelemetryPageViewProps = [Record]; +export type TelemetryPageViewProps = Record[]; export class TelemetryService { async trackEvent( diff --git a/src/ui-spa/src/common/constants/pageTitles.ts b/src/ui-spa/src/common/constants/pageTitles.ts new file mode 100644 index 00000000..21028f1f --- /dev/null +++ b/src/ui-spa/src/common/constants/pageTitles.ts @@ -0,0 +1,38 @@ +export const pageTitles: Record = { + "/case-registration": "Home", + "/areas": "Case Areas", + "/case-details": "Case Details", + "/first-hearing": "First Hearing Details", + "/case-complexity": "Case Complexity", + "/case-monitoring-codes": "Case Monitoring Codes", + "/case-assignee": "Case Assignee", + "/case-summary": "Case Summary", + "/case-registration-confirmation": "Case Registration Confirmation", + "/add-suspect": "Add Suspect", + "/suspect-dob": "Suspect Date of Birth", + "/suspect-gender": "Suspect Gender", + "/suspect-ethnicity": "Suspect Ethnicity", + "/suspect-religion": "Suspect Religion", + "/suspect-disability": "Suspect Disability", + "/suspect-add-aliases": "Suspect Add Aliases", + "/suspect-aliases-summary": "Suspect Aliases Summary", + "/suspect-asn": "Suspect ASN", + "/suspect-offender": "Suspect Offender Type", + "/suspect-summary": "Suspect Summary", + "/suspect-remove-confirmation": "Suspect Remove Confirmation", + "/want-to-add-charges": "Want To Add Charges", + "/add-charge-suspect": "Add Charge Suspect", + "/charges-offence-search": "Charges Offence Search", + "/add-charge-details": "Add Charge Details", + "/charges-summary": "Charges Summary", + "/add-charge-victim": "Add Charge Victim", + "/charge-remove-confirmation": "Charge Remove Confirmation", + "/charges-victim-duplicate-confirmation": + "Charges Victim Duplicate Confirmation", + "/change-area-confirmation": "Change Area Confirmation", + "/change-registering-unit-confirmation": + "Change Registering Unit Confirmation", + "/remove-all-suspects-confirmation": "Remove All Suspects Confirmation", + "/cancel-case-registration-confirmation": + "Cancel Case Registration Confirmation", +}; diff --git a/src/ui-spa/src/common/hooks/usePageView.test.tsx b/src/ui-spa/src/common/hooks/usePageView.test.tsx new file mode 100644 index 00000000..0cdb4fb2 --- /dev/null +++ b/src/ui-spa/src/common/hooks/usePageView.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../TelemetryLogger", () => ({ + telemetryService: { + trackPageView: vi.fn(), + }, +})); + +vi.mock("react-router", () => ({ + useLocation: vi.fn(), +})); + +import { telemetryService } from "../../TelemetryLogger"; +import { useLocation } from "react-router"; +import { CaseRegistrationFormContext } from "../providers/CaseRegistrationProvider"; +import usePageView from "./usePageView"; + +function TestComponent() { + usePageView(); + return null; +} + +describe("usePageView", () => { + const mockedUseLocation = useLocation as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls telemetryService.trackPageView with the expected payload when pathname changes and journeyId is present", () => { + const pathname = "/case-registration"; + mockedUseLocation.mockImplementation(() => ({ + pathname, + })); + + const providerValue = { + state: { telemetryData: { journeyId: "J1" } }, + } as unknown as React.ContextType; + + render( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + + const call = (telemetryService.trackPageView as Mock).mock.calls[0]; + + const expectedPageName = "Home"; + const expectedProperties = [{ journeyId: "J1" }, { path: pathname }]; + + expect(call).toEqual([expectedPageName, expectedProperties]); + }); + + it("does not call trackPageView when journeyId is missing", () => { + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/step-1", + })); + + const providerValue = { + state: { telemetryData: { journeyId: "" } }, + } as unknown as React.ContextType; + + render( + + + , + ); + + expect(telemetryService.trackPageView).not.toHaveBeenCalled(); + }); + + it("does not call trackPageView again if pathname hasn't changed", () => { + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/", + })); + + const providerValue = { + state: { telemetryData: { journeyId: "J1" } }, + } as unknown as React.ContextType; + + const { rerender } = render( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + + mockedUseLocation.mockImplementation(() => ({ + pathname: "/case-registration/", + })); + rerender( + + + , + ); + + expect(telemetryService.trackPageView).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui-spa/src/common/hooks/usePageView.tsx b/src/ui-spa/src/common/hooks/usePageView.tsx new file mode 100644 index 00000000..69d73269 --- /dev/null +++ b/src/ui-spa/src/common/hooks/usePageView.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useContext } from "react"; +import { useLocation } from "react-router"; +import { telemetryService } from "../../TelemetryLogger"; +import { CaseRegistrationFormContext } from "../providers/CaseRegistrationProvider"; +import { pageTitles } from "../constants/pageTitles"; + +const usePageView = () => { + const { state } = useContext(CaseRegistrationFormContext); + const location = useLocation(); + const lastPath = useRef(null); + const cleanPath = location.pathname.replace(/\/+$/, "") || "/"; + useEffect(() => { + if (lastPath.current === cleanPath) return; + + if (!state.telemetryData.journeyId) { + return; + } + + const lastSlashIndex = cleanPath.lastIndexOf("/"); + const lastSegment = cleanPath.substring(lastSlashIndex); + const pageName = pageTitles[lastSegment] || "Unknown Page"; + + telemetryService.trackPageView(pageName, [ + { + journeyId: state.telemetryData.journeyId, + }, + { + path: cleanPath, + }, + ]); + + lastPath.current = cleanPath; + }, [cleanPath, state.telemetryData.journeyId]); +}; + +export default usePageView; diff --git a/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx b/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx index f120994b..f9c6bc15 100644 --- a/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx +++ b/src/ui-spa/src/common/hooks/useRouteDocumentTitle.tsx @@ -1,44 +1,6 @@ import { useEffect, useState } from "react"; import { useLocation } from "react-router"; - -const routeTitles: Record = { - "/case-registration": "Home", - "/areas": "Case Areas", - "/case-details": "Case Details", - "/first-hearing": "First Hearing Details", - "/case-complexity": "Case Complexity", - "/case-monitoring-codes": "Case Monitoring Codes", - "/case-assignee": "Case Assignee", - "/case-summary": "Case Summary", - "/case-registration-confirmation": "Case Registration Confirmation", - "/add-suspect": "Add Suspect", - "/suspect-dob": "Suspect Date of Birth", - "/suspect-gender": "Suspect Gender", - "/suspect-ethnicity": "Suspect Ethnicity", - "/suspect-religion": "Suspect Religion", - "/suspect-disability": "Suspect Disability", - "/suspect-add-aliases": "Suspect Add Aliases", - "/suspect-aliases-summary": "Suspect Aliases Summary", - "/suspect-asn": "Suspect ASN", - "/suspect-offender": "Suspect Offender Type", - "/suspect-summary": "Suspect Summary", - "/suspect-remove-confirmation": "Suspect Remove Confirmation", - "/want-to-add-charges": "Want To Add Charges", - "/add-charge-suspect": "Add Charge Suspect", - "/charges-offence-search": "Charges Offence Search", - "/add-charge-details": "Add Charge Details", - "/charges-summary": "Charges Summary", - "/add-charge-victim": "Add Charge Victim", - "/charge-remove-confirmation": "Charge Remove Confirmation", - "/charges-victim-duplicate-confirmation": - "Charges Victim Duplicate Confirmation", - "/change-area-confirmation": "Change Area Confirmation", - "/change-registering-unit-confirmation": - "Change Registering Unit Confirmation", - "/remove-all-suspects-confirmation": "Remove All Suspects Confirmation", - "/cancel-case-registration-confirmation": - "Cancel Case Registration Confirmation", -}; +import { pageTitles } from "../constants/pageTitles"; const DEFAULT_TITLE = "Home"; @@ -48,7 +10,7 @@ export const useRouteDocumentTitle = (): { title: string } => { useEffect(() => { const lastSlashIndex = pathname.lastIndexOf("/"); const lastSegment = pathname.substring(lastSlashIndex); - const title = `${routeTitles[lastSegment] ?? DEFAULT_TITLE} - Register A Case`; + const title = `${pageTitles[lastSegment] ?? DEFAULT_TITLE} - Register A Case`; document.title = title; setTitle(title); }, [pathname]); diff --git a/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts b/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts index c376b064..e5e706a3 100644 --- a/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts +++ b/src/ui-spa/src/common/reducers/caseRegistrationReducer.test.ts @@ -141,6 +141,9 @@ describe("caseRegistrationReducer", () => { }, }, apiData: apiData, + telemetryData: { + journeyId: "", + }, }; const action: CaseRegistrationActions = { type: "RESET_FORM_DATA" }; const state = caseRegistrationReducer(modifiedState, action); @@ -1587,6 +1590,25 @@ describe("caseRegistrationReducer", () => { const state = caseRegistrationReducer(modifiedState, action); expect(state).toEqual(modifiedState); }); + + it("SET_TELEMETRY_JOURNEY_ID should set the telemetryData journeyId", () => { + const startState = { + ...initialState, + formData: { + ...initialState.formData, + }, + }; + + const action = { + type: "SET_TELEMETRY_JOURNEY_ID" as const, + payload: { journeyId: "J1" }, + }; + + const next = caseRegistrationReducer(startState, action); + + expect(next.telemetryData.journeyId).toBe("J1"); + expect(next.apiData).toEqual(startState.apiData); + }); }); describe("getResetFieldValues", () => { it("should reset caseProsecutorText and caseCaseworkerText when caseProsecutorRadio is 'no'", () => { diff --git a/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts b/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts index 2c039823..181fef91 100644 --- a/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts +++ b/src/ui-spa/src/common/reducers/caseRegistrationReducer.ts @@ -164,6 +164,9 @@ export type CaseRegistrationState = { suspectOffenderTypes?: OffenderTypes | null; offencesSearchResults?: Offences | null; }; + telemetryData: { + journeyId: string; + }; }; export const suspectInitialState: SuspectFormData = { @@ -261,6 +264,10 @@ export const initialState: CaseRegistrationState = { suspectReligions: null, suspectOffenderTypes: null, }, + + telemetryData: { + journeyId: "", + }, }; export type CaseRegistrationActions = @@ -484,6 +491,12 @@ export type CaseRegistrationActions = } | { type: "REMOVE_ALL_SUSPECTS"; + } + | { + type: "SET_TELEMETRY_JOURNEY_ID"; + payload: { + journeyId: string; + }; }; export type DispatchType = React.Dispatch; @@ -879,6 +892,16 @@ export const caseRegistrationReducer = ( }; } + case "SET_TELEMETRY_JOURNEY_ID": { + return { + ...state, + telemetryData: { + ...state.telemetryData, + journeyId: action.payload.journeyId, + }, + }; + } + default: return state; } diff --git a/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts b/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts index 78e1b52f..8296ea50 100644 --- a/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts +++ b/src/ui-spa/src/common/utils/getCaseRegistartionRequestData.test.ts @@ -99,10 +99,13 @@ describe("getCaseRegistrationRequestData", () => { }, defendants: [], victims: [], + journeyId: "JOURNEY_ID", + areaOrDivisionText: "Area 1", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); @@ -201,10 +204,13 @@ describe("getCaseRegistrationRequestData", () => { }, defendants: [], victims: [], + journeyId: "JOURNEY_ID", + areaOrDivisionText: "Area 1", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", { unitId: 2091, unitDescription: "Plymouth Magistrates Court Unit", @@ -383,11 +389,14 @@ describe("getCaseRegistrationRequestData", () => { oicRank: "PC", oicShoulderNumber: "12", oicSurname: "last", + journeyId: "JOURNEY_ID", + areaOrDivisionText: "CAMBRIDGESHIRE", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); @@ -755,11 +764,14 @@ describe("getCaseRegistrationRequestData", () => { oicRank: "PC", oicShoulderNumber: "12", oicSurname: "last", + journeyId: "JOURNEY_ID", + areaOrDivisionText: "CAMBRIDGESHIRE", }; const result = getCaseRegistrationRequestData( formData, monitoringCodesData, + "JOURNEY_ID", ); expect(result).toEqual(expectedResult); }); diff --git a/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts b/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts index 80aab9dc..f6152a58 100644 --- a/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts +++ b/src/ui-spa/src/common/utils/getCaseRegistrationRequestData.ts @@ -14,6 +14,7 @@ import { export const getCaseRegistrationRequestData = ( formData: CaseRegistrationFormData, monitoringCodesData: CaseMonitoringCode[], + journeyId: string, policeUnit?: PoliceUnit, ): CaseRegistrationRequestData => { const monitoringCodes = monitoringCodesData @@ -56,6 +57,8 @@ export const getCaseRegistrationRequestData = ( oicPoliceUnit: policeUnit ? policeUnit.code : "", defendants: getSuspectRequestData(formData.suspects, formData.victimsList), victims: getVictimRequestData(formData.victimsList), + journeyId: journeyId, + areaOrDivisionText: formData.areaOrDivisionText.description, }; const validatedData = diff --git a/src/ui-spa/src/components/AppRoutes.tsx b/src/ui-spa/src/components/AppRoutes.tsx index 9d10e8ef..21ac7c9c 100644 --- a/src/ui-spa/src/components/AppRoutes.tsx +++ b/src/ui-spa/src/components/AppRoutes.tsx @@ -33,8 +33,10 @@ import ChangeRegisteringUnitConfirmationPage from "./case-registration/change-re import RemoveAllSuspectsConfirmationPage from "./case-registration/remove-all-suspects-confirmation"; import CancelCaseRegistrationConfirmationPage from "./case-registration/cancel-case-registration-confirmation"; import ProtectedRoutes from "./ProtectedRoutes"; +import usePageView from "../common/hooks/usePageView"; const AppRoutes = () => { + usePageView(); return ( } /> diff --git a/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx b/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx index 5cf1b80b..cb20b609 100644 --- a/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx +++ b/src/ui-spa/src/components/case-registration/cancel-case-registration-confirmation/index.tsx @@ -1,12 +1,22 @@ -import { useRef, useEffect, useState, useCallback, useMemo } from "react"; +import { + useRef, + useEffect, + useState, + useCallback, + useMemo, + useContext, +} from "react"; import { Radios, Button, ErrorSummary } from "../../govuk"; import { type GeneralRadioValue } from "../../../common/reducers/caseRegistrationReducer"; import { HOME_PAGE_URL } from "../../../config"; import { useNavigate, useLocation } from "react-router"; import PageContentWrapper from "../../common/PageContentWrapper"; +import { telemetryService } from "../../../TelemetryLogger"; +import { CaseRegistrationFormContext } from "../../../common/providers/CaseRegistrationProvider"; import styles from "../index.module.scss"; const CancelCaseRegistrationConfirmationPage = () => { + const { state } = useContext(CaseRegistrationFormContext); const navigate = useNavigate(); const { state: { backRoute }, @@ -90,6 +100,10 @@ const CancelCaseRegistrationConfirmationPage = () => { if (!validateFormData()) return; if (formData.cancelRegistrationRadio === "yes") { + telemetryService.trackEvent("JourneyCancelled", [ + { journeyId: state.telemetryData.journeyId }, + { cancelledFrom: backRoute || "unknown" }, + ]); globalThis.location.href = HOME_PAGE_URL; return; } diff --git a/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx b/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx index 6c977621..761fc478 100644 --- a/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx +++ b/src/ui-spa/src/components/case-registration/case-summary-page/index.tsx @@ -146,6 +146,7 @@ const CaseSummaryPage = () => { const requestData = getCaseRegistrationRequestData( state.formData, state.apiData.caseMonitoringCodes!, + state.telemetryData.journeyId, policeUnit, ); diff --git a/src/ui-spa/src/components/case-registration/index.tsx b/src/ui-spa/src/components/case-registration/index.tsx index c24f2b4b..a0978ae4 100644 --- a/src/ui-spa/src/components/case-registration/index.tsx +++ b/src/ui-spa/src/components/case-registration/index.tsx @@ -15,6 +15,8 @@ import { DEFAULT_COMPLEXITY_DESCRIPTION } from "../../common/constants/general"; import useErrorSummaryList from "../../common/hooks/useErrorSummaryList"; import { useNavigate } from "react-router"; import PageContentWrapper from "../common/PageContentWrapper"; +import { v4 as uuidv4 } from "uuid"; +import { telemetryService } from "../../TelemetryLogger"; import styles from "./index.module.scss"; const CaseRegistrationPage = () => { @@ -275,6 +277,15 @@ const CaseRegistrationPage = () => { if (!validateFormData()) return; setDisableBtns(true); + if (!state.telemetryData.journeyId) { + const journeyId = uuidv4(); + dispatch({ + type: "SET_TELEMETRY_JOURNEY_ID", + payload: { journeyId }, + }); + telemetryService.trackEvent("JourneyStarted", [{ journeyId }]); + } + let nextRoute = "/case-registration/areas"; if (state.formData.navigation.fromCaseSummaryPage) { nextRoute = "/case-registration/case-summary"; diff --git a/src/ui-spa/src/mocks/handlers.ts b/src/ui-spa/src/mocks/handlers.ts index 55ed4a13..34bdb5d5 100644 --- a/src/ui-spa/src/mocks/handlers.ts +++ b/src/ui-spa/src/mocks/handlers.ts @@ -134,6 +134,10 @@ export const setupHandlers = (baseUrl: string, apiMockSource: string) => { await delay(RESPONSE_DELAY); return HttpResponse.json(results); }), + http.post(`${baseUrl}/api/v1/telemetry`, async () => { + await delay(RESPONSE_DELAY); + return HttpResponse.json(true); + }), ]; }; diff --git a/src/ui-spa/src/schemas/requests/caseRegistration.ts b/src/ui-spa/src/schemas/requests/caseRegistration.ts index 1f26aff9..6de25bb9 100644 --- a/src/ui-spa/src/schemas/requests/caseRegistration.ts +++ b/src/ui-spa/src/schemas/requests/caseRegistration.ts @@ -7,6 +7,8 @@ export const caseRegistrationRequestDataSchema = z.object({ uniqueRef: z.string(), year: z.number(), }), + journeyId: z.string(), + areaOrDivisionText: z.string(), registeringAreaId: z.number(), registeringUnitId: z.number(), allocatedWcuId: z.number(),