diff --git a/docs/docs/guides/06-guided-tour.md b/docs/docs/guides/06-guided-tour.md new file mode 100644 index 0000000000..40bcb372d3 --- /dev/null +++ b/docs/docs/guides/06-guided-tour.md @@ -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: () => ( + <> +
This is the content for Step 1 of some feature
+ + ), + }, + ]), +} 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 ( + + + + ); +} +``` + +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'; + + +
A part of a feature I want to show off
+ +``` diff --git a/packages/core/admin/admin/src/components/Providers.tsx b/packages/core/admin/admin/src/components/Providers.tsx index b37f305262..e3775563c8 100644 --- a/packages/core/admin/admin/src/components/Providers.tsx +++ b/packages/core/admin/admin/src/components/Providers.tsx @@ -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) => { - - {children} - + + + {children} + + diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx new file mode 100644 index 0000000000..bbb2a611b8 --- /dev/null +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx @@ -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; +}; + +const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{ + state: State; + dispatch: React.Dispatch; +}>('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 + ); + const [state, dispatch] = React.useReducer(reducer, { + currentSteps, + }); + + return ( + + {children} + + ); +}; + +export type { Action, State, ValidTourName }; +export { UnstableGuidedTourContext, unstableUseGuidedTour, reducer }; diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx new file mode 100644 index 0000000000..5e5e640984 --- /dev/null +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Tours.tsx @@ -0,0 +1,78 @@ +import type { State, Action } from './Context'; + +/* ------------------------------------------------------------------------------------------------- + * Tours + * -----------------------------------------------------------------------------------------------*/ + +const tours = { + contentManager: createTour('contentManager', [ + { + name: 'TEST', + content: () => ( + <> +
This is TEST
+ + ), + }, + ]), +} as const; + +type Tours = typeof tours; + +/* ------------------------------------------------------------------------------------------------- + * Tour factory + * -----------------------------------------------------------------------------------------------*/ + +type TourStep

= { + name: P; + content: Content; +}; + +type Content = ({ + state, + dispatch, +}: { + state: State; + dispatch: React.Dispatch; +}) => React.ReactNode; + +function createTour>>(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 }) => ( +

+
TODO: GuidedTourTooltip goes here and receives these props
+
+ content: + {step.content({ state: { currentSteps: { contentManager: 0 } }, dispatch: () => {} })} +
+
+ children: + {children} +
+
+ tourName: + {tourName} +
+
+ step: + {index} +
+
+ ); + + return acc; + }, {} as Components); + + return tour; +} + +export type { Content, Tours }; +export { tours }; diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts new file mode 100644 index 0000000000..afd350f7cb --- /dev/null +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts @@ -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); + }); + }); +}); diff --git a/packages/core/admin/admin/src/index.ts b/packages/core/admin/admin/src/index.ts index 4892b849fa..a02d88121d 100644 --- a/packages/core/admin/admin/src/index.ts +++ b/packages/core/admin/admin/src/index.ts @@ -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