diff --git a/docs/docs/docs/01-core/admin/05-guided-tour.md b/docs/docs/docs/01-core/admin/05-guided-tour.md index 521280adc3..d1f1561884 100644 --- a/docs/docs/docs/01-core/admin/05-guided-tour.md +++ b/docs/docs/docs/01-core/admin/05-guided-tour.md @@ -7,22 +7,10 @@ The Guided Tour provides an interactive onboarding experience that guides new us The Guided Tour system is built with a modular architecture consisting of: - **Context Provider** (`Context.tsx`) - Global state management and persistence -- **Tour Factory** (`Tours.tsx`) - Tour creation and step management -- **Step Components** (`Step.tsx`) - Reusable popover components for tour steps +- **Tour Factory** (`Tours.tsx`) - Tour factory and GuidedTourTooltip +- **Steps** (`Steps`) - Step factory, reusable step components, and tour specific step components - **Overview Component** (`Overview.tsx`) - Homepage tour overview and progress tracking -### Core Files - -``` -GuidedTour/ -├── Context.tsx # State management and React context -├── Tours.tsx # Tour definitions and factory functions -├── Step.tsx # Step component factory and UI -├── Overview.tsx # Homepage overview component -└── tests/ - └── reducer.test.ts # Unit tests for state reducer -``` - ## Core Concepts ### 1. Tours and Steps @@ -71,6 +59,29 @@ Steps can be conditionally displayed based on user actions: } ``` +### 4. Excluding Steps from Step Count + +Some steps shouldn't be counted in the step count displayed to the user. Use `excludeFromStepCount` to exclude them: + +```typescript +{ + name: 'Welcome', + excludeFromStepCount: true, + content: (Step) => ( + + + + + + ) +} +``` + +`createTour()` will add a `_meta` property to the tour object that provides information about the number of steps in the tour (total steps) and then the number of steps that will actually be displayed to the user: + +- `_meta.totalStepCount` - The total number of steps defined for the tour +- `_meta.displayedStepCount` - The total number of steps - the number of steps with `excludeFromStepCount: true` + ## Usage Guide ### Creating a New Tour @@ -161,10 +172,6 @@ const MyComponent = () => { 3. **Mark actions complete** to trigger conditional steps: -:::note -To foster a declarative API it is recommended to update the [backend endpoint](#backend-integration) for actions that save to the database and invalidate the cache when the action is completed. Otherwise an action on the frontend (ie didCopyApiToken) can update the state imperatively. -::: - ```tsx import { useGuidedTour } from './Context'; @@ -271,7 +278,6 @@ const initialState = { tours: { contentManager: { currentStep: 0, - length: 3, isCompleted: false, }, }, @@ -285,10 +291,12 @@ const initialState = { The tour reducer handles these actions: - `next_step` - Advance to the next step in a tour +- `previous_step` - Go back to the previous step in a tour +- `go_to_step` - Go to a specific step - `skip_tour` - Mark a tour as completed (skipped) -- `set_completed_actions` - Update the list of completed user actions - `skip_all_tours` - Disable all tours globally - `reset_all_tours` - Reset all tours to initial state +- `set_completed_actions` - Update the list of completed user actions ### Using the Hook @@ -318,9 +326,9 @@ Tour state is automatically persisted to localStorage using the `usePersistentSt Tours integrate with the backend through: -- `useGetGuidedTourMetaQuery()` - Fetches tour metadata from the server -- Completed actions are synchronized between client and server. -- First-time user detection (`isFirstSuperAdminUser`) +`useGetGuidedTourMetaQuery()` + +- Fetches relative metadata from the server such as `isFirstSuperAdminUser` ## E2E Testing @@ -336,6 +344,7 @@ type TourStep

