future: add guided tour provider and tour factory (#23770)

This commit is contained in:
markkaylor 2025-06-19 12:06:33 +02:00 committed by GitHub
parent 212d172a2f
commit f499a6c17f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 268 additions and 7 deletions

View File

@ -0,0 +1,63 @@
---
title: Guided Tour
---
This document explains how to create and use Guided Tours in the Strapi CMS.
## Creating tours
To create a tour use the `createTour` factory function.
```tsx
const tours = {
contentManager: createTour('contentManager', [
{
name: 'TheFeatureStepName',
content: () => (
<>
<div>This is the content for Step 1 of some feature</div>
</>
),
},
]),
} as const;
```
Tours for the CMS are defined in the `packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx` file.
The tours are then passed to the `UnstableGuidedTourContext` provider.
```tsx
import { tours } from '../UnstableGuidedTour/Tours';
import { UnstableGuidedTourContext } from '../UnstableGuidedTour/Context';
function App() {
return (
<UnstableGuidedTourContext tours={tours}>
<Outlet />
</UnstableGuidedTourContext>
);
}
```
The provider derives the tour state from the tours object to create an object where each tour's name points to its current step index.
Continuing our example from above the intial tour state would be:
```ts
{
contentManager: 0;
}
```
## Displaying tours in the CMS
The tours object is exported from strapi admin and can be accessed anywhere in the CMS. Wrapping an element will anchor the tour tooltip to that element.
```tsx
import { tours } from '../UnstableGuidedTour/Tours';
<tours.contentManager.TheFeatureStepName>
<div>A part of a feature I want to show off<div>
</tours.contentManager.TheFeatureStepName>
```

View File

@ -13,6 +13,8 @@ import { TrackingProvider } from '../features/Tracking';
import { GuidedTourProvider } from './GuidedTour/Provider';
import { LanguageProvider } from './LanguageProvider';
import { Theme } from './Theme';
import { UnstableGuidedTourContext } from './UnstableGuidedTour/Context';
import { tours } from './UnstableGuidedTour/Tours';
import type { Store } from '../core/store/configure';
import type { StrapiApp } from '../StrapiApp';
@ -57,13 +59,15 @@ const Providers = ({ children, strapi, store }: ProvidersProps) => {
<NotificationsProvider>
<TrackingProvider>
<GuidedTourProvider>
<ConfigurationProvider
defaultAuthLogo={strapi.configurations.authLogo}
defaultMenuLogo={strapi.configurations.menuLogo}
showReleaseNotification={strapi.configurations.notifications.releases}
>
{children}
</ConfigurationProvider>
<UnstableGuidedTourContext tours={tours}>
<ConfigurationProvider
defaultAuthLogo={strapi.configurations.authLogo}
defaultMenuLogo={strapi.configurations.menuLogo}
showReleaseNotification={strapi.configurations.notifications.releases}
>
{children}
</ConfigurationProvider>
</UnstableGuidedTourContext>
</GuidedTourProvider>
</TrackingProvider>
</NotificationsProvider>

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import { produce } from 'immer';
import { createContext } from '../Context';
import type { Tours } from './Tours';
/* -------------------------------------------------------------------------------------------------
* GuidedTourProvider
* -----------------------------------------------------------------------------------------------*/
// Infer valid tour names from the tours object
type ValidTourName = keyof Tours;
// Now use ValidTourName in all type definitions
type Action = {
type: 'next_step';
payload: ValidTourName;
};
type State = {
currentSteps: Record<ValidTourName, number>;
};
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
}>('GuidedTour');
function reducer(state: State, action: Action): State {
return produce(state, (draft) => {
if (action.type === 'next_step') {
draft.currentSteps[action.payload] += 1;
}
});
}
const UnstableGuidedTourContext = ({
children,
tours,
}: {
children: React.ReactNode;
tours: Tours;
}) => {
// Derive the tour steps from the tours object
const currentSteps = Object.keys(tours).reduce(
(acc, tourName) => {
acc[tourName as ValidTourName] = 0;
return acc;
},
{} as Record<ValidTourName, number>
);
const [state, dispatch] = React.useReducer(reducer, {
currentSteps,
});
return (
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
{children}
</GuidedTourProviderImpl>
);
};
export type { Action, State, ValidTourName };
export { UnstableGuidedTourContext, unstableUseGuidedTour, reducer };

View File

@ -0,0 +1,78 @@
import type { State, Action } from './Context';
/* -------------------------------------------------------------------------------------------------
* Tours
* -----------------------------------------------------------------------------------------------*/
const tours = {
contentManager: createTour('contentManager', [
{
name: 'TEST',
content: () => (
<>
<div>This is TEST</div>
</>
),
},
]),
} as const;
type Tours = typeof tours;
/* -------------------------------------------------------------------------------------------------
* Tour factory
* -----------------------------------------------------------------------------------------------*/
type TourStep<P extends string> = {
name: P;
content: Content;
};
type Content = ({
state,
dispatch,
}: {
state: State;
dispatch: React.Dispatch<Action>;
}) => React.ReactNode;
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
type Components = {
[K in T[number]['name']]: React.ComponentType<{ children: React.ReactNode }>;
};
const tour = steps.reduce((acc, step, index) => {
if (step.name in acc) {
throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`);
}
acc[step.name as keyof Components] = ({ children }: { children: React.ReactNode }) => (
<div>
<div>TODO: GuidedTourTooltip goes here and receives these props</div>
<div style={{ display: 'flex', gap: 2 }}>
<span>content:</span>
{step.content({ state: { currentSteps: { contentManager: 0 } }, dispatch: () => {} })}
</div>
<div style={{ display: 'flex', gap: 2 }}>
<span>children:</span>
{children}
</div>
<div style={{ display: 'flex', gap: 2 }}>
<span>tourName:</span>
{tourName}
</div>
<div style={{ display: 'flex', gap: 2 }}>
<span>step:</span>
{index}
</div>
</div>
);
return acc;
}, {} as Components);
return tour;
}
export type { Content, Tours };
export { tours };

View File

@ -0,0 +1,49 @@
import { type Action, reducer } from '../Context';
describe('GuidedTour | reducer', () => {
describe('next_step', () => {
it('should increment the step count for the specified tour', () => {
const initialState = {
currentSteps: {
contentManager: 0,
},
};
const action: Action = {
type: 'next_step',
payload: 'contentManager',
};
const expectedState = {
currentSteps: {
contentManager: 1,
},
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
it('should preserve other tour states when advancing a specific tour', () => {
const initialState = {
currentSteps: {
contentManager: 1,
contentTypeBuilder: 2,
},
};
const action: Action = {
type: 'next_step',
payload: 'contentManager',
};
const expectedState = {
currentSteps: {
contentManager: 2,
contentTypeBuilder: 2,
},
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
});
});

View File

@ -26,6 +26,7 @@ export * from './components/SubNav';
export * from './components/GradientBadge';
export { useGuidedTour } from './components/GuidedTour/Provider';
export { tours as unstable_tours } from './components/UnstableGuidedTour/Tours';
/**
* Features