chore: update guided tour tracking events (#24192)

This commit is contained in:
markkaylor 2025-08-25 10:35:19 +02:00 committed by GitHub
parent 6a38f974ec
commit c96edda856
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 55 additions and 29 deletions

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { produce } from 'immer'; import { produce } from 'immer';
import { useTracking } from '../../features/Tracking';
import { usePersistentState } from '../../hooks/usePersistentState'; import { usePersistentState } from '../../hooks/usePersistentState';
import { createContext } from '../Context'; import { createContext } from '../Context';
@ -81,17 +82,23 @@ const getInitialTourState = (tours: Tours) => {
}, {} as TourState); }, {} as TourState);
}; };
const getCompletedTours = (tours: TourState): ValidTourName[] => {
return Object.keys(tours).filter(
(tourName) => tours[tourName as ValidTourName].isCompleted
) as ValidTourName[];
};
const areAllToursCompleted = (tours: TourState) => Object.values(tours).every((t) => t.isCompleted);
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
return produce(state, (draft) => { return produce(state, (draft) => {
if (action.type === 'next_step') { if (action.type === 'next_step') {
const currentStep = draft.tours[action.payload].currentStep; const currentStep = draft.tours[action.payload].currentStep;
const tourLength = guidedTours[action.payload]._meta.totalStepCount; const tourLength = guidedTours[action.payload]._meta.totalStepCount;
if (currentStep >= tourLength) return;
const nextStep = currentStep + 1; const nextStep = currentStep + 1;
draft.tours[action.payload].currentStep = nextStep; draft.tours[action.payload].currentStep = nextStep;
draft.tours[action.payload].isCompleted = nextStep === tourLength; draft.tours[action.payload].isCompleted = nextStep >= tourLength;
} }
if (action.type === 'previous_step') { if (action.type === 'previous_step') {
@ -141,6 +148,7 @@ const GuidedTourContext = ({
children: React.ReactNode; children: React.ReactNode;
enabled?: boolean; enabled?: boolean;
}) => { }) => {
const { trackUsage } = useTracking();
const [storedTours, setStoredTours] = usePersistentState<State>(STORAGE_KEY, { const [storedTours, setStoredTours] = usePersistentState<State>(STORAGE_KEY, {
tours: getInitialTourState(guidedTours), tours: getInitialTourState(guidedTours),
enabled, enabled,
@ -154,6 +162,22 @@ const GuidedTourContext = ({
setStoredTours(state); setStoredTours(state);
}, [state, setStoredTours]); }, [state, setStoredTours]);
// Derive all completed tours from state
const currentAllCompletedState = areAllToursCompleted(state.tours);
// Store completed state in ref to survive a re-render,
// when current state changes this will persist and be used for comparison
const previousAllCompletedStateRef = React.useRef(currentAllCompletedState);
React.useEffect(() => {
const previousAllCompletedState = previousAllCompletedStateRef.current;
// When the previous state was not complete but the current state is now complete, fire the event
if (!previousAllCompletedState && currentAllCompletedState) {
trackUsage('didCompleteGuidedTour', { name: 'all' });
}
// When the current state has all tours completed so will the previous state, the tracking event won't fire again
previousAllCompletedStateRef.current = currentAllCompletedState;
}, [currentAllCompletedState, trackUsage]);
return ( return (
<GuidedTourProviderImpl state={state} dispatch={dispatch}> <GuidedTourProviderImpl state={state} dispatch={dispatch}>
{children} {children}
@ -162,4 +186,4 @@ const GuidedTourContext = ({
}; };
export type { Action, State, ValidTourName }; export type { Action, State, ValidTourName };
export { GuidedTourContext, useGuidedTour, reducer }; export { GuidedTourContext, useGuidedTour, reducer, getCompletedTours };

View File

@ -10,7 +10,7 @@ import { useTracking } from '../../features/Tracking';
import { useGetGuidedTourMetaQuery } from '../../services/admin'; import { useGetGuidedTourMetaQuery } from '../../services/admin';
import { ConfirmDialog } from '../ConfirmDialog'; import { ConfirmDialog } from '../ConfirmDialog';
import { type ValidTourName, useGuidedTour } from './Context'; import { type ValidTourName, useGuidedTour, getCompletedTours } from './Context';
import { GUIDED_TOUR_REQUIRED_ACTIONS } from './utils/constants'; import { GUIDED_TOUR_REQUIRED_ACTIONS } from './utils/constants';
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
@ -145,14 +145,14 @@ export const GuidedTourHomepageOverview = () => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { trackUsage } = useTracking(); const { trackUsage } = useTracking();
const tours = useGuidedTour('Overview', (s) => s.state.tours); const tourState = useGuidedTour('Overview', (s) => s.state.tours);
const dispatch = useGuidedTour('Overview', (s) => s.dispatch); const dispatch = useGuidedTour('Overview', (s) => s.dispatch);
const enabled = useGuidedTour('Overview', (s) => s.state.enabled); const enabled = useGuidedTour('Overview', (s) => s.state.enabled);
const completedActions = useGuidedTour('Overview', (s) => s.state.completedActions); const completedActions = useGuidedTour('Overview', (s) => s.state.completedActions);
const { data: guidedTourMeta } = useGetGuidedTourMetaQuery(); const { data: guidedTourMeta } = useGetGuidedTourMetaQuery();
const tourNames = Object.keys(tours) as ValidTourName[]; const tourNames = Object.keys(tourState) as ValidTourName[];
const completedTours = tourNames.filter((tourName) => tours[tourName].isCompleted); const completedTours = getCompletedTours(tourState);
const completionPercentage = const completionPercentage =
tourNames.length > 0 ? Math.round((completedTours.length / tourNames.length) * 100) : 0; tourNames.length > 0 ? Math.round((completedTours.length / tourNames.length) * 100) : 0;
@ -161,9 +161,13 @@ export const GuidedTourHomepageOverview = () => {
dispatch({ type: 'skip_all_tours' }); dispatch({ type: 'skip_all_tours' });
}; };
const handleClickStrapiCloud = (tourName: ValidTourName) => { const handleStartTour = (tourName: ValidTourName) => {
trackUsage('didCompleteGuidedTour', { name: tourName }); trackUsage('didStartGuidedTour', { name: tourName, fromHomepage: true });
dispatch({ type: 'skip_tour', payload: tourName });
if (tourName === 'strapiCloud') {
trackUsage('didCompleteGuidedTour', { name: tourName });
dispatch({ type: 'next_step', payload: tourName });
}
}; };
if (!guidedTourMeta?.data.isFirstSuperAdminUser || !enabled) { if (!guidedTourMeta?.data.isFirstSuperAdminUser || !enabled) {
@ -197,7 +201,15 @@ export const GuidedTourHomepageOverview = () => {
paddingBottom={8} paddingBottom={8}
gap={2} gap={2}
> >
<Typography variant="pi">{completionPercentage}%</Typography> <Typography variant="pi">
{formatMessage(
{
id: 'tours.overview.completed',
defaultMessage: '{completed}% completed',
},
{ completed: completionPercentage }
)}
</Typography>
<StyledProgressBar value={completionPercentage} /> <StyledProgressBar value={completionPercentage} />
</Flex> </Flex>
<Dialog.Root> <Dialog.Root>
@ -229,7 +241,7 @@ export const GuidedTourHomepageOverview = () => {
<Box tag="ul" width="100%" borderColor="neutral150" marginTop={4} hasRadius> <Box tag="ul" width="100%" borderColor="neutral150" marginTop={4} hasRadius>
{TASK_CONTENT.map((task) => { {TASK_CONTENT.map((task) => {
const tourName = task.tourName as ValidTourName; const tourName = task.tourName as ValidTourName;
const tour = tours[tourName]; const tour = tourState[tourName];
const isLinkDisabled = const isLinkDisabled =
tourName !== 'contentTypeBuilder' && tourName !== 'contentTypeBuilder' &&
@ -270,7 +282,7 @@ export const GuidedTourHomepageOverview = () => {
isExternal isExternal
disabled={isLinkDisabled} disabled={isLinkDisabled}
href={task.link.to} href={task.link.to}
onClick={() => handleClickStrapiCloud(task.tourName as ValidTourName)} onClick={() => handleStartTour(task.tourName as ValidTourName)}
> >
{formatMessage(task.link.label)} {formatMessage(task.link.label)}
</Link> </Link>
@ -281,7 +293,7 @@ export const GuidedTourHomepageOverview = () => {
to={task.link.to} to={task.link.to}
tag={NavLink} tag={NavLink}
onClick={() => onClick={() =>
trackUsage('didStartGuidedTourFromHomepage', { name: tourName }) trackUsage('didStartGuidedTour', { name: tourName, fromHomepage: true })
} }
> >
{formatMessage(task.link.label)} {formatMessage(task.link.label)}

View File

@ -399,14 +399,15 @@ interface DidSkipGuidedTour {
interface DidCompleteGuidedTour { interface DidCompleteGuidedTour {
name: 'didCompleteGuidedTour'; name: 'didCompleteGuidedTour';
properties: { properties: {
name: keyof Tours; name: keyof Tours | 'all';
}; };
} }
interface DidStartGuidedTour { interface DidStartGuidedTour {
name: 'didStartGuidedTourFromHomepage'; name: 'didStartGuidedTour';
properties: { properties: {
name: keyof Tours; name: keyof Tours;
fromHomepage?: boolean;
}; };
} }

View File

@ -4,15 +4,10 @@ import { screen, within } from '@testing-library/react';
import { render as renderRTL, waitFor } from '@tests/utils'; import { render as renderRTL, waitFor } from '@tests/utils';
import { useAppInfo } from '../../../features/AppInfo'; import { useAppInfo } from '../../../features/AppInfo';
import { useTracking } from '../../../features/Tracking';
import { MarketplacePage } from '../MarketplacePage'; import { MarketplacePage } from '../MarketplacePage';
jest.mock('../hooks/useNavigatorOnline'); jest.mock('../hooks/useNavigatorOnline');
jest.mock('../../../features/Tracking', () => ({
useTracking: jest.fn(() => ({ trackUsage: jest.fn() })),
}));
jest.mock('../../../features/AppInfo', () => ({ jest.mock('../../../features/AppInfo', () => ({
...jest.requireActual('../../../features/AppInfo'), ...jest.requireActual('../../../features/AppInfo'),
useAppInfo: jest.fn(() => ({ useAppInfo: jest.fn(() => ({
@ -34,15 +29,8 @@ const waitForReload = async () => {
describe('Marketplace page - layout', () => { describe('Marketplace page - layout', () => {
it('renders the online layout', async () => { it('renders the online layout', async () => {
const trackUsage = jest.fn();
// @ts-expect-error - mock
useTracking.mockImplementationOnce(() => ({ trackUsage }));
const { queryByText, getByRole } = render(); const { queryByText, getByRole } = render();
await waitForReload(); await waitForReload();
// Calls the tracking event
expect(trackUsage).toHaveBeenCalledWith('didGoToMarketplace');
expect(trackUsage).toHaveBeenCalledTimes(1);
expect(queryByText('You are offline')).not.toBeInTheDocument(); expect(queryByText('You are offline')).not.toBeInTheDocument();
// Shows the sort button // Shows the sort button

View File

@ -859,6 +859,7 @@
"tours.profile.description": "You can reset the guided tour at any time.", "tours.profile.description": "You can reset the guided tour at any time.",
"tours.profile.reset": "Reset guided tour", "tours.profile.reset": "Reset guided tour",
"tours.profile.notification.success.reset": "Guided tour reset", "tours.profile.notification.success.reset": "Guided tour reset",
"tours.overview.completed": "{completed}% completed",
"widget.key-statistics.title": "Project statistics", "widget.key-statistics.title": "Project statistics",
"widget.key-statistics.list.admins": "Admins", "widget.key-statistics.list.admins": "Admins",
"widget.key-statistics.list.apiTokens": "API Tokens", "widget.key-statistics.list.apiTokens": "API Tokens",