= { name: P; content: Content; when?: (completedActions: ExtendedCompletedActions) => boolean; + excludeFromStepCount?: boolean; // Exclude from "Step X of Y" counting }; // State management @@ -348,10 +357,18 @@ type State = { type Action = | { type: 'next_step'; payload: ValidTourName } | { type: 'skip_tour'; payload: ValidTourName } + | { type: 'previous_step'; payload: ValidTourName } + | { type: 'go_to_step'; payload: { tourName: ValidTourName; step: number } } | { type: 'set_completed_actions'; payload: ExtendedCompletedActions } | { type: 'skip_all_tours' } | { type: 'reset_all_tours' }; +// Tour metadata +type TourMeta = { + totalStepCount: number; + displayedStepCount: number; +}; + // Step components type Step = { Root: React.ForwardRefExoticComponent; @@ -359,4 +376,7 @@ type Step = { Content: (props: StepProps) => React.ReactNode; Actions: (props: ActionsProps & { to?: string } & FlexProps) => React.ReactNode; }; + +// Tour object includes both steps and metadata +type Tour = Components & { _meta: TourMeta }; ``` diff --git a/examples/empty/config/admin.ts b/examples/empty/config/admin.ts index e0f87f9231..286b11892f 100644 --- a/examples/empty/config/admin.ts +++ b/examples/empty/config/admin.ts @@ -5,6 +5,9 @@ const adminConfig = ({ env }) => ({ apiToken: { salt: env('API_TOKEN_SALT', 'example-salt'), }, + secrets: { + encryptionKey: env('ENCRYPTION_KEY', 'example-key'), + }, transfer: { token: { salt: env('TRANSFER_TOKEN_SALT', 'example-salt'), diff --git a/packages/core/admin/admin/src/components/GuidedTour/Context.tsx b/packages/core/admin/admin/src/components/GuidedTour/Context.tsx index 5488579f2d..49f8d52a4a 100644 --- a/packages/core/admin/admin/src/components/GuidedTour/Context.tsx +++ b/packages/core/admin/admin/src/components/GuidedTour/Context.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { produce } from 'immer'; -import { GetGuidedTourMeta } from '../../../../shared/contracts/admin'; import { usePersistentState } from '../../hooks/usePersistentState'; import { createContext } from '../Context'; import { type Tours, tours as guidedTours } from './Tours'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from './utils/constants'; +import { migrateTours } from './utils/migrations'; /* ------------------------------------------------------------------------------------------------- * GuidedTourProvider @@ -14,10 +15,13 @@ import { type Tours, tours as guidedTours } from './Tours'; type ValidTourName = keyof Tours; -export type ExtendedCompletedActions = ( - | GetGuidedTourMeta.Response['data']['completedActions'][number] - | 'didCopyApiToken' -)[]; +/** + * Derive the union of all string literal values from GUIDED_TOUR_REQUIRED_ACTIONS + * (ie didCreateContentTypeSchema | didCreateContent etc...) + */ +type ValueOf = T[keyof T]; +type NonEmptyValueOf = T extends Record ? never : ValueOf; +export type CompletedActions = NonEmptyValueOf>[]; type Action = | { @@ -25,25 +29,40 @@ type Action = payload: ValidTourName; } | { - type: 'skip_tour'; + type: 'previous_step'; payload: ValidTourName; } | { - type: 'set_completed_actions'; - payload: ExtendedCompletedActions; + type: 'go_to_step'; + payload: { + tourName: ValidTourName; + step: number; + }; + } + | { + type: 'skip_tour'; + payload: ValidTourName; } | { type: 'skip_all_tours'; } | { type: 'reset_all_tours'; + } + | { + type: 'set_completed_actions'; + payload: CompletedActions; + } + | { + type: 'remove_completed_action'; + payload: ValueOf; }; -type Tour = Record; +type TourState = Record; type State = { - tours: Tour; + tours: TourState; enabled: boolean; - completedActions: ExtendedCompletedActions; + completedActions: CompletedActions; }; const [GuidedTourProviderImpl, useGuidedTour] = createContext<{ @@ -53,23 +72,35 @@ const [GuidedTourProviderImpl, useGuidedTour] = createContext<{ const getInitialTourState = (tours: Tours) => { return Object.keys(tours).reduce((acc, tourName) => { - const tourLength = Object.keys(tours[tourName as ValidTourName]).length; acc[tourName as ValidTourName] = { currentStep: 0, - length: tourLength, isCompleted: false, }; return acc; - }, {} as Tour); + }, {} as TourState); }; function reducer(state: State, action: Action): State { return produce(state, (draft) => { if (action.type === 'next_step') { - const nextStep = draft.tours[action.payload].currentStep + 1; + const currentStep = draft.tours[action.payload].currentStep; + const tourLength = guidedTours[action.payload]._meta.totalStepCount; + + if (currentStep >= tourLength) return; + + const nextStep = currentStep + 1; draft.tours[action.payload].currentStep = nextStep; - draft.tours[action.payload].isCompleted = nextStep === draft.tours[action.payload].length; + draft.tours[action.payload].isCompleted = nextStep === tourLength; + } + + if (action.type === 'previous_step') { + const currentStep = draft.tours[action.payload].currentStep; + + if (currentStep <= 0) return; + + const previousStep = currentStep - 1; + draft.tours[action.payload].currentStep = previousStep; } if (action.type === 'skip_tour') { @@ -80,6 +111,12 @@ function reducer(state: State, action: Action): State { draft.completedActions = [...new Set([...draft.completedActions, ...action.payload])]; } + if (action.type === 'remove_completed_action') { + draft.completedActions = draft.completedActions.filter( + (completedAction) => completedAction !== action.payload + ); + } + if (action.type === 'skip_all_tours') { draft.enabled = false; } @@ -89,6 +126,10 @@ function reducer(state: State, action: Action): State { draft.tours = getInitialTourState(guidedTours); draft.completedActions = []; } + + if (action.type === 'go_to_step') { + draft.tours[action.payload.tourName].currentStep = action.payload.step; + } }); } @@ -100,17 +141,18 @@ const GuidedTourContext = ({ children: React.ReactNode; enabled?: boolean; }) => { - const [tours, setTours] = usePersistentState(STORAGE_KEY, { + const [storedTours, setStoredTours] = usePersistentState(STORAGE_KEY, { tours: getInitialTourState(guidedTours), enabled, completedActions: [], }); - const [state, dispatch] = React.useReducer(reducer, tours); + const migratedTourState = migrateTours(storedTours); + const [state, dispatch] = React.useReducer(reducer, migratedTourState); // Sync local storage React.useEffect(() => { - setTours(state); - }, [state, setTours]); + setStoredTours(state); + }, [state, setStoredTours]); return ( diff --git a/packages/core/admin/admin/src/components/GuidedTour/Overview.tsx b/packages/core/admin/admin/src/components/GuidedTour/Overview.tsx index 25f409e7fb..4a606252d6 100644 --- a/packages/core/admin/admin/src/components/GuidedTour/Overview.tsx +++ b/packages/core/admin/admin/src/components/GuidedTour/Overview.tsx @@ -1,3 +1,5 @@ +import * as React from 'react'; + import { Box, Button, Dialog, Flex, Link, ProgressBar, Typography } from '@strapi/design-system'; import { CheckCircle, ChevronRight } from '@strapi/icons'; import { useIntl } from 'react-intl'; @@ -9,6 +11,7 @@ import { useGetGuidedTourMetaQuery } from '../../services/admin'; import { ConfirmDialog } from '../ConfirmDialog'; import { type ValidTourName, useGuidedTour } from './Context'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from './utils/constants'; /* ------------------------------------------------------------------------------------------------- * Styled @@ -100,7 +103,7 @@ const TASK_CONTENT = [ }, title: { id: 'tours.overview.apiTokens.label', - defaultMessage: 'Create and copy an API token', + defaultMessage: 'Copy an API token', }, done: DONE_LABEL, }, @@ -153,10 +156,6 @@ export const GuidedTourHomepageOverview = () => { const completionPercentage = tourNames.length > 0 ? Math.round((completedTours.length / tourNames.length) * 100) : 0; - if (!guidedTourMeta?.data.isFirstSuperAdminUser || !enabled) { - return null; - } - const handleConfirmDialog = () => { trackUsage('didSkipGuidedTour', { name: 'all' }); dispatch({ type: 'skip_all_tours' }); @@ -167,6 +166,10 @@ export const GuidedTourHomepageOverview = () => { dispatch({ type: 'skip_tour', payload: tourName }); }; + if (!guidedTourMeta?.data.isFirstSuperAdminUser || !enabled) { + return null; + } + return ( {/* Greeting */} @@ -227,9 +230,12 @@ export const GuidedTourHomepageOverview = () => { {TASK_CONTENT.map((task) => { const tourName = task.tourName as ValidTourName; const tour = tours[tourName]; + const isLinkDisabled = tourName !== 'contentTypeBuilder' && - !completedActions.includes('didCreateContentTypeSchema'); + !completedActions.includes( + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema + ); return ( ( + + + + + +); + +const ManageAPIToken = ({ Step }: StepContentProps) => ( + + + + + +); + +const ViewAPIToken = ({ Step, dispatch }: StepContentProps) => ( + + + + + + dispatch({ type: 'next_step', payload: 'apiTokens' })} /> + + +); + +const CopyAPIToken = ({ Step, dispatch }: StepContentProps) => ( + + + , + a: (msg: React.ReactNode) => ( + + {msg} + + ), + }} + /> + + + dispatch({ type: 'next_step', payload: 'apiTokens' })} /> + + +); + +const Finish = ({ Step }: StepContentProps) => ( + + + + + +); + +/* ------------------------------------------------------------------------------------------------- + * Steps + * -----------------------------------------------------------------------------------------------*/ + +export const apiTokensSteps = [ + { + name: 'Introduction', + content: Introduction, + }, + { + name: 'ManageAPIToken', + content: ManageAPIToken, + }, + { + name: 'ViewAPIToken', + content: ViewAPIToken, + }, + { + name: 'CopyAPIToken', + content: CopyAPIToken, + }, + { + name: 'Finish', + content: Finish, + excludeFromStepCount: true, + when: (completedActions: CompletedActions) => + completedActions.includes(GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken), + }, +] as const; diff --git a/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentManagerSteps.tsx b/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentManagerSteps.tsx new file mode 100644 index 0000000000..233ec2ffd8 --- /dev/null +++ b/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentManagerSteps.tsx @@ -0,0 +1,213 @@ +import { useParams } from 'react-router-dom'; + +import { CompletedActions, useGuidedTour } from '../Context'; +import { tours, type StepContentProps } from '../Tours'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from '../utils/constants'; + +import { DefaultActions, DefaultActionsProps, GotItAction, StepCount } from './Step'; + +const ContentManagerActions = ({ + isActionRequired = false, + ...props +}: Omit & { + isActionRequired?: boolean; +}) => { + const { collectionType } = useParams(); + + const state = useGuidedTour('ContentManagerActions', (s) => s.state); + const dispatch = useGuidedTour('ContentManagerActions', (s) => s.dispatch); + + const isSingleType = collectionType === 'single-types'; + + const currentStepOffset = state.tours.contentManager.currentStep + 1; + const displayedCurrentStep = (() => { + if (isSingleType && currentStepOffset > collectionTypeSpecificSteps.length) { + return currentStepOffset - collectionTypeSpecificSteps.length; + } + + return currentStepOffset; + })(); + + // For single types we subtract all contentTypeSpecificSteps + const displayedTourLength = isSingleType + ? tours.contentManager._meta.displayedStepCount - collectionTypeSpecificSteps.length + : tours.contentManager._meta.displayedStepCount; + + const handleNextStep = () => { + if (isSingleType && state.tours.contentManager.currentStep === 0) { + // The tours diverge after the first step, on next click skip all the collection type specific steps + dispatch({ + type: 'go_to_step', + payload: { tourName: 'contentManager', step: collectionTypeSpecificSteps.length + 1 }, + }); + } else { + dispatch({ + type: 'next_step', + payload: 'contentManager', + }); + } + }; + + const handlePreviousStep = () => { + if ( + isSingleType && + // Check the currentStep is the step after the collection type specific steps + state.tours.contentManager.currentStep === collectionTypeSpecificSteps.length + 1 + ) { + dispatch({ + type: 'go_to_step', + payload: { + tourName: 'contentManager', + // Go to the step just before the collection type specific steps + step: state.tours.contentManager.currentStep - collectionTypeSpecificSteps.length - 1, + }, + }); + } else { + dispatch({ + type: 'previous_step', + payload: 'contentManager', + }); + } + }; + + if (isActionRequired) { + return ( + <> + + + + ); + } + + return ( + <> + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * Step Components + * -----------------------------------------------------------------------------------------------*/ + +const Introduction = ({ Step }: StepContentProps) => { + return ( + + + + + + + + ); +}; + +const CreateNewEntry = ({ Step }: StepContentProps) => { + return ( + + + + + + + + ); +}; + +const Fields = ({ Step }: StepContentProps) => ( + + + + + + + +); + +const Publish = ({ Step }: StepContentProps) => ( + + + + + + + +); + +const Finish = ({ Step }: StepContentProps) => ( + + + + + +); + +/* ------------------------------------------------------------------------------------------------- + * Steps + * -----------------------------------------------------------------------------------------------*/ +const collectionTypeSpecificSteps = [ + { + name: 'CreateNewEntry', + content: CreateNewEntry, + }, +]; + +export const contentManagerSteps = [ + { + name: 'Introduction', + when: (completedActions: CompletedActions) => + completedActions.includes(GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema), + content: Introduction, + }, + ...collectionTypeSpecificSteps, + { + name: 'Fields', + content: Fields, + }, + { + name: 'Publish', + content: Publish, + }, + { + name: 'Finish', + content: Finish, + excludeFromStepCount: true, + when: (completedActions: CompletedActions) => + completedActions.includes(GUIDED_TOUR_REQUIRED_ACTIONS.contentManager.createContent), + }, +] as const; diff --git a/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentTypeBuilderSteps.tsx b/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentTypeBuilderSteps.tsx new file mode 100644 index 0000000000..2d3923c5f5 --- /dev/null +++ b/packages/core/admin/admin/src/components/GuidedTour/Steps/ContentTypeBuilderSteps.tsx @@ -0,0 +1,189 @@ +import { UID } from '@strapi/types'; +import { useParams } from 'react-router-dom'; + +import { useGetGuidedTourMetaQuery } from '../../../services/admin'; +import { CompletedActions } from '../Context'; +import { type StepContentProps } from '../Tours'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from '../utils/constants'; + +import { GotItAction, StepCount } from './Step'; + +/* ------------------------------------------------------------------------------------------------- + * Step Components + * -----------------------------------------------------------------------------------------------*/ + +const Introduction = ({ Step }: StepContentProps) => ( + + + + + +); + +const CollectionTypes = ({ Step }: StepContentProps) => ( + + + + + +); + +const SingleTypes = ({ Step }: StepContentProps) => ( + + + + + +); + +const Components = ({ Step }: StepContentProps) => ( + + + + + +); + +const YourTurn = ({ Step }: StepContentProps) => ( + + + + + +); + +const AddFields = ({ Step, dispatch }: StepContentProps) => ( + + + + + + dispatch({ type: 'next_step', payload: 'contentTypeBuilder' })} /> + + +); + +const Save = ({ Step, dispatch }: StepContentProps) => ( + + + + + + { + // Ensure the completed action is removed + // in the event the user already has a schema but is still doing the tour + dispatch({ + type: 'remove_completed_action', + payload: GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + }); + dispatch({ type: 'next_step', payload: 'contentTypeBuilder' }); + }} + /> + + +); + +const Finish = ({ Step }: StepContentProps) => { + const { data: guidedTourMeta } = useGetGuidedTourMetaQuery(); + const { '*': routeParams } = useParams(); + // Get the uid from the params + const uid = routeParams?.split('/').pop(); + const contentType = uid ? guidedTourMeta?.data?.schemas?.[uid as UID.ContentType] : null; + const contentTypeKindDictionary = { + collectionType: 'collection-types', + singleType: 'single-types', + }; + + const to = contentType + ? `/content-manager/${contentTypeKindDictionary[contentType.kind]}/${contentType.uid}` + : '/content-manager'; + + return ( + + + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * Steps + * -----------------------------------------------------------------------------------------------*/ + +export const contentTypeBuilderSteps = [ + { + name: 'Introduction', + content: Introduction, + }, + { + name: 'CollectionTypes', + content: CollectionTypes, + }, + { + name: 'SingleTypes', + content: SingleTypes, + }, + { + name: 'Components', + content: Components, + }, + { + name: 'YourTurn', + content: YourTurn, + }, + { + name: 'AddFields', + content: AddFields, + }, + { + name: 'Save', + when: (completedActions: CompletedActions) => + completedActions.includes(GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.addField), + content: Save, + }, + { + name: 'Finish', + content: Finish, + excludeFromStepCount: true, + when: (completedActions: CompletedActions) => + completedActions.includes(GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema), + }, +] as const; diff --git a/packages/core/admin/admin/src/components/GuidedTour/Step.tsx b/packages/core/admin/admin/src/components/GuidedTour/Steps/Step.tsx similarity index 52% rename from packages/core/admin/admin/src/components/GuidedTour/Step.tsx rename to packages/core/admin/admin/src/components/GuidedTour/Steps/Step.tsx index 136d04e47d..09212ea598 100644 --- a/packages/core/admin/admin/src/components/GuidedTour/Step.tsx +++ b/packages/core/admin/admin/src/components/GuidedTour/Steps/Step.tsx @@ -10,12 +10,120 @@ import { FlexProps, } from '@strapi/design-system'; import { FormattedMessage, type MessageDescriptor } from 'react-intl'; -import { NavLink } from 'react-router-dom'; +import { To, NavLink } from 'react-router-dom'; import { styled } from 'styled-components'; -import { useTracking } from '../../features/Tracking'; +import { useTracking } from '../../../features/Tracking'; +import { useGuidedTour, type ValidTourName } from '../Context'; +import { tours } from '../Tours'; -import { useGuidedTour, type ValidTourName } from './Context'; +/* ------------------------------------------------------------------------------------------------- + * Common Step Components + * -----------------------------------------------------------------------------------------------*/ + +const StepCount = ({ + tourName, + displayedCurrentStep, + displayedTourLength, +}: { + tourName: ValidTourName; + displayedCurrentStep?: number; + displayedTourLength?: number; +}) => { + const state = useGuidedTour('GuidedTourPopover', (s) => s.state); + const currentStep = displayedCurrentStep ?? state.tours[tourName].currentStep + 1; + const displayedStepCount = displayedTourLength ?? tours[tourName]._meta.displayedStepCount; + + return ( + + + + ); +}; + +const GotItAction = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; + +export type DefaultActionsProps = { + showSkip?: boolean; + showPrevious?: boolean; + to?: To; + onNextStep?: () => void; + onPreviousStep?: () => void; + tourName: ValidTourName; +}; +const DefaultActions = ({ + showSkip, + showPrevious, + to, + tourName, + onNextStep, + onPreviousStep, +}: DefaultActionsProps) => { + const { trackUsage } = useTracking(); + const dispatch = useGuidedTour('GuidedTourPopover', (s) => s.dispatch); + const state = useGuidedTour('GuidedTourPopover', (s) => s.state); + const currentStep = state.tours[tourName].currentStep + 1; + const actualTourLength = tours[tourName]._meta.totalStepCount; + + const handleSkip = () => { + trackUsage('didSkipGuidedTour', { name: tourName }); + dispatch({ type: 'skip_tour', payload: tourName }); + }; + + const handleNextStep = () => { + if (currentStep === actualTourLength) { + trackUsage('didCompleteGuidedTour', { name: tourName }); + } + + if (onNextStep) { + onNextStep(); + } else { + dispatch({ type: 'next_step', payload: tourName }); + } + }; + + const handlePreviousStep = () => { + if (onPreviousStep) { + onPreviousStep(); + } else { + dispatch({ type: 'previous_step', payload: tourName }); + } + }; + + return ( + + {showSkip && ( + + )} + {!showSkip && showPrevious && ( + + )} + {to ? ( + + + + ) : ( + + )} + + ); +}; /* ------------------------------------------------------------------------------------------------- * Step factory @@ -38,12 +146,14 @@ type WithActionsChildren = { children: React.ReactNode; showStepCount?: boolean; showSkip?: boolean; + showPrevious?: boolean; }; type WithActionsProps = { children?: undefined; showStepCount?: boolean; showSkip?: boolean; + showPrevious?: boolean; }; type StepProps = WithChildren | WithIntl; @@ -54,7 +164,11 @@ type Step = { React.ComponentProps & { withArrow?: boolean } >; Title: (props: StepProps) => React.ReactNode; - Content: (props: StepProps) => React.ReactNode; + Content: ( + props: StepProps & { + values?: Record React.ReactNode)>; + } + ) => React.ReactNode; Actions: (props: ActionsProps & { to?: string } & FlexProps) => React.ReactNode; }; @@ -71,23 +185,6 @@ const PopoverArrow = styled(Popover.Arrow)` transform: translateY(-16px) rotate(-90deg); `; -export const StepCount = ({ tourName }: { tourName: ValidTourName }) => { - const state = useGuidedTour('GuidedTourPopover', (s) => s.state); - const currentStep = state.tours[tourName].currentStep + 1; - // TODO: Currently all tours do not count their last step, but we should find a way to make this more smart - const displayedLength = state.tours[tourName].length - 1; - - return ( - - - - ); -}; - const createStepComponents = (tourName: ValidTourName): Step => ({ Root: React.forwardRef(({ withArrow = true, ...props }, ref) => { return ( @@ -97,6 +194,7 @@ const createStepComponents = (tourName: ValidTourName): Step => ({ side="top" align="center" style={{ border: 'none' }} + onClick={(e) => e.stopPropagation()} {...props} > {withArrow && ( @@ -139,31 +237,24 @@ const createStepComponents = (tourName: ValidTourName): Step => ({ props.children ) : ( - + )} ), - Actions: ({ showStepCount = true, showSkip = false, to, children, ...flexProps }) => { - const { trackUsage } = useTracking(); - const dispatch = useGuidedTour('GuidedTourPopover', (s) => s.dispatch); - const state = useGuidedTour('GuidedTourPopover', (s) => s.state); - const currentStep = state.tours[tourName].currentStep + 1; - const actualTourLength = state.tours[tourName].length; - - const handleSkipAction = () => { - trackUsage('didSkipGuidedTour', { name: tourName }); - dispatch({ type: 'skip_tour', payload: tourName }); - }; - - const handleNextStep = () => { - if (currentStep === actualTourLength) { - trackUsage('didCompleteGuidedTour', { name: tourName }); - } - dispatch({ type: 'next_step', payload: tourName }); - }; - + Actions: ({ + showStepCount = true, + showPrevious = true, + showSkip = false, + to, + children, + ...flexProps + }) => { return ( ({ ) : ( <> {showStepCount && } - - {showSkip && ( - - )} - {to ? ( - - - - ) : ( - - )} - + )} @@ -201,4 +282,4 @@ const createStepComponents = (tourName: ValidTourName): Step => ({ }); export type { Step }; -export { createStepComponents }; +export { createStepComponents, GotItAction, StepCount, DefaultActions }; diff --git a/packages/core/admin/admin/src/components/GuidedTour/Tours.tsx b/packages/core/admin/admin/src/components/GuidedTour/Tours.tsx index 1511be4b51..fe960dee64 100644 --- a/packages/core/admin/admin/src/components/GuidedTour/Tours.tsx +++ b/packages/core/admin/admin/src/components/GuidedTour/Tours.tsx @@ -1,253 +1,25 @@ import * as React from 'react'; -import { Box, Popover, Portal, Button } from '@strapi/design-system'; -import { FormattedMessage } from 'react-intl'; +import { Box, Popover, Portal } from '@strapi/design-system'; import { styled } from 'styled-components'; import { useGetGuidedTourMetaQuery } from '../../services/admin'; -import { - type State, - type Action, - useGuidedTour, - ValidTourName, - ExtendedCompletedActions, -} from './Context'; -import { Step, StepCount, createStepComponents } from './Step'; +import { type State, type Action, useGuidedTour, ValidTourName, CompletedActions } from './Context'; +import { apiTokensSteps } from './Steps/ApiTokensSteps'; +import { contentManagerSteps } from './Steps/ContentManagerSteps'; +import { contentTypeBuilderSteps } from './Steps/ContentTypeBuilderSteps'; +import { type Step, createStepComponents } from './Steps/Step'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from './utils/constants'; /* ------------------------------------------------------------------------------------------------- * Tours * -----------------------------------------------------------------------------------------------*/ -const GotItAction = ({ onClick }: { onClick: () => void }) => { - return ( - - ); -}; - const tours = { - contentTypeBuilder: createTour('contentTypeBuilder', [ - { - name: 'Introduction', - content: (Step) => ( - - - - - - ), - }, - { - name: 'CollectionTypes', - content: (Step) => ( - - - - - - ), - }, - { - name: 'SingleTypes', - content: (Step) => ( - - - - - - ), - }, - { - name: 'Components', - content: (Step, { dispatch }) => ( - - - - - - dispatch({ type: 'next_step', payload: 'contentTypeBuilder' })} - /> - - - ), - }, - { - name: 'Finish', - content: (Step) => ( - - - - - - ), - when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'), - }, - ]), - contentManager: createTour('contentManager', [ - { - name: 'Introduction', - when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'), - content: (Step) => ( - - - - - - ), - }, - { - name: 'Fields', - content: (Step) => ( - - - - - - ), - }, - { - name: 'Publish', - content: (Step, { dispatch }) => ( - - - - - - dispatch({ type: 'next_step', payload: 'contentManager' })} - /> - - - ), - }, - { - name: 'Finish', - content: (Step) => ( - - - - - - ), - when: (completedActions) => completedActions.includes('didCreateContent'), - }, - ]), - apiTokens: createTour('apiTokens', [ - { - name: 'Introduction', - content: (Step) => ( - - - - - - ), - }, - { - name: 'CreateAnAPIToken', - content: (Step) => ( - - - - - - ), - }, - { - name: 'CopyAPIToken', - content: (Step, { dispatch }) => ( - - - - - - dispatch({ type: 'next_step', payload: 'apiTokens' })} /> - - - ), - when: (completedActions) => completedActions.includes('didCreateApiToken'), - }, - { - name: 'Finish', - content: (Step) => ( - - - - - - ), - when: (completedActions) => completedActions.includes('didCopyApiToken'), - }, - ]), + contentTypeBuilder: createTour('contentTypeBuilder', contentTypeBuilderSteps), + contentManager: createTour('contentManager', contentManagerSteps), + apiTokens: createTour('apiTokens', apiTokensSteps), strapiCloud: createTour('strapiCloud', []), } as const; @@ -257,30 +29,26 @@ type Tours = typeof tours; * GuidedTourTooltip * -----------------------------------------------------------------------------------------------*/ -type Content = ( - Step: Step, - { - state, - dispatch, - }: { - state: State; - dispatch: React.Dispatch; - } -) => React.ReactNode; +export type StepContentProps = { + Step: Step; + state: State; + dispatch: React.Dispatch; +}; +type Content = (props: StepContentProps) => React.ReactNode; type GuidedTourTooltipProps = { children: React.ReactNode; content: Content; tourName: ValidTourName; step: number; - when?: (completedActions: ExtendedCompletedActions) => boolean; + when?: (completedActions: CompletedActions) => boolean; }; const GuidedTourTooltip = ({ children, ...props }: GuidedTourTooltipProps) => { const state = useGuidedTour('TooltipWrapper', (s) => s.state); if (!state.enabled) { - return <>{children}; + return children; } return {children}; @@ -328,16 +96,29 @@ const GuidedTourTooltipImpl = ({ }; }, [isPopoverOpen]); - // TODO: This isn't great but the only solution for syncing the completed actions - React.useEffect(() => { - dispatch({ - type: 'set_completed_actions', - payload: guidedTourMeta?.data?.completedActions ?? [], - }); - }, [dispatch, guidedTourMeta?.data?.completedActions]); - const Step = React.useMemo(() => createStepComponents(tourName), [tourName]); + const hasApiSchema = + Object.keys(guidedTourMeta?.data?.schemas ?? {}).filter((key) => key.startsWith('api::')) + .length > 0; + + React.useEffect(() => { + if (hasApiSchema) { + /** + * Fallback sync: + * + * When the user already has a schema (ie started project from template with seeded data), + * allow them to proceed to the content manager tour. + * + * When the CTB fails to restart after saving a schema (as it often does) + */ + dispatch({ + type: 'set_completed_actions', + payload: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], + }); + } + }, [dispatch, hasApiSchema, step, tourName]); + return ( <> {isPopoverOpen && ( @@ -347,7 +128,7 @@ const GuidedTourTooltipImpl = ({ )} {children} - {content(Step, { state, dispatch })} + {content({ Step, state, dispatch })} ); @@ -357,37 +138,53 @@ const GuidedTourTooltipImpl = ({ * Tour factory * -----------------------------------------------------------------------------------------------*/ -type TourStep

= { +export type TourStep

= { name: P; content: Content; - when?: (completedActions: ExtendedCompletedActions) => boolean; + when?: (completedActions: CompletedActions) => boolean; + excludeFromStepCount?: boolean; }; -function createTour>>(tourName: string, steps: T) { +export 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`); + const tour = steps.reduce( + (acc, step, index) => { + const name = step.name as keyof Components; + + if (name in acc) { + throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`); + } + + (acc as Components)[name] = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); + }; + + if (step.excludeFromStepCount) { + // Subtract all steps registered to be excluded from the step count + acc._meta.displayedStepCount--; + } + + return acc; + }, + { _meta: { totalStepCount: steps.length, displayedStepCount: steps.length } } as Components & { + _meta: { totalStepCount: number; displayedStepCount: number }; } - - acc[step.name as keyof Components] = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); - }; - - return acc; - }, {} as Components); + ); return tour; } diff --git a/packages/core/admin/admin/src/components/GuidedTour/tests/migrations.test.ts b/packages/core/admin/admin/src/components/GuidedTour/tests/migrations.test.ts new file mode 100644 index 0000000000..22924ad505 --- /dev/null +++ b/packages/core/admin/admin/src/components/GuidedTour/tests/migrations.test.ts @@ -0,0 +1,190 @@ +import { GUIDED_TOUR_REQUIRED_ACTIONS } from '../utils/constants'; +import { migrateTours } from '../utils/migrations'; + +import type { State } from '../Context'; + +describe('GuidedTour | migrateTours', () => { + it('should add new tours that are not in stored state', () => { + const initialState: State = { + // @ts-expect-error test + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + // Missing contentManager, apiTokens, and strapiCloud + }, + enabled: true, + completedActions: [], + }; + + const migratedState = migrateTours(initialState); + + // Should have all current tours + expect(Object.keys(migratedState.tours)).toEqual( + expect.arrayContaining(['contentTypeBuilder', 'contentManager', 'apiTokens', 'strapiCloud']) + ); + + // Existing tour should be preserved + expect(migratedState.tours.contentTypeBuilder.currentStep).toBe(2); + expect(migratedState.tours.contentTypeBuilder.isCompleted).toBe(false); + + // New tours should be initialized properly + expect(migratedState.tours.contentManager).toEqual({ + currentStep: 0, + isCompleted: false, + }); + expect(migratedState.tours.apiTokens).toEqual({ + currentStep: 0, + isCompleted: false, + }); + expect(migratedState.tours.strapiCloud).toEqual({ + currentStep: 0, + isCompleted: false, + }); + }); + + it('should remove tours that no longer exist in current tours', () => { + const initialState: State = { + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + contentManager: { + currentStep: 1, + isCompleted: true, + }, + // @ts-expect-error - simulating removed tour + removedTour: { + currentStep: 3, + isCompleted: false, + }, + anotherRemovedTour: { + currentStep: 0, + isCompleted: true, + }, + }, + enabled: true, + completedActions: [], + }; + + const migratedState = migrateTours(initialState); + + // Should only have current tours + expect(Object.keys(migratedState.tours)).toEqual( + expect.arrayContaining(['contentTypeBuilder', 'contentManager', 'apiTokens', 'strapiCloud']) + ); + expect(Object.keys(migratedState.tours)).toHaveLength(4); + + // Should not have removed tours + expect(migratedState.tours).not.toHaveProperty('removedTour'); + expect(migratedState.tours).not.toHaveProperty('anotherRemovedTour'); + + // Existing valid tours should be preserved + expect(migratedState.tours.contentTypeBuilder.currentStep).toBe(2); + expect(migratedState.tours.contentManager.currentStep).toBe(1); + expect(migratedState.tours.contentManager.isCompleted).toBe(true); + }); + + it('should handle both additions and removals simultaneously', () => { + const initialState: State = { + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + // Missing contentManager, apiTokens, strapiCloud (additions) + // @ts-expect-error - simulating removed tour + removedTour: { + currentStep: 3, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], + }; + + const migratedState = migrateTours(initialState); + + // Should have exactly the current tours + expect(Object.keys(migratedState.tours)).toEqual( + expect.arrayContaining(['contentTypeBuilder', 'contentManager', 'apiTokens', 'strapiCloud']) + ); + expect(Object.keys(migratedState.tours)).toHaveLength(4); + + // Existing tour should be preserved + expect(migratedState.tours.contentTypeBuilder.currentStep).toBe(2); + + // New tours should be added with proper initialization + expect(migratedState.tours.contentManager).toEqual({ + currentStep: 0, + isCompleted: false, + }); + + // Removed tour should not exist + expect(migratedState.tours).not.toHaveProperty('removedTour'); + }); + + it('should return state unchanged when tours are already synchronized', () => { + const initialState: State = { + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + contentManager: { + currentStep: 1, + isCompleted: true, + }, + apiTokens: { + currentStep: 0, + isCompleted: false, + }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], + }; + + const migratedState = migrateTours(initialState); + + // Should have all tours with preserved state + expect(migratedState.tours.contentTypeBuilder.currentStep).toBe(2); + expect(migratedState.tours.contentManager.currentStep).toBe(1); + expect(migratedState.tours.contentManager.isCompleted).toBe(true); + expect(migratedState.tours.apiTokens.currentStep).toBe(0); + expect(migratedState.tours.strapiCloud.currentStep).toBe(0); + + // Should have exactly the current tours + expect(Object.keys(migratedState.tours)).toEqual( + expect.arrayContaining(['contentTypeBuilder', 'contentManager', 'apiTokens', 'strapiCloud']) + ); + expect(Object.keys(migratedState.tours)).toHaveLength(4); + }); + + it('should preserve other state properties during migration', () => { + const initialState: State = { + // @ts-expect-error - simulating tours with only one tour + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + }, + enabled: false, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], + }; + + const migratedState = migrateTours(initialState); + + // Other state properties should be preserved + expect(migratedState.enabled).toBe(false); + expect(migratedState.completedActions).toEqual([ + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + ]); + }); +}); diff --git a/packages/core/admin/admin/src/components/GuidedTour/tests/reducer.test.ts b/packages/core/admin/admin/src/components/GuidedTour/tests/reducer.test.ts index 0f8a07dc65..a580e13328 100644 --- a/packages/core/admin/admin/src/components/GuidedTour/tests/reducer.test.ts +++ b/packages/core/admin/admin/src/components/GuidedTour/tests/reducer.test.ts @@ -1,4 +1,6 @@ -import { type Action, type ExtendedCompletedActions, reducer } from '../Context'; +import { type Action, reducer } from '../Context'; +import { tours } from '../Tours'; +import { GUIDED_TOUR_REQUIRED_ACTIONS } from '../utils/constants'; describe('GuidedTour | reducer', () => { describe('next_step', () => { @@ -8,26 +10,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { @@ -40,26 +38,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -71,26 +65,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: false, - length: 1, }, contentManager: { currentStep: 2, isCompleted: false, - length: 1, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { @@ -103,57 +93,50 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 2, isCompleted: false, - length: 1, }, contentManager: { currentStep: 2, isCompleted: false, - length: 1, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); }); it('should mark tour as completed when reaching the last step', () => { + const totalStepCount = tours.contentTypeBuilder._meta.totalStepCount; const initialState = { tours: { contentTypeBuilder: { - currentStep: 0, + currentStep: totalStepCount - 1, isCompleted: false, - length: 1, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { @@ -164,28 +147,24 @@ describe('GuidedTour | reducer', () => { const expectedState = { tours: { contentTypeBuilder: { - currentStep: 1, + currentStep: totalStepCount, isCompleted: true, - length: 1, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -199,26 +178,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 3, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { @@ -231,26 +206,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: true, - length: 3, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -262,26 +233,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 3, }, contentManager: { currentStep: 1, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { @@ -294,26 +261,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: true, - length: 3, }, contentManager: { currentStep: 1, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -327,31 +290,30 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { type: 'set_completed_actions', - payload: ['didCreateContentTypeSchema', 'didCreateContent'] as ExtendedCompletedActions, + payload: [ + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.contentManager.createContent, + ], }; const expectedState = { @@ -359,29 +321,25 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, completedActions: [ - 'didCreateContentTypeSchema', - 'didCreateContent', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.contentManager.createContent, + ], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -393,34 +351,33 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + ], }; const action: Action = { type: 'set_completed_actions', - payload: ['didCreateContentTypeSchema', 'didCreateApiToken'] as ExtendedCompletedActions, + payload: [ + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.createToken, + ], }; const expectedState = { @@ -428,30 +385,26 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - 'didCreateApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.createToken, + ], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -463,31 +416,27 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], }; const action: Action = { type: 'set_completed_actions', - payload: [] as ExtendedCompletedActions, + payload: [], }; const expectedState = { @@ -495,26 +444,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -526,31 +471,27 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: true, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 2, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: false, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; const action: Action = { type: 'set_completed_actions', - payload: ['didCopyApiToken'] as ExtendedCompletedActions, + payload: [GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken], }; const expectedState = { @@ -558,26 +499,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: true, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 2, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: false, - completedActions: ['didCopyApiToken'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -591,26 +528,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: true, - length: 2, }, apiTokens: { currentStep: 2, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], }; const action: Action = { @@ -622,26 +555,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 1, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: true, - length: 2, }, apiTokens: { currentStep: 2, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: false, - completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -653,30 +582,26 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - 'didCreateApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.createToken, + ], }; const action: Action = { @@ -688,30 +613,26 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 2, }, contentManager: { currentStep: 0, isCompleted: false, - length: 2, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 3, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: false, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - 'didCreateApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.createToken, + ], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -719,36 +640,32 @@ describe('GuidedTour | reducer', () => { }); describe('reset_all_tours', () => { - it('should reset when all tours have been completed', () => { + it('should reset all tours', () => { const initialState = { tours: { contentTypeBuilder: { - currentStep: 5, + currentStep: 8, isCompleted: true, - length: 5, }, contentManager: { currentStep: 4, isCompleted: true, - length: 4, }, apiTokens: { currentStep: 4, isCompleted: true, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: true, - length: 0, }, }, enabled: true, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - 'didCreateApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.createToken, + ], }; const action: Action = { @@ -760,26 +677,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 5, }, contentManager: { currentStep: 0, isCompleted: false, - length: 4, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -791,26 +704,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 2, isCompleted: false, - length: 5, }, contentManager: { currentStep: 4, isCompleted: true, - length: 4, }, apiTokens: { currentStep: 1, isCompleted: false, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: ['didCreateContentTypeSchema'] as ExtendedCompletedActions, + completedActions: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema], }; const action: Action = { @@ -822,26 +731,22 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 5, }, contentManager: { currentStep: 0, isCompleted: false, - length: 4, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); @@ -853,29 +758,25 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 3, isCompleted: false, - length: 5, }, contentManager: { currentStep: 2, isCompleted: false, - length: 4, }, apiTokens: { currentStep: 4, isCompleted: true, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: false, completedActions: [ - 'didCreateContentTypeSchema', - 'didCopyApiToken', - ] as ExtendedCompletedActions, + GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema, + GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken, + ], }; const action: Action = { @@ -887,26 +788,134 @@ describe('GuidedTour | reducer', () => { contentTypeBuilder: { currentStep: 0, isCompleted: false, - length: 5, }, contentManager: { currentStep: 0, isCompleted: false, - length: 4, }, apiTokens: { currentStep: 0, isCompleted: false, - length: 4, }, strapiCloud: { currentStep: 0, isCompleted: false, - length: 0, }, }, enabled: true, - completedActions: [] as ExtendedCompletedActions, + completedActions: [], + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + }); + + describe('previous_step', () => { + it('should decrement the step count for the specified tour', () => { + const initialState = { + tours: { + contentTypeBuilder: { + currentStep: 2, + isCompleted: false, + }, + contentManager: { + currentStep: 0, + isCompleted: false, + }, + apiTokens: { + currentStep: 0, + isCompleted: false, + }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], + }; + + const action: Action = { + type: 'previous_step', + payload: 'contentTypeBuilder', + }; + + const expectedState = { + tours: { + contentTypeBuilder: { + currentStep: 1, + isCompleted: false, + }, + contentManager: { + currentStep: 0, + isCompleted: false, + }, + apiTokens: { + currentStep: 0, + isCompleted: false, + }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], + }; + + expect(reducer(initialState, action)).toEqual(expectedState); + }); + + it('should not decrement below 0', () => { + const initialState = { + tours: { + contentTypeBuilder: { + currentStep: 0, + isCompleted: false, + }, + contentManager: { + currentStep: 0, + isCompleted: false, + }, + apiTokens: { + currentStep: 0, + isCompleted: false, + }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], + }; + + const action: Action = { + type: 'previous_step', + payload: 'contentTypeBuilder', + }; + + const expectedState = { + tours: { + contentTypeBuilder: { + currentStep: 0, + isCompleted: false, + }, + contentManager: { + currentStep: 0, + isCompleted: false, + }, + apiTokens: { + currentStep: 0, + isCompleted: false, + }, + strapiCloud: { + currentStep: 0, + isCompleted: false, + }, + }, + enabled: true, + completedActions: [], }; expect(reducer(initialState, action)).toEqual(expectedState); diff --git a/packages/core/admin/admin/src/components/GuidedTour/utils/constants.ts b/packages/core/admin/admin/src/components/GuidedTour/utils/constants.ts new file mode 100644 index 0000000000..3eb70d1148 --- /dev/null +++ b/packages/core/admin/admin/src/components/GuidedTour/utils/constants.ts @@ -0,0 +1,16 @@ +const GUIDED_TOUR_REQUIRED_ACTIONS = { + contentTypeBuilder: { + createSchema: 'didCreateContentTypeSchema', + addField: 'didAddFieldToSchema', + }, + contentManager: { + createContent: 'didCreateContent', + }, + apiTokens: { + createToken: 'didCreateApiToken', + copyToken: 'didCopyApiToken', + }, + strapiCloud: {}, +} as const; + +export { GUIDED_TOUR_REQUIRED_ACTIONS }; diff --git a/packages/core/admin/admin/src/components/GuidedTour/utils/migrations.ts b/packages/core/admin/admin/src/components/GuidedTour/utils/migrations.ts new file mode 100644 index 0000000000..49e925d90b --- /dev/null +++ b/packages/core/admin/admin/src/components/GuidedTour/utils/migrations.ts @@ -0,0 +1,34 @@ +import { produce } from 'immer'; + +import { tours } from '../Tours'; + +import type { State, ValidTourName } from '../Context'; + +/** + * Migrates tours added or removed from the tours object + */ +const migrateTours = (storedTourState: State) => { + const storedTourNames = Object.keys(storedTourState.tours) as ValidTourName[]; + const currentTourNames = Object.keys(tours) as ValidTourName[]; + + return produce(storedTourState, (draft) => { + // Add new tours that don't exist in stored state + currentTourNames.forEach((tourName) => { + if (!storedTourNames.includes(tourName)) { + draft.tours[tourName] = { + currentStep: 0, + isCompleted: false, + }; + } + }); + + // Remove tours that no longer exist in current tours + storedTourNames.forEach((tourName) => { + if (!currentTourNames.includes(tourName)) { + delete draft.tours[tourName]; + } + }); + }); +}; + +export { migrateTours }; diff --git a/packages/core/admin/admin/src/components/SubNav.tsx b/packages/core/admin/admin/src/components/SubNav.tsx index efa522ae5d..3d9fe5522c 100644 --- a/packages/core/admin/admin/src/components/SubNav.tsx +++ b/packages/core/admin/admin/src/components/SubNav.tsx @@ -131,7 +131,7 @@ const GuidedTourTooltip = ({ case 'models': return ( - {children} + {children} ); case 'singleTypes': diff --git a/packages/core/admin/admin/src/index.ts b/packages/core/admin/admin/src/index.ts index d77127be4b..7d96362cee 100644 --- a/packages/core/admin/admin/src/index.ts +++ b/packages/core/admin/admin/src/index.ts @@ -25,7 +25,12 @@ export * from './components/ContentBox'; export * from './components/SubNav'; export * from './components/GradientBadge'; +/** @internal */ export { tours } from './components/GuidedTour/Tours'; +/** @internal */ +export { useGuidedTour } from './components/GuidedTour/Context'; +/** @internal */ +export { GUIDED_TOUR_REQUIRED_ACTIONS } from './components/GuidedTour/utils/constants'; /** * Features diff --git a/packages/core/admin/admin/src/pages/Settings/components/Tokens/FormHead.tsx b/packages/core/admin/admin/src/pages/Settings/components/Tokens/FormHead.tsx index 7736a31fe2..e17af4c69a 100644 --- a/packages/core/admin/admin/src/pages/Settings/components/Tokens/FormHead.tsx +++ b/packages/core/admin/admin/src/pages/Settings/components/Tokens/FormHead.tsx @@ -5,6 +5,7 @@ import { Check, ArrowClockwise, Eye, EyeStriked } from '@strapi/icons'; import { MessageDescriptor, useIntl } from 'react-intl'; import { ConfirmDialog } from '../../../../components/ConfirmDialog'; +import { tours } from '../../../../components/GuidedTour/Tours'; import { Layouts } from '../../../../components/Layouts/Layout'; import { BackButton } from '../../../../features/BackButton'; import { useNotification } from '../../../../features/Notifications'; @@ -159,29 +160,31 @@ export const FormHead = ({ /> )} {token?.id && toggleToken && ( - - - + + + )}