mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
future: add guided tour provider and tour factory (#23770)
This commit is contained in:
parent
212d172a2f
commit
f499a6c17f
63
docs/docs/guides/06-guided-tour.md
Normal file
63
docs/docs/guides/06-guided-tour.md
Normal 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>
|
||||
```
|
@ -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>
|
||||
|
@ -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 };
|
@ -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 };
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user