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 { GuidedTourProvider } from './GuidedTour/Provider';
|
||||||
import { LanguageProvider } from './LanguageProvider';
|
import { LanguageProvider } from './LanguageProvider';
|
||||||
import { Theme } from './Theme';
|
import { Theme } from './Theme';
|
||||||
|
import { UnstableGuidedTourContext } from './UnstableGuidedTour/Context';
|
||||||
|
import { tours } from './UnstableGuidedTour/Tours';
|
||||||
|
|
||||||
import type { Store } from '../core/store/configure';
|
import type { Store } from '../core/store/configure';
|
||||||
import type { StrapiApp } from '../StrapiApp';
|
import type { StrapiApp } from '../StrapiApp';
|
||||||
@ -57,13 +59,15 @@ const Providers = ({ children, strapi, store }: ProvidersProps) => {
|
|||||||
<NotificationsProvider>
|
<NotificationsProvider>
|
||||||
<TrackingProvider>
|
<TrackingProvider>
|
||||||
<GuidedTourProvider>
|
<GuidedTourProvider>
|
||||||
<ConfigurationProvider
|
<UnstableGuidedTourContext tours={tours}>
|
||||||
defaultAuthLogo={strapi.configurations.authLogo}
|
<ConfigurationProvider
|
||||||
defaultMenuLogo={strapi.configurations.menuLogo}
|
defaultAuthLogo={strapi.configurations.authLogo}
|
||||||
showReleaseNotification={strapi.configurations.notifications.releases}
|
defaultMenuLogo={strapi.configurations.menuLogo}
|
||||||
>
|
showReleaseNotification={strapi.configurations.notifications.releases}
|
||||||
{children}
|
>
|
||||||
</ConfigurationProvider>
|
{children}
|
||||||
|
</ConfigurationProvider>
|
||||||
|
</UnstableGuidedTourContext>
|
||||||
</GuidedTourProvider>
|
</GuidedTourProvider>
|
||||||
</TrackingProvider>
|
</TrackingProvider>
|
||||||
</NotificationsProvider>
|
</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 * from './components/GradientBadge';
|
||||||
|
|
||||||
export { useGuidedTour } from './components/GuidedTour/Provider';
|
export { useGuidedTour } from './components/GuidedTour/Provider';
|
||||||
|
export { tours as unstable_tours } from './components/UnstableGuidedTour/Tours';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Features
|
* Features
|
||||||
|
Loading…
x
Reference in New Issue
Block a user