diff --git a/.env b/.env index 6b3a1db4..f993e8ee 100644 --- a/.env +++ b/.env @@ -9,4 +9,6 @@ VITE_VISUALIZE_DISABLED= # Temporary env var used only in vite.config.js to indicate base, in app we use the native import.meta.env.BASE_URL, by default there is no base path ("") # Waiting vite-env support dynamic base path VITE_BASE_PATH= -VITE_REVIEW_IDENTITY_PROVIDER= \ No newline at end of file +VITE_REVIEW_IDENTITY_PROVIDER= +# When VITE_TELEMETRY_DISABLED equals true we disable telemetry. (default: enabled) +VITE_TELEMETRY_DISABLED= diff --git a/eslint.config.js b/eslint.config.js index 783201e7..d951bbf2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,8 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, + // see https://typescript-eslint.netlify.app/rules/no-unused-vars/ + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_$" }], '@typescript-eslint/no-explicit-any': ['off'], '@typescript-eslint/no-namespace': ['off'], 'react-refresh/only-export-components': [ diff --git a/package.json b/package.json index 36eef278..4b3dc9fd 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ "prebuild": "react-dsfr update-icons", "generate-api-client": "tsx scripts/generate-api-client.ts", "test": "vitest run", + "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, "dependencies": { "@codegouvfr/react-dsfr": "^1.13.6", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", - "@inseefr/lunatic": "^3.4.4", - "@inseefr/lunatic-dsfr": "^2.4.1", + "@inseefr/lunatic": "^3.4.7", + "@inseefr/lunatic-dsfr": "^2.4.2", "@mui/material": "^5.16.7", "@tanstack/react-query": "^5.52.0", "@tanstack/react-router": "^1.49.2", @@ -46,6 +47,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/he": "^1.2.3", "@types/node": "^22.7.3", "@types/react": "^18.3.9", diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..4ab3e619 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,63 @@ +import { TelemetryProvider } from '@/contexts/TelemetryContext' +import { OidcProvider } from '@/oidc' +import { routeTree } from '@/router/router' +import { MuiDsfrThemeProvider } from '@codegouvfr/react-dsfr/mui' +import { startReactDsfr } from '@codegouvfr/react-dsfr/spa' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + Link, + RouterProvider, + createRouter, + type LinkProps, +} from '@tanstack/react-router' + +startReactDsfr({ + defaultColorScheme: 'system', + Link, +}) + +declare module '@codegouvfr/react-dsfr/spa' { + interface RegisterLink { + Link: (props: LinkProps) => JSX.Element + } +} + +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + networkMode: 'always', + }, + }, +}) + +const router = createRouter({ + routeTree, + context: { + queryClient, + }, + defaultPreloadStaleTime: 0, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +/** Wraps and inits the providers used in the app */ +export function App() { + return ( + + + + + + + + + + ) +} diff --git a/src/constants/mode.ts b/src/constants/mode.ts new file mode 100644 index 00000000..3bdbb034 --- /dev/null +++ b/src/constants/mode.ts @@ -0,0 +1,5 @@ +export enum MODE_TYPE { + COLLECT = 'collect', + REVIEW = 'review', + VISUALIZE = 'visualize', +} diff --git a/src/constants/page.ts b/src/constants/page.ts new file mode 100644 index 00000000..01f2afad --- /dev/null +++ b/src/constants/page.ts @@ -0,0 +1,6 @@ +export enum PAGE_TYPE { + WELCOME = 'welcomePage', + VALIDATION = 'validationPage', + END = 'endPage', + LUNATIC = 'lunaticPage', +} diff --git a/src/constants/telemetry.ts b/src/constants/telemetry.ts new file mode 100644 index 00000000..05d32a0a --- /dev/null +++ b/src/constants/telemetry.ts @@ -0,0 +1,13 @@ +export enum TELEMETRY_EVENT_TYPE { + CONTACT_SUPPORT = 'contact-support', + CONTROL = 'control', + CONTROL_SKIP = 'control-skip', + EXIT = 'exit', + INIT = 'initialized', + INPUT = 'input', + NEW_PAGE = 'new-page', +} + +export enum TELEMETRY_EVENT_EXIT_SOURCE { + LOGOUT = 'logout', +} diff --git a/src/contexts/TelemetryContext.test.tsx b/src/contexts/TelemetryContext.test.tsx new file mode 100644 index 00000000..dc5076f8 --- /dev/null +++ b/src/contexts/TelemetryContext.test.tsx @@ -0,0 +1,52 @@ +import { computeInitEvent } from '@/utils/telemetry' +import '@testing-library/jest-dom' +import { renderHook } from '@testing-library/react' +import { TelemetryContext, useTelemetry } from './TelemetryContext' + +describe('Telemetry context', () => { + test('push events', () => { + const mock = vi.fn() + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {}, + }} + > + {children} + + ) + + const { result } = renderHook(() => useTelemetry(), { wrapper }) + + const myEvent = computeInitEvent() + result.current.pushEvent(myEvent) + + expect(mock).toHaveBeenCalledWith(myEvent) + }) + + test('set default values', () => { + const mock = vi.fn() + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {}, + setDefaultValues: mock, + }} + > + {children} + + ) + + const { result } = renderHook(() => useTelemetry(), { wrapper }) + + const myValues = { idSU: 'abc' } + result.current.setDefaultValues(myValues) + + expect(mock).toHaveBeenCalledWith(myValues) + }) +}) diff --git a/src/contexts/TelemetryContext.tsx b/src/contexts/TelemetryContext.tsx new file mode 100644 index 00000000..0f5996e7 --- /dev/null +++ b/src/contexts/TelemetryContext.tsx @@ -0,0 +1,104 @@ +/* eslint-disable react-refresh/only-export-components */ +import { addParadata } from '@/api/07-paradata-events' +import { useBatch } from '@/shared/hooks/useBatch' +import type { + DefaultParadataValues, + TelemetryEvent, + TelemetryParadata, +} from '@/types/telemetry' +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' + +type TelemetryContextType = { + isTelemetryDisabled: boolean + pushEvent: (e: TelemetryParadata) => void | Promise + setDefaultValues: (e: DefaultParadataValues) => void + triggerBatchTelemetryCallback?: () => Promise +} + +/** Mandatory values used as a context's last-resort fallback */ +const defaultValues = { + isTelemetryDisabled: true, + pushEvent: (_: TelemetryParadata) => {}, + setDefaultValues: (_: DefaultParadataValues) => {}, +} + +/** + * Exposes shared functions to handle telemetry events. + * + * Should be used with the useBatch hook to reduce external API load. + */ +export const TelemetryContext: React.Context = + createContext(defaultValues) + +/** + * Returns the current telemetry context value. + * + * @version 1.2.4 + * @see https://react.dev/reference/react/useContext + */ +export function useTelemetry() { + return useContext(TelemetryContext) +} + +/** Initializes the telemetry context with a batch system */ +export function TelemetryProvider({ + children, +}: Readonly<{ + children: React.ReactElement +}>) { + const isTelemetryDisabled = import.meta.env.VITE_TELEMETRY_DISABLED === 'true' + + const [defaultValues, setDefaultValues] = useState({ + userAgent: navigator.userAgent, + }) + + /** Push events to telemetry API after an arbitrary number of events or a delay. */ + const pushEvents = useCallback(async (events: Array) => { + if (events.length > 0) { + return addParadata({ idSU: events[0].idSU, events }) + } + }, []) + + const { addDatum, triggerTimeoutEvent } = useBatch(pushEvents) + + /** Add the event to a batch mechanism. */ + const pushEvent = useCallback( + (event: TelemetryParadata) => { + addDatum({ ...defaultValues, ...event }) + }, + [addDatum, defaultValues] + ) + + /** Add values that will be appended to every telemetry event (e.g. user id) */ + const updateDefaultValues = useCallback( + (newDefaultValues: DefaultParadataValues) => { + setDefaultValues((oldValues: DefaultParadataValues) => ({ + ...oldValues, + ...newDefaultValues, + })) + }, + [] + ) + + const telemetryContextValues = useMemo( + () => ({ + isTelemetryDisabled, + pushEvent, + setDefaultValues: updateDefaultValues, + triggerBatchTelemetryCallback: triggerTimeoutEvent, + }), + [isTelemetryDisabled, pushEvent, triggerTimeoutEvent, updateDefaultValues] + ) + + return ( + + {children} + + ) +} diff --git a/src/i18n/resources/en.tsx b/src/i18n/resources/en.tsx index 3227dea1..a30eb1d0 100644 --- a/src/i18n/resources/en.tsx +++ b/src/i18n/resources/en.tsx @@ -1,3 +1,4 @@ +import { PAGE_TYPE } from '@/constants/page' import type { Translations } from '@/i18n/types' export const translations: Translations<'en'> = { @@ -90,23 +91,20 @@ export const translations: Translations<'en'> = { SurveyContainer: { 'button continue label': ({ currentPage }) => { switch (currentPage) { - case 'welcomePage': + case PAGE_TYPE.WELCOME: return 'Start' - case 'lunaticPage': + case PAGE_TYPE.LUNATIC: return 'Continue' - case 'endPage': + case PAGE_TYPE.END: return 'Download the receipt' - case 'validationPage': + case PAGE_TYPE.VALIDATION: return 'Submit my responses' } }, 'button continue title': ({ currentPage }) => { - switch (currentPage) { - case 'endPage': - return 'Download the acknowledgment of receipt' - default: - return 'Proceed to the next step' - } + if (currentPage === PAGE_TYPE.END) + return 'Download the acknowledgment of receipt' + return 'Proceed to the next step' }, 'button download': 'Download data', 'button expand': 'Expand view', diff --git a/src/i18n/resources/fr.tsx b/src/i18n/resources/fr.tsx index 249a5d5a..f320c60a 100644 --- a/src/i18n/resources/fr.tsx +++ b/src/i18n/resources/fr.tsx @@ -1,3 +1,4 @@ +import { PAGE_TYPE } from '@/constants/page' import type { Translations } from '@/i18n/types' export const translations: Translations<'fr'> = { @@ -89,23 +90,20 @@ export const translations: Translations<'fr'> = { SurveyContainer: { 'button continue label': ({ currentPage }) => { switch (currentPage) { - case 'welcomePage': + case PAGE_TYPE.WELCOME: return 'Commencer' - case 'lunaticPage': + case PAGE_TYPE.LUNATIC: return 'Continuer' - case 'endPage': + case PAGE_TYPE.END: return "Télécharger l'accusé de réception" - case 'validationPage': + case PAGE_TYPE.VALIDATION: return 'Envoyer mes réponses' } }, 'button continue title': ({ currentPage }) => { - switch (currentPage) { - case 'endPage': - return "Télécharger l'accusé de réception" - default: - return "Passer à l'étape suivante" - } + if (currentPage === PAGE_TYPE.END) + return "Télécharger l'accusé de réception" + return "Passer à l'étape suivante" }, 'button download': 'Télécharger les données', diff --git a/src/i18n/resources/sq.tsx b/src/i18n/resources/sq.tsx index e932ca11..8902087d 100644 --- a/src/i18n/resources/sq.tsx +++ b/src/i18n/resources/sq.tsx @@ -1,3 +1,4 @@ +import { PAGE_TYPE } from '@/constants/page' import type { Translations } from '@/i18n/types' export const translations: Translations<'sq'> = { @@ -90,23 +91,20 @@ export const translations: Translations<'sq'> = { SurveyContainer: { 'button continue label': ({ currentPage }) => { switch (currentPage) { - case 'welcomePage': + case PAGE_TYPE.WELCOME: return 'Fillo' - case 'lunaticPage': + case PAGE_TYPE.LUNATIC: return 'Vazhdo' - case 'endPage': + case PAGE_TYPE.END: return 'Shkarko njoftimin për pranimin' - case 'validationPage': + case PAGE_TYPE.VALIDATION: return 'Dërgo përgjigjet e mia' } }, 'button continue title': ({ currentPage }) => { - switch (currentPage) { - case 'endPage': - return 'Shkarkoni konfirmimin e pranimit' - default: - return 'Kaloni në hapin tjetër' - } + if (currentPage === PAGE_TYPE.END) + return 'Shkarkoni konfirmimin e pranimit' + return 'Kaloni në hapin tjetër' }, 'button download': 'Shkarko të dhënat', 'button expand': 'Zgjero pamjen', diff --git a/src/main.tsx b/src/main.tsx index ff828e23..ee3f7454 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,61 +1,9 @@ -import { OidcProvider } from '@/oidc' -import { routeTree } from '@/router/router' -import { MuiDsfrThemeProvider } from '@codegouvfr/react-dsfr/mui' -import { startReactDsfr } from '@codegouvfr/react-dsfr/spa' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - Link, - RouterProvider, - createRouter, - type LinkProps, -} from '@tanstack/react-router' import React from 'react' import ReactDOM from 'react-dom/client' - -startReactDsfr({ - defaultColorScheme: 'system', - Link, -}) - -declare module '@codegouvfr/react-dsfr/spa' { - interface RegisterLink { - Link: (props: LinkProps) => JSX.Element - } -} - -const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - networkMode: 'always', - }, - }, -}) - -const router = createRouter({ - routeTree, - context: { - queryClient, - }, - defaultPreloadStaleTime: 0, -}) - -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} +import { App } from './App' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + ) diff --git a/src/model/Page.ts b/src/model/Page.ts index 82e9b332..fa53d67a 100644 --- a/src/model/Page.ts +++ b/src/model/Page.ts @@ -1,7 +1,11 @@ +import { PAGE_TYPE } from '@/constants/page' import type { LunaticState } from '@inseefr/lunatic' -export type StromaePage = 'welcomePage' | 'validationPage' | 'endPage' +export type StromaePage = + | PAGE_TYPE.WELCOME + | PAGE_TYPE.VALIDATION + | PAGE_TYPE.END export type PageType = StromaePage | LunaticState['pageTag'] -export type InternalPageType = StromaePage | 'lunaticPage' +export type InternalPageType = StromaePage | PAGE_TYPE.LUNATIC diff --git a/src/model/SurveyUnitData.ts b/src/model/SurveyUnitData.ts index e0c1868c..28f8053a 100644 --- a/src/model/SurveyUnitData.ts +++ b/src/model/SurveyUnitData.ts @@ -5,9 +5,10 @@ import type { StateData } from './StateData' import type { SurveyUnit } from './api' export type SurveyUnitData = { - stateData?: StateData data?: LunaticData + id?: string personalization?: Array<{ name: string; value: string }> + stateData?: StateData } assert>() diff --git a/src/oidc.tsx b/src/oidc.tsx index b9527155..9a2b71fa 100644 --- a/src/oidc.tsx +++ b/src/oidc.tsx @@ -10,6 +10,7 @@ export const { OidcProvider, useOidc, getOidc } = issuerUri: import.meta.env.VITE_OIDC_ISSUER, publicUrl: import.meta.env.BASE_URL, decodedIdTokenSchema: z.object({ + sid: z.string(), sub: z.string(), preferred_username: z.string(), }), diff --git a/src/pages/Collect/CollectPage.tsx b/src/pages/Collect/CollectPage.tsx index 2dd64979..630cb81b 100644 --- a/src/pages/Collect/CollectPage.tsx +++ b/src/pages/Collect/CollectPage.tsx @@ -4,6 +4,7 @@ import { getGetSurveyUnitByIdQueryKey, updateSurveyUnitDataStateDataById, } from '@/api/06-survey-units' +import { MODE_TYPE } from '@/constants/mode' import type { StateData } from '@/model/StateData' import { Orchestrator } from '@/shared/components/Orchestrator/Orchestrator' import type { @@ -94,7 +95,7 @@ export const CollectPage = memo(function CollectPage() { return ( protectedRouteLoader(), validateSearch: collectSearchParams, - loader: ({ + loader: async ({ params: { questionnaireId, surveyUnitId }, context: { queryClient }, abortController, diff --git a/src/pages/Review/ReviewPage.tsx b/src/pages/Review/ReviewPage.tsx index be1cb073..4b1f1f76 100644 --- a/src/pages/Review/ReviewPage.tsx +++ b/src/pages/Review/ReviewPage.tsx @@ -1,4 +1,5 @@ import { getGetNomenclatureByIdQueryOptions } from '@/api/04-nomenclatures' +import { MODE_TYPE } from '@/constants/mode' import { Orchestrator } from '@/shared/components/Orchestrator/Orchestrator' import type { LunaticGetReferentiel, @@ -22,7 +23,7 @@ export const ReviewPage = memo(function ReviewPage() { return ( rootRoute, diff --git a/src/pages/Visualize/Visualize.tsx b/src/pages/Visualize/Visualize.tsx index 232db4f1..d57ecbe9 100644 --- a/src/pages/Visualize/Visualize.tsx +++ b/src/pages/Visualize/Visualize.tsx @@ -1,3 +1,4 @@ +import { MODE_TYPE } from '@/constants/mode' import { Orchestrator } from '@/shared/components/Orchestrator/Orchestrator' import type { LunaticGetReferentiel } from '@/shared/components/Orchestrator/utils/lunaticType' import { nomenclatureQueryOptions } from '@/shared/query/visualizeQueryOptions' @@ -32,7 +33,7 @@ export const VisualizePage = memo(function VisualizePage() { return ( ()({}) + +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + networkMode: 'always', + }, + }, +}) + +export const router = createRouter({ + routeTree: rootRoute, + context: { + queryClient, + }, +}) diff --git a/src/router/router.tsx b/src/router/router.tsx index 9a519a4a..76487b02 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,3 +1,5 @@ +import { useTelemetry } from '@/contexts/TelemetryContext' +import { useOidc } from '@/oidc' import { accessibilityRoute } from '@/pages/Accessibility/route' import { collectRoute } from '@/pages/Collect/route' import { legalsRoute } from '@/pages/Legals/route' @@ -17,11 +19,28 @@ import { Outlet, ScrollRestoration, } from '@tanstack/react-router' -import { memo } from 'react' +import { decodeJwt } from 'oidc-spa/tools/decodeJwt' +import { memo, useEffect, useState } from 'react' import { Toaster } from 'react-hot-toast' // eslint-disable-next-line react-refresh/only-export-components const RootComponent = memo(() => { + const { oidcTokens } = useOidc() + const { isTelemetryDisabled, setDefaultValues } = useTelemetry() + const [sid, setSID] = useState(undefined) + + useEffect(() => { + setSID(oidcTokens ? decodeJwt(oidcTokens?.idToken)?.sid : undefined) + }, [oidcTokens]) + + useEffect(() => { + if (!isTelemetryDisabled && sid) { + // Retrieve the OIDC's session id (different for each session of the user + // agent used by the end-user which allows to identify distinct sessions) + setDefaultValues({ sid }) + } + }, [isTelemetryDisabled, sid, setDefaultValues]) + return (
{ + it('triggers telemetry contact support event', async () => { + const user = userEvent.setup() + const pushEvent = vi.fn() + + vi.mocked(useMode).mockReturnValueOnce(MODE_TYPE.COLLECT) + + const { getAllByText } = renderWithRouter( + + {}, + }} + > +
+ + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + + const e = getAllByText('Contact support')[0] + await user.click(e) + + await waitFor(() => expect(pushEvent).toHaveBeenCalledOnce()) + await waitFor(() => + expect(pushEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: TELEMETRY_EVENT_TYPE.CONTACT_SUPPORT, + }) + ) + ) + }) + + it('does not trigger telemetry contact support event when not in collect mode', async () => { + const user = userEvent.setup() + const pushEvent = vi.fn() + + vi.mocked(useMode).mockReturnValueOnce(MODE_TYPE.VISUALIZE) + + const { getAllByText } = renderWithRouter( + + {}, + }} + > +
+ + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + + const e = getAllByText('Contact support')[0] + await user.click(e) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + }) + + it('does not trigger telemetry contact support event when telemetry is disabled', async () => { + const user = userEvent.setup() + const pushEvent = vi.fn() + + vi.mocked(useMode).mockReturnValueOnce(MODE_TYPE.COLLECT) + + const { getAllByText } = renderWithRouter( + + {}, + }} + > +
+ + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + + const e = getAllByText('Contact support')[0] + await user.click(e) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + }) +}) diff --git a/src/shared/components/Layout/Header.tsx b/src/shared/components/Layout/Header.tsx index f01795fb..153ceed3 100644 --- a/src/shared/components/Layout/Header.tsx +++ b/src/shared/components/Layout/Header.tsx @@ -1,3 +1,6 @@ +import { MODE_TYPE } from '@/constants/mode' +import { TELEMETRY_EVENT_EXIT_SOURCE } from '@/constants/telemetry' +import { useTelemetry } from '@/contexts/TelemetryContext' import { declareComponentKeys, useResolveLocalizedString, @@ -6,10 +9,12 @@ import { import { useOidc } from '@/oidc' import { collectPath } from '@/pages/Collect/route' import { executePreLogoutActions } from '@/shared/hooks/prelogout' +import { useMode } from '@/shared/hooks/useMode' import { useMetadataStore } from '@/shared/metadataStore/useMetadataStore' +import { computeContactSupportEvent, computeExitEvent } from '@/utils/telemetry' import { headerFooterDisplayItem } from '@codegouvfr/react-dsfr/Display' import { Header as DsfrHeader } from '@codegouvfr/react-dsfr/Header' -import { useMatchRoute, useSearch } from '@tanstack/react-router' +import { useSearch } from '@tanstack/react-router' export function Header() { const { t } = useTranslation({ Header }) @@ -18,18 +23,20 @@ export function Header() { useResolveLocalizedString({ labelWhenMismatchingLanguage: true, }) + const mode = useMode() const { label: serviceTitle, mainLogo, surveyUnitIdentifier, } = useMetadataStore() + const { isTelemetryDisabled, pushEvent, triggerBatchTelemetryCallback } = + useTelemetry() /** * There is an issue with this part of the code: the search type is not well narrowed with isCollectRoute. I'm waiting for a better solution. */ - const matchRoute = useMatchRoute() - const isCollectRoute = !!matchRoute({ to: collectPath }) + const isCollectRoute = mode === MODE_TYPE.COLLECT const search = useSearch({ strict: false }) return ( @@ -54,6 +61,12 @@ export function Header() { ? `${import.meta.env.VITE_PORTAIL_URL}${search?.['pathAssistance'] ?? ''}` : '', disabled: isCollectRoute, + onClick: + isCollectRoute && !isTelemetryDisabled + ? () => { + pushEvent(computeContactSupportEvent()) + } + : undefined, }, text: t('quick access support'), }, @@ -65,6 +78,16 @@ export function Header() { buttonProps: { onClick: async () => { await executePreLogoutActions() + if (!isTelemetryDisabled) { + pushEvent( + computeExitEvent({ + source: TELEMETRY_EVENT_EXIT_SOURCE.LOGOUT, + }) + ) + if (triggerBatchTelemetryCallback) { + await triggerBatchTelemetryCallback() + } + } logout({ redirectTo: 'specific url', url: `${import.meta.env.VITE_PORTAIL_URL}${search?.['pathLogout'] ?? ''}`, diff --git a/src/shared/components/Orchestrator/CustomPages/EndPage.tsx b/src/shared/components/Orchestrator/CustomPages/EndPage.tsx index b4e1f51e..52b77615 100644 --- a/src/shared/components/Orchestrator/CustomPages/EndPage.tsx +++ b/src/shared/components/Orchestrator/CustomPages/EndPage.tsx @@ -1,4 +1,5 @@ import { declareComponentKeys, useTranslation } from '@/i18n' +import type { StateData } from '@/model/StateData' import { useDocumentTitle } from '@/shared/hooks/useDocumentTitle' import { fr } from '@codegouvfr/react-dsfr' @@ -12,7 +13,7 @@ export function EndPage({ state, }: Readonly<{ date?: number - state?: 'INIT' | 'COMPLETED' | 'VALIDATED' | 'TOEXTRACT' | 'EXTRACTED' + state?: StateData['state'] }>) { const { t } = useTranslation({ EndPage }) const formattedDate = date ? new Date(date).toLocaleString() : undefined diff --git a/src/shared/components/Orchestrator/CustomPages/WelcomeModal.tsx b/src/shared/components/Orchestrator/CustomPages/WelcomeModal.tsx index 70dd278e..ab755c31 100644 --- a/src/shared/components/Orchestrator/CustomPages/WelcomeModal.tsx +++ b/src/shared/components/Orchestrator/CustomPages/WelcomeModal.tsx @@ -28,7 +28,7 @@ export function WelcomeModal({ goBack, open }: Props) { modal.open() wasDisplayed.current = true } - }, 10) + }, 50) }, [open]) return ( diff --git a/src/shared/components/Orchestrator/Orchestrator.test.tsx b/src/shared/components/Orchestrator/Orchestrator.test.tsx new file mode 100644 index 00000000..7f5b5e4c --- /dev/null +++ b/src/shared/components/Orchestrator/Orchestrator.test.tsx @@ -0,0 +1,232 @@ +import { MODE_TYPE } from '@/constants/mode' +import { TELEMETRY_EVENT_TYPE } from '@/constants/telemetry' +import { TelemetryContext } from '@/contexts/TelemetryContext' +import { renderWithRouter } from '@/utils/tests' +import { act, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect } from 'vitest' +import { Orchestrator } from './Orchestrator' + +describe('Orchestrator', () => { + const surveyUnitData = { + stateData: undefined, + data: undefined, + personalization: undefined, + id: 'my-service-unit-id', + } + const metadata = { + label: 'my label', + objectives: 'my objectives', + mainLogo: { label: 'logo label', url: '' }, + surveyUnitIdentifier: 'my survey id', + } + const source = { + components: [ + { + componentType: 'Sequence', + page: '1', + id: 's1', + }, + { + componentType: 'Question', + page: '1', + components: [ + { + componentType: 'Input', + page: '1', + label: { value: 'my-question', type: 'TXT' }, + id: 'i1', + response: { name: 'my-question-input' }, + }, + ], + }, + ], + variables: [], + } + const OrchestratorTestWrapper = ({ + mode, + }: { + mode: MODE_TYPE.COLLECT | MODE_TYPE.REVIEW | MODE_TYPE.VISUALIZE + }) => ( + { + return new Promise(() => []) + }} + updateDataAndStateData={() => { + return new Promise(() => {}) + }} + getDepositProof={() => { + return new Promise(() => {}) + }} + /> + ) + + it('sets idSU as default value', async () => { + const setDefaultValues = vi.fn() + + renderWithRouter( + {}, + setDefaultValues, + }} + > + + + ) + + await waitFor(() => expect(setDefaultValues).toHaveBeenCalledOnce()) + await waitFor(() => + expect(setDefaultValues).toHaveBeenCalledWith({ + idSU: 'my-service-unit-id', + }) + ) + }) + + it('triggers telemetry init event', async () => { + const pushEvent = vi.fn() + + renderWithRouter( + {}, + }} + > + + + ) + + await waitFor(() => expect(pushEvent).toHaveBeenCalledOnce()) + await waitFor(() => + expect(pushEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: TELEMETRY_EVENT_TYPE.INIT, + }) + ) + ) + }) + + it('does not trigger telemetry event in visualize mode', async () => { + const pushEvent = vi.fn() + + const { getByText } = renderWithRouter( + {}, + }} + > + + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + act(() => getByText('Start').click()) + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + }) + + it('does not trigger telemetry event in review mode', async () => { + const pushEvent = vi.fn() + + const { getByText } = renderWithRouter( + {}, + }} + > + + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + act(() => getByText('Start').click()) + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + }) + + it('does not trigger telemetry event if disabled', async () => { + const pushEvent = vi.fn() + + const { getByText } = renderWithRouter( + {}, + }} + > + + + ) + + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + act(() => getByText('Start').click()) + await waitFor(() => expect(pushEvent).not.toHaveBeenCalled()) + }) + + it('triggers telemetry next page event', async () => { + const pushEvent = vi.fn() + + const { getByText } = renderWithRouter( + {}, + }} + > + + + ) + + act(() => getByText('Start').click()) + + await waitFor(() => expect(pushEvent).toHaveBeenCalledTimes(2)) + expect(pushEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: TELEMETRY_EVENT_TYPE.NEW_PAGE, + }) + ) + }) + + it('triggers telemetry input event', async () => { + const pushEvent = vi.fn() + const user = userEvent.setup() + + const { getByText } = renderWithRouter( + {}, + }} + > + + + ) + + act(() => getByText('Start').click()) + + const e = getByText('my-question') + await user.click(e) + await user.keyboard('f') + + await new Promise((r) => setTimeout(r, 1000)) + await waitFor(() => expect(pushEvent).toHaveBeenCalledTimes(3)) + expect(pushEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: TELEMETRY_EVENT_TYPE.INPUT, + }) + ) + }) +}) diff --git a/src/shared/components/Orchestrator/Orchestrator.tsx b/src/shared/components/Orchestrator/Orchestrator.tsx index d5381ca8..e8dd9a82 100644 --- a/src/shared/components/Orchestrator/Orchestrator.tsx +++ b/src/shared/components/Orchestrator/Orchestrator.tsx @@ -1,23 +1,35 @@ +import { MODE_TYPE } from '@/constants/mode' +import { PAGE_TYPE } from '@/constants/page' +import { useTelemetry } from '@/contexts/TelemetryContext' import type { Metadata } from '@/model/Metadata' import type { StateData } from '@/model/StateData' import type { SurveyUnitData } from '@/model/SurveyUnitData' +import { usePushEventAfterInactivity } from '@/shared/components/Orchestrator/usePushEventAfterInactivity' import { useAddPreLogoutAction } from '@/shared/hooks/prelogout' import { usePrevious } from '@/shared/hooks/usePrevious' import { downloadAsJson } from '@/utils/downloadAsJson' import { isObjectEmpty } from '@/utils/isObjectEmpty' import { hasBeenSent, shouldDisplayWelcomeModal } from '@/utils/orchestrator' +import { + computeControlEvent, + computeControlSkipEvent, + computeInitEvent, + computeInputEvent, + computeNewPageEvent, +} from '@/utils/telemetry' import { useRefSync } from '@/utils/useRefSync' import { useUpdateEffect } from '@/utils/useUpdateEffect' import { fr } from '@codegouvfr/react-dsfr' import { LunaticComponents, useLunatic, + type LunaticChangesHandler, type LunaticData, type LunaticError, type LunaticSource, } from '@inseefr/lunatic' import { useNavigate } from '@tanstack/react-router' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EndPage } from './CustomPages/EndPage' import { ValidationModal } from './CustomPages/ValidationModal' import { ValidationPage } from './CustomPages/ValidationPage' @@ -28,13 +40,10 @@ import { VTLDevTools } from './VTLDevTools/VTLDevtools' import { createLunaticLogger } from './VTLDevTools/VTLErrorStore' import { slotComponents } from './slotComponents' import { useStromaeNavigation } from './useStromaeNavigation' +import { computeLunaticComponents } from './utils/components' import { isBlockingError, isSameErrors } from './utils/controls' import { trimCollectedData } from './utils/data' -import type { - LunaticComponentsProps, - LunaticGetReferentiel, - LunaticPageTag, -} from './utils/lunaticType' +import type { LunaticGetReferentiel, LunaticPageTag } from './utils/lunaticType' import { scrollAndFocusToFirstError } from './utils/scrollAndFocusToFirstError' import { isSequencePage } from './utils/sequence' @@ -56,27 +65,33 @@ export type OrchestratorProps = OrchestratorProps.Common & export namespace OrchestratorProps { export type Common = { + /** Questionnaire data consumed by Lunatic to make its components */ source: LunaticSource + /** Initial survey unit data when we initialize the orchestrator */ surveyUnitData: SurveyUnitData | undefined + /** Allows to fetch nomenclature by id */ getReferentiel: LunaticGetReferentiel + /** Survey unit metadata */ metadata: Metadata } export type Visualize = { - mode: 'visualize' + mode: MODE_TYPE.VISUALIZE } export type Review = { - mode: 'review' + mode: MODE_TYPE.REVIEW } export type Collect = { - mode: 'collect' + mode: MODE_TYPE.COLLECT + /** Updates data with the modified data and survey state */ updateDataAndStateData: (params: { stateData: StateData data: LunaticData['COLLECTED'] onSuccess?: () => void }) => Promise + /** Allows user to download a deposit proof PDF */ getDepositProof: () => Promise } } @@ -84,22 +99,75 @@ export namespace OrchestratorProps { export function Orchestrator(props: OrchestratorProps) { const { source, surveyUnitData, getReferentiel, mode, metadata } = props - const initialCurrentPage = surveyUnitData?.stateData?.currentPage - const initialState = surveyUnitData?.stateData?.state - const pagination = source.pagination ?? 'question' + // Allow to send telemetry events once survey unit id has been set + const [isTelemetryActivated, setIsTelemetryActivated] = + useState(false) + + const navigate = useNavigate() + const { + isTelemetryDisabled, + pushEvent, + setDefaultValues, + triggerBatchTelemetryCallback, + } = useTelemetry() + const { setEventToPushAfterInactivity, triggerInactivityTimeoutEvent } = + usePushEventAfterInactivity(pushEvent) const containerRef = useRef(null) const contentRef = useRef(null) const pageTagRef = useRef('1') + const validationModalActionsRef = useRef({ + open: () => Promise.resolve(), + }) + + const initialCurrentPage = surveyUnitData?.stateData?.currentPage + const initialState = surveyUnitData?.stateData?.state + const pagination = source.pagination ?? 'question' + + /** Displays the welcome modal which allows to come back to current page */ + const shouldWelcome = shouldDisplayWelcomeModal( + initialState, + initialCurrentPage + ) const lunaticLogger = useMemo( () => - mode === 'visualize' + mode === MODE_TYPE.VISUALIZE ? createLunaticLogger({ pageTag: pageTagRef }) : undefined, [mode] ) + /** Triggers telemetry input event on Lunatic change */ + const handleLunaticChange: LunaticChangesHandler = useCallback( + (changes) => { + if (changes.length === 1) { + // could be a text input, we only send the event once user has stopped + // actively typing since Lunatic triggers its onChange on every input + const { name, value, iteration } = changes[0] + setEventToPushAfterInactivity( + computeInputEvent({ + value: value, + name: name, + iteration: iteration, + }) + ) + } else { + for (const { name, value, iteration } of changes) { + // weird inputs, probably not text input, push everything + pushEvent( + computeInputEvent({ + value: value, + name: name, + iteration: iteration, + }) + ) + } + } + }, + [pushEvent, setEventToPushAfterInactivity] + ) + const { getComponents, Provider: LunaticProvider, @@ -119,8 +187,9 @@ export function Orchestrator(props: OrchestratorProps) { activeControls: true, getReferentiel, autoSuggesterLoading: true, - trackChanges: mode === 'collect', + trackChanges: mode === MODE_TYPE.COLLECT, withOverview: true, + onChange: isTelemetryActivated ? handleLunaticChange : undefined, }) pageTagRef.current = pageTag @@ -133,16 +202,6 @@ export function Orchestrator(props: OrchestratorProps) { Record | undefined >(undefined) - useEffect(() => { - if (activeErrors) { - scrollAndFocusToFirstError() - } - }, [activeErrors]) - - const validationModalActionsRef = useRef({ - open: () => Promise.resolve(), - }) - // Decorates goNext function with controls behavior const goNextWithControls = (goNext: () => void) => { const { currentErrors } = compileControls() @@ -154,19 +213,31 @@ export function Orchestrator(props: OrchestratorProps) { return } - // An error is blocking, we stay on the page - if (isBlockingError(currentErrors)) { - //compileControls returns isCritical but I prefer define my own rules of blocking error in the orchestrator - setActiveErrors(currentErrors) - return - } - // activeErrors and currentErrors are the same and no blocking error, we go next - if (isSameErrors(currentErrors, activeErrors)) { + if ( + !isBlockingError(currentErrors) && + isSameErrors(currentErrors, activeErrors) + ) { + if (isTelemetryActivated) { + pushEvent( + computeControlSkipEvent({ + controlIds: Object.keys(currentErrors), + }) + ) + } setActiveErrors(undefined) goNext() return } + + // display the errors to the user + if (isTelemetryActivated) { + pushEvent( + computeControlEvent({ + controlIds: Object.keys(currentErrors), + }) + ) + } setActiveErrors(currentErrors) } @@ -185,17 +256,18 @@ export function Orchestrator(props: OrchestratorProps) { const getCurrentStateData = useRefSync((): StateData => { switch (currentPage) { - case 'endPage': + case PAGE_TYPE.END: return { date: Date.now(), currentPage, state: 'VALIDATED' } - case 'lunaticPage': + case PAGE_TYPE.LUNATIC: return { date: Date.now(), currentPage: pageTag, state: 'INIT' } - case 'validationPage': - case 'welcomePage': + case PAGE_TYPE.VALIDATION: + case PAGE_TYPE.WELCOME: default: return { date: Date.now(), currentPage, state: 'INIT' } } }) + /** Allows to download data for visualize */ const downloadAsJsonRef = useRefSync(() => { downloadAsJson({ dataToDownload: { @@ -209,7 +281,7 @@ export function Orchestrator(props: OrchestratorProps) { }) const triggerDataAndStateUpdate = () => { - if (mode === 'collect' && !hasBeenSent(initialState)) { + if (mode === MODE_TYPE.COLLECT && !hasBeenSent(initialState)) { const stateData = getCurrentStateData.current() const data = getChangedData() @@ -217,8 +289,8 @@ export function Orchestrator(props: OrchestratorProps) { const isCollectedDataEmpty = isObjectEmpty(data.COLLECTED ?? {}) if ( isCollectedDataEmpty && - (currentPage === 'lunaticPage' - ? previousPage === 'lunaticPage' && previousPageTag === pageTag + (currentPage === PAGE_TYPE.LUNATIC + ? previousPage === PAGE_TYPE.LUNATIC && previousPageTag === pageTag : stateData.currentPage === previousPage) ) { // no change, no need to push anything @@ -239,12 +311,46 @@ export function Orchestrator(props: OrchestratorProps) { } } + // Telemetry initialization + useEffect(() => { + if (!isTelemetryDisabled && mode === MODE_TYPE.COLLECT) { + setDefaultValues({ idSU: surveyUnitData?.id }) + setIsTelemetryActivated(true) + } + }, [isTelemetryDisabled, mode, setDefaultValues, surveyUnitData?.id]) + + // Initialization + useEffect(() => { + if (isTelemetryActivated) { + pushEvent(computeInitEvent()) + } + }, [isTelemetryActivated, pushEvent]) + + useEffect(() => { + if (activeErrors) { + scrollAndFocusToFirstError() + } + }, [activeErrors]) + useAddPreLogoutAction(async () => { + if (isTelemetryActivated) { + triggerInactivityTimeoutEvent() + } triggerDataAndStateUpdate() }) - //When page change + // On page change useUpdateEffect(() => { + if (isTelemetryActivated) { + triggerInactivityTimeoutEvent() + pushEvent( + computeNewPageEvent({ + page: currentPage, + pageTag, + }) + ) + } + //Reset scroll to the container when the top is not visible if ( containerRef.current && @@ -267,68 +373,47 @@ export function Orchestrator(props: OrchestratorProps) { // Persist data when component unmount (ie when navigate etc...) useEffect(() => { return () => { + if (isTelemetryActivated) { + triggerInactivityTimeoutEvent() + if (triggerBatchTelemetryCallback) { + ;(async () => { + await triggerBatchTelemetryCallback() + })() + } + } triggerDataAndStateUpdate() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const navigate = useNavigate() + const { components, bottomComponents } = computeLunaticComponents( + getComponents(), + pagination + ) const handleDepositProofClick = async () => { switch (mode) { - case 'visualize': { + case MODE_TYPE.VISUALIZE: { downloadAsJsonRef.current() navigate({ to: '/visualize', params: {} }) break } - case 'collect': { + case MODE_TYPE.COLLECT: { return props.getDepositProof() } - case 'review': + case MODE_TYPE.REVIEW: default: break } } - const { components, bottomComponents } = getComponents().reduce<{ - components: LunaticComponentsProps - bottomComponents: LunaticComponentsProps - }>( - (acc, c) => { - // In sequence pagination we do not want to display Sequence components - if (pagination === 'sequence' && c.componentType === 'Sequence') { - return acc // Skip this component - } - - // We want to be able to display at the bottom components with position "bottom" - if (c.position === 'bottom') { - return { - components: acc.components, - bottomComponents: [...acc.bottomComponents, c], - } - } - - return { - components: [...acc.components, c], - bottomComponents: acc.bottomComponents, - } - }, - { components: [], bottomComponents: [] } - ) - - // Displays the welcome modal which allows to come back to current page - const shouldWelcome = shouldDisplayWelcomeModal( - initialState, - initialCurrentPage - ) - return (
0 && (
- {currentPage === 'lunaticPage' && ( + {currentPage === PAGE_TYPE.LUNATIC && (
- {currentPage === 'welcomePage' && ( + {currentPage === PAGE_TYPE.WELCOME && ( )} - {currentPage === 'lunaticPage' && ( + {currentPage === PAGE_TYPE.LUNATIC && ( )} - {currentPage === 'validationPage' && } - {currentPage === 'endPage' ? ( + {currentPage === PAGE_TYPE.VALIDATION && } + {currentPage === PAGE_TYPE.END && ( - ) : null} + )} initialCurrentPage @@ -377,7 +462,7 @@ export function Orchestrator(props: OrchestratorProps) { open={shouldWelcome} /> - {mode === 'visualize' && } + {mode === MODE_TYPE.VISUALIZE && }
diff --git a/src/shared/components/Orchestrator/SurveyContainer.tsx b/src/shared/components/Orchestrator/SurveyContainer.tsx index 8ecac1ac..452394d8 100644 --- a/src/shared/components/Orchestrator/SurveyContainer.tsx +++ b/src/shared/components/Orchestrator/SurveyContainer.tsx @@ -1,3 +1,5 @@ +import { MODE_TYPE } from '@/constants/mode' +import { PAGE_TYPE } from '@/constants/page' import { declareComponentKeys, useTranslation } from '@/i18n' import type { InternalPageType } from '@/model/Page' import { fr } from '@codegouvfr/react-dsfr' @@ -37,68 +39,63 @@ export function SurveyContainer( const { t } = useTranslation({ SurveyContainer }) - const isPreviousButtonDisplayed = ['welcomePage', 'endPage'].includes( + const isPreviousButtonDisplayed = [PAGE_TYPE.WELCOME, PAGE_TYPE.END].includes( currentPage ) const [isLayoutExpanded, setIsLayoutExpanded] = useState(false) - const displaySequenceHeader = !isSequencePage && currentPage === 'lunaticPage' + const displaySequenceHeader = + !isSequencePage && currentPage === PAGE_TYPE.LUNATIC return ( <> {!isPreviousButtonDisplayed && ( - <> -
- {displaySequenceHeader && ( - - )} -
-
+
+ {displaySequenceHeader && ( + + )} +
+
+ +
+ + {pagination === 'sequence' && currentPage === PAGE_TYPE.LUNATIC && ( +
+ iconId={ + isLayoutExpanded + ? 'ri-collapse-diagonal-line' + : 'ri-expand-diagonal-line' + } + priority="tertiary" + onClick={() => setIsLayoutExpanded((expanded) => !expanded)} + title={t('button expand')} + />
- - {pagination === 'sequence' && currentPage === 'lunaticPage' && ( - <> -
-
- - )} -
+ )}
- +
)}
@@ -107,7 +104,7 @@ export function SurveyContainer( className={fr.cx( 'fr-col-12', 'fr-mb-10v', - ...(!(isLayoutExpanded && currentPage === 'lunaticPage') + ...(!(isLayoutExpanded && currentPage === PAGE_TYPE.LUNATIC) ? (['fr-col-md-9', 'fr-col-lg-8'] as const) : []) )} @@ -118,7 +115,7 @@ export function SurveyContainer( title={t('button continue title', { currentPage })} id="continue-button" onClick={ - currentPage === 'endPage' + currentPage === PAGE_TYPE.END ? handleDepositProofClick : handleNextClick } @@ -126,7 +123,7 @@ export function SurveyContainer( {t('button continue label', { currentPage })} {bottomContent} - {mode === 'visualize' && ( + {mode === MODE_TYPE.VISUALIZE && (