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 && (
-
- : }
- variant="secondary"
- onClick={() => toggleToken?.()}
- disabled={!canShowToken}
+
+
- {formatMessage({
- id: 'Settings.tokens.viewToken',
- defaultMessage: 'View token',
- })}
-
-
+ : }
+ variant="secondary"
+ onClick={() => toggleToken?.()}
+ disabled={!canShowToken}
+ >
+ {formatMessage({
+ id: 'Settings.tokens.viewToken',
+ defaultMessage: 'View token',
+ })}
+
+
+
)}
- {sortedTokens.map((token) => (
-
-
-
- {token.name}
-
-
-
-
- {token.description}
-
-
-
-
- {/* @ts-expect-error One of the tokens doesn't have createdAt */}
-
-
-
-
- {token.lastUsedAt && (
-
-
+ {sortedTokens.map((token) => {
+ const GuidedTourTooltip =
+ token.name === 'Read Only' ? tours.apiTokens.ManageAPIToken : React.Fragment;
+ return (
+
+
+
+ {token.name}
- )}
-
- {canUpdate || canRead || canDelete ? (
-
-
- {canUpdate && }
- {canDelete && (
- onConfirmDelete?.(token.id)}
- tokenType={tokenType}
- />
- )}
-
- ) : null}
-
- ))}
+
+
+ {token.description}
+
+
+
+
+ {/* @ts-expect-error One of the tokens doesn't have createdAt */}
+
+
+
+
+ {token.lastUsedAt && (
+
+
+
+ )}
+
+ {canUpdate || canRead || canDelete ? (
+
+
+
+ {canUpdate && }
+
+ {canDelete && (
+ onConfirmDelete?.(token.id)}
+ tokenType={tokenType}
+ />
+ )}
+
+
+ ) : null}
+
+ );
+ })}
diff --git a/packages/core/admin/admin/src/pages/Settings/components/Tokens/TokenBox.tsx b/packages/core/admin/admin/src/pages/Settings/components/Tokens/TokenBox.tsx
index af7f2f60f1..485f6c018f 100644
--- a/packages/core/admin/admin/src/pages/Settings/components/Tokens/TokenBox.tsx
+++ b/packages/core/admin/admin/src/pages/Settings/components/Tokens/TokenBox.tsx
@@ -8,6 +8,7 @@ import { styled } from 'styled-components';
import { ContentBox } from '../../../../components/ContentBox';
import { useGuidedTour } from '../../../../components/GuidedTour/Context';
import { tours } from '../../../../components/GuidedTour/Tours';
+import { GUIDED_TOUR_REQUIRED_ACTIONS } from '../../../../components/GuidedTour/utils/constants';
import { useNotification } from '../../../../features/Notifications';
import { useTracking } from '../../../../features/Tracking';
import { useClipboard } from '../../../../hooks/useClipboard';
@@ -29,7 +30,7 @@ export const ApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
const { copy } = useClipboard();
- const handleClick = (token: TokenBoxProps['token']) => async () => {
+ const handleCopyToken = async (token: TokenBoxProps['token']) => {
if (token) {
const didCopy = await copy(token);
@@ -39,7 +40,7 @@ export const ApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
});
dispatch({
type: 'set_completed_actions',
- payload: ['didCopyApiToken'],
+ payload: [GUIDED_TOUR_REQUIRED_ACTIONS.apiTokens.copyToken],
});
toggleNotification({
type: 'success',
@@ -68,8 +69,8 @@ export const ApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
{formatMessage({
- id: 'Settings.tokens.copy.lastWarning',
- defaultMessage: "Make sure to copy this token, you won't be able to see it again!",
+ id: 'Settings.apiTokens.copy.lastWarning',
+ defaultMessage: 'Copy your API token',
})}
@@ -82,7 +83,10 @@ export const ApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
}
variant="secondary"
- onClick={handleClick(token)}
+ onClick={(e: React.MouseEvent) => {
+ e.preventDefault();
+ handleCopyToken(token);
+ }}
marginTop={6}
>
{formatMessage({ id: 'Settings.tokens.copy.copy', defaultMessage: 'Copy' })}
diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx
index 1bca206824..78fd4a5db3 100644
--- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx
+++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx
@@ -21,7 +21,7 @@ import { useGetPermissionsQuery, useGetRoutesQuery } from '../../../../../servic
import { isBaseQueryError } from '../../../../../utils/baseQuery';
import { API_TOKEN_TYPE } from '../../../components/Tokens/constants';
import { FormHead } from '../../../components/Tokens/FormHead';
-import { TokenBox, ApiTokenBox } from '../../../components/Tokens/TokenBox';
+import { ApiTokenBox } from '../../../components/Tokens/TokenBox';
import {
ApiTokenPermissionsContextValue,
@@ -50,6 +50,7 @@ export const EditView = () => {
}
: null
);
+
const [showToken, setShowToken] = React.useState(Boolean(locationState?.apiToken?.accessKey));
const hideTimerRef = React.useRef | null>(null);
const { trackUsage } = useTracking();
diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx
index 73b86a242b..fe4459fb85 100644
--- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx
+++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx
@@ -1,6 +1,6 @@
import * as React from 'react';
-import { EmptyStateLayout, LinkButton } from '@strapi/design-system';
+import { Box, EmptyStateLayout, LinkButton } from '@strapi/design-system';
import { Plus } from '@strapi/icons';
import { EmptyDocuments } from '@strapi/icons/symbols';
import * as qs from 'qs';
@@ -130,6 +130,12 @@ export const ListView = () => {
return (
<>
+ {apiTokens.length > 0 && (
+
+ {/* Invisible Anchor */}
+
+
+ )}
{formatMessage(
{ id: 'Settings.PageTitle', defaultMessage: 'Settings - {name}' },
@@ -144,25 +150,23 @@ export const ListView = () => {
})}
primaryAction={
canCreate && (
-
- }
- size="S"
- onClick={() =>
- trackUsage('willAddTokenFromList', {
- tokenType: API_TOKEN_TYPE,
- })
- }
- to="/settings/api-tokens/create"
- >
- {formatMessage({
- id: 'Settings.apiTokens.create',
- defaultMessage: 'Create new API Token',
- })}
-
-
+ }
+ size="S"
+ onClick={() =>
+ trackUsage('willAddTokenFromList', {
+ tokenType: API_TOKEN_TYPE,
+ })
+ }
+ to="/settings/api-tokens/create"
+ >
+ {formatMessage({
+ id: 'Settings.apiTokens.create',
+ defaultMessage: 'Create new API Token',
+ })}
+
)
}
/>
@@ -170,51 +174,49 @@ export const ListView = () => {
) : (
-
-
- {apiTokens.length > 0 && (
-
- )}
- {canCreate && apiTokens.length === 0 ? (
- }
- content={formatMessage({
- id: 'Settings.apiTokens.addFirstToken',
- defaultMessage: 'Add your first API Token',
- })}
- action={
- }
- to="/settings/api-tokens/create"
- >
- {formatMessage({
- id: 'Settings.apiTokens.addNewToken',
- defaultMessage: 'Add new API Token',
- })}
-
- }
- />
- ) : null}
- {!canCreate && apiTokens.length === 0 ? (
- }
- content={formatMessage({
- id: 'Settings.apiTokens.emptyStateLayout',
- defaultMessage: 'You don’t have any content yet...',
- })}
- />
- ) : null}
-
-
+
+ {apiTokens.length > 0 && (
+
+ )}
+ {canCreate && apiTokens.length === 0 ? (
+ }
+ content={formatMessage({
+ id: 'Settings.apiTokens.addFirstToken',
+ defaultMessage: 'Add your first API Token',
+ })}
+ action={
+ }
+ to="/settings/api-tokens/create"
+ >
+ {formatMessage({
+ id: 'Settings.apiTokens.addNewToken',
+ defaultMessage: 'Add new API Token',
+ })}
+
+ }
+ />
+ ) : null}
+ {!canCreate && apiTokens.length === 0 ? (
+ }
+ content={formatMessage({
+ id: 'Settings.apiTokens.emptyStateLayout',
+ defaultMessage: 'You don’t have any content yet...',
+ })}
+ />
+ ) : null}
+
)}
>
diff --git a/packages/core/admin/admin/src/services/apiTokens.ts b/packages/core/admin/admin/src/services/apiTokens.ts
index 4f2dcc84f3..bb8cbe5855 100644
--- a/packages/core/admin/admin/src/services/apiTokens.ts
+++ b/packages/core/admin/admin/src/services/apiTokens.ts
@@ -31,11 +31,7 @@ const apiTokensService = adminApi
data: body,
}),
transformResponse: (response: ApiToken.Create.Response) => response.data,
- invalidatesTags: [
- { type: 'ApiToken' as const, id: 'LIST' },
- 'GuidedTourMeta',
- 'HomepageKeyStatistics',
- ],
+ invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }, 'HomepageKeyStatistics'],
}),
deleteAPIToken: builder.mutation<
ApiToken.Revoke.Response['data'],
diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json
index 8304684b59..cdc10ce254 100644
--- a/packages/core/admin/admin/src/translations/en.json
+++ b/packages/core/admin/admin/src/translations/en.json
@@ -67,7 +67,7 @@
"HomePage.widget.loading": "Loading widget content",
"HomePage.widget.error": "Couldn't load widget content.",
"HomePage.widget.no-data": "No content found.",
- "HomePage.widget.no-permissions": "You don’t have the permission to see this widget",
+ "HomePage.widget.no-permissions": "You don't have the permission to see this widget",
"Media Library": "Media Library",
"New entry": "New entry",
"Password": "Password",
@@ -81,6 +81,7 @@
"Roles.RoleRow.user-count": "{number, plural, =0 {# user} one {# user} other {# users}}",
"Roles.components.List.empty.withSearch": "There is no role corresponding to the search ({search})...",
"Settings.PageTitle": "Settings — {name}",
+ "Settings.apiTokens.copy.lastWarning": "Copy your API token",
"Settings.apiTokens.ListView.headers.createdAt": "Created at",
"Settings.apiTokens.ListView.headers.description": "Description",
"Settings.apiTokens.ListView.headers.lastUsedAt": "Last used",
@@ -802,32 +803,42 @@
"selectButtonTitle": "Select",
"skipToContent": "Skip to content",
"submit": "Submit",
- "tours.contentTypeBuilder.Introduction.title": "Content-Type Builder",
- "tours.contentTypeBuilder.Introduction.content": "Create and manage your content structure with collection types, single types and components.",
+ "tours.contentTypeBuilder.Introduction.title": "Welcome to the Content-Type Builder!",
+ "tours.contentTypeBuilder.Introduction.content": "Here you create and manage the structure of your app with collection types, single types, and reusable components. Let's dive in!",
"tours.contentTypeBuilder.CollectionTypes.title": "Collection Types",
- "tours.contentTypeBuilder.CollectionTypes.content": "A content structure that can manage multiple entries, such as articles or products.",
+ "tours.contentTypeBuilder.CollectionTypes.content": "These are your go-to for managing multiple entries — think blog posts or products.",
"tours.contentTypeBuilder.SingleTypes.title": "Single Types",
- "tours.contentTypeBuilder.SingleTypes.content": "A content structure that can manage only one entry, such as a homepage or a contact page.",
+ "tours.contentTypeBuilder.SingleTypes.content": "Perfect for one-off entries like your homepage or site settings.",
"tours.contentTypeBuilder.Components.title": "Components",
- "tours.contentTypeBuilder.Components.content": "A content structure that can be used in multiple collection types or single types.",
- "tours.contentTypeBuilder.Finish.title": "It's time to create content!",
- "tours.contentTypeBuilder.Finish.content": "Now that you created content types, you'll be able to create content in the content manager.",
+ "tours.contentTypeBuilder.Components.content": "Build it once, reuse it everywhere. Use components for things like buttons, cards, or sliders.",
+ "tours.contentTypeBuilder.YourTurn.title": "Your turn — Build something!",
+ "tours.contentTypeBuilder.YourTurn.content": "Go ahead and create a collection type or single type. Click the \"+\" button, pick a name, hit \"Continue\", and we'll guide you from there.",
+ "tours.contentTypeBuilder.AddFields.title": "Add a field to bring it to life",
+ "tours.contentTypeBuilder.AddFields.content": "Start by adding your first field — like a name, image, or relation. Your content type needs a shape before it can hold content.",
+ "tours.contentTypeBuilder.Save.title": "Don't leave without saving!",
+ "tours.contentTypeBuilder.Save.content": "Click the \"Save\" button to lock in your content type and avoid losing your work. Almost done!",
+ "tours.contentTypeBuilder.Finish.title": "First Step: Done! 🎉",
+ "tours.contentTypeBuilder.Finish.content": "You've built your first content type! Now head over to the Content Manager to start adding entries!",
+ "tours.apiTokens.Introduction.title": "Last but not least, API tokens",
+ "tours.apiTokens.Introduction.content": "Control API access with highly customizable permissions.",
+ "tours.apiTokens.ManageAPIToken.title": "Manage an API token",
+ "tours.apiTokens.ManageAPIToken.content": "Click the \"Pencil\" icon to view and update an existing API token.",
+ "tours.apiTokens.ViewAPIToken.title": "View API token",
+ "tours.apiTokens.ViewAPIToken.content": "Click the \"View token\" button to see your API token.",
+ "tours.apiTokens.CopyAPIToken.title": "Copy your API token",
+ "tours.apiTokens.CopyAPIToken.content": "Click the \"Copy\" button to save your API token, you will need it to make requests to your application. {spacer} Still have questions? Learn more about API tokens .",
+ "tours.apiTokens.FinalStep.title": "Congratulations, it's time to deploy your application!",
+ "tours.apiTokens.FinalStep.content": "You have everything you need to deploy your application and share content with the world.",
"tours.contentManager.Introduction.title": "Content manager",
"tours.contentManager.Introduction.content": "Create and manage content from your collection types and single types.",
- "tours.apiTokens.Introduction.title": "API tokens",
- "tours.apiTokens.Introduction.content": "Create and manage API tokens with highly customizable permissions.",
- "tours.apiTokens.CreateAnAPIToken.title": "Create an API token",
- "tours.apiTokens.CreateAnAPIToken.content": "Create a new API token. Choose a name, duration and type.",
- "tours.apiTokens.CopyAPIToken.title": "Copy your new API token",
- "tours.apiTokens.CopyAPIToken.content": "Make sure to do it now, you won't be able to see it again. You'll need to generate a new one if you lose it.",
- "tours.apiTokens.FinalStep.title": "It's time to deploy your application!",
- "tours.apiTokens.FinalStep.content": "Your application is ready to be deployed and its content to be shared with the world!",
+ "tours.contentManager.CreateNewEntry.title": "Create new entry",
+ "tours.contentManager.CreateNewEntry.content": "Click the \"Create new entry\" button to create and publish a new entry for this collection type.",
"tours.contentManager.Fields.title": "Fields",
- "tours.contentManager.Fields.content": "Add content to the fields created in the Content-Type Builder.",
+ "tours.contentManager.Fields.content": "First, fill in the fields you created in the Content-Type Builder.",
"tours.contentManager.Publish.title": "Publish",
- "tours.contentManager.Publish.content": "Publish entries to make their content available through the content API.",
- "tours.contentManager.FinalStep.title": "It's time to create API Tokens!",
- "tours.contentManager.FinalStep.content": "Now that you've created and published content, time to create API tokens and set up permissions.",
+ "tours.contentManager.Publish.content": "Then click the \"Publish\" button to make your content available through the content API.",
+ "tours.contentManager.FinalStep.title": "Time to setup API tokens!",
+ "tours.contentManager.FinalStep.content": "Now that you've created and published an entry, let's setup an API token to manage access to your content.",
"tours.stepCount": "Step {currentStep} of {tourLength}",
"tours.skip": "Skip",
"tours.next": "Next",
@@ -838,7 +849,7 @@
"tours.overview.tasks": "Your tasks",
"tours.overview.contentTypeBuilder.label": "Create your schema",
"tours.overview.contentManager.label": "Create and publish content",
- "tours.overview.apiTokens.label": "Create and copy an API token",
+ "tours.overview.apiTokens.label": "Copy an API token",
"tours.overview.strapiCloud.label": "Deploy your application to Strapi Cloud",
"tours.overview.strapiCloud.link": "Read documentation",
"tours.overview.tour.link": "Start",
diff --git a/packages/core/admin/server/src/controllers/admin.ts b/packages/core/admin/server/src/controllers/admin.ts
index 038aba2843..59bb2fc700 100644
--- a/packages/core/admin/server/src/controllers/admin.ts
+++ b/packages/core/admin/server/src/controllers/admin.ts
@@ -187,15 +187,12 @@ export default {
},
async getGuidedTourMeta(ctx: Context) {
- const [isFirstSuperAdminUser, completedActions] = await Promise.all([
- getService('user').isFirstSuperAdminUser(ctx.state.user.id),
- getService('guided-tour').getCompletedActions(),
- ]);
+ const isFirstSuperAdminUser = await getService('user').isFirstSuperAdminUser(ctx.state.user.id);
return {
data: {
isFirstSuperAdminUser,
- completedActions,
+ schemas: strapi.contentTypes,
},
} satisfies GetGuidedTourMeta.Response;
},
diff --git a/packages/core/admin/server/src/services/guided-tour.ts b/packages/core/admin/server/src/services/guided-tour.ts
index 82d67b9a3c..a2ae2a2f49 100644
--- a/packages/core/admin/server/src/services/guided-tour.ts
+++ b/packages/core/admin/server/src/services/guided-tour.ts
@@ -1,45 +1,25 @@
import { Core, Internal } from '@strapi/types';
-import constants from './constants';
export type GuidedTourRequiredActions = {
- didCreateContentTypeSchema: boolean;
didCreateContent: boolean;
- didCreateApiToken: boolean;
};
export type GuidedTourCompletedActions = keyof GuidedTourRequiredActions;
-const DEFAULT_ATTIBUTES = [
- 'createdAt',
- 'updatedAt',
- 'publishedAt',
- 'createdBy',
- 'updatedBy',
- 'locale',
- 'localizations',
-];
-
export const createGuidedTourService = ({ strapi }: { strapi: Core.Strapi }) => {
+ /**
+ * @internal
+ * TODO:
+ * Remove completed actions from the server and handle it all on the frontend
+ * [x] didCreateContentTypeSchema
+ * [ ] didCreateContent
+ * [x] didCreateApiToken
+ */
const getCompletedActions = async () => {
- // Check if any content-type schemas have been created on the api:: namespace
- const contentTypeSchemaNames = Object.keys(strapi.contentTypes).filter((contentTypeUid) =>
- contentTypeUid.startsWith('api::')
- );
- const contentTypeSchemaAttributes = contentTypeSchemaNames.map((uid) => {
- const attributes = Object.keys(
- strapi.contentType(uid as Internal.UID.ContentType).attributes
- );
- return attributes.filter((attribute) => !DEFAULT_ATTIBUTES.includes(attribute));
- });
- const didCreateContentTypeSchema = (() => {
- if (contentTypeSchemaNames.length === 0) {
- return false;
- }
- return contentTypeSchemaAttributes.some((attributes) => attributes.length > 0);
- })();
-
// Check if any content has been created for content-types on the api:: namespace
const hasContent = await (async () => {
- for (const name of contentTypeSchemaNames) {
+ for (const name of Object.keys(strapi.contentTypes).filter((contentTypeUid) =>
+ contentTypeUid.startsWith('api::')
+ )) {
const count = await strapi.documents(name as Internal.UID.ContentType).count({});
if (count > 0) return true;
@@ -47,23 +27,10 @@ export const createGuidedTourService = ({ strapi }: { strapi: Core.Strapi }) =>
return false;
})();
- const didCreateContent = didCreateContentTypeSchema && hasContent;
+ const didCreateContent = hasContent;
- // Check if any api tokens have been created besides the default ones
- const createdApiTokens = await strapi
- .documents('admin::api-token')
- .findMany({ fields: ['name', 'description'] });
- const didCreateApiToken = createdApiTokens.some((doc) =>
- constants.DEFAULT_API_TOKENS.every(
- (token) => token.name !== doc.name && token.description !== doc.description
- )
- );
-
- // Compute an array of action names that have been completed
const requiredActions = {
- didCreateContentTypeSchema,
didCreateContent,
- didCreateApiToken,
};
const requiredActionNames = Object.keys(requiredActions) as Array;
const completedActions = requiredActionNames.filter((key) => requiredActions[key]);
diff --git a/packages/core/admin/server/src/services/index.ts b/packages/core/admin/server/src/services/index.ts
index 5e2d6e8c3c..ae789e0d54 100644
--- a/packages/core/admin/server/src/services/index.ts
+++ b/packages/core/admin/server/src/services/index.ts
@@ -14,7 +14,6 @@ import * as action from './action';
import * as apiToken from './api-token';
import * as transfer from './transfer';
import * as projectSettings from './project-settings';
-import { createGuidedTourService } from './guided-tour';
import { homepageService } from './homepage';
// TODO: TS - Export services one by one as this export is cjs
@@ -34,6 +33,5 @@ export default {
transfer,
'project-settings': projectSettings,
encryption,
- 'guided-tour': createGuidedTourService,
homepage: homepageService,
};
diff --git a/packages/core/admin/server/src/utils/index.d.ts b/packages/core/admin/server/src/utils/index.d.ts
index b371e3d34f..90091a1938 100644
--- a/packages/core/admin/server/src/utils/index.d.ts
+++ b/packages/core/admin/server/src/utils/index.d.ts
@@ -10,7 +10,6 @@ import * as token from '../services/token';
import * as apiToken from '../services/api-token';
import * as projectSettings from '../services/project-settings';
import * as transfer from '../services/transfer';
-import { createGuidedTourService } from '../services/guided-tour';
import { homepageService } from '../services/homepage';
type S = {
diff --git a/packages/core/admin/shared/contracts/admin.ts b/packages/core/admin/shared/contracts/admin.ts
index d613bc206b..e4e54f4f27 100644
--- a/packages/core/admin/shared/contracts/admin.ts
+++ b/packages/core/admin/shared/contracts/admin.ts
@@ -1,7 +1,6 @@
-import { Data } from '@strapi/types';
+import { Struct, UID } from '@strapi/types';
import { errors } from '@strapi/utils';
import type { File } from 'formidable';
-import type { GuidedTourCompletedActions } from '../../server/src/services/guided-tour';
export interface Logo {
name: string;
@@ -230,7 +229,7 @@ export declare namespace GetGuidedTourMeta {
export interface Response {
data: {
isFirstSuperAdminUser: boolean;
- completedActions: GuidedTourCompletedActions[];
+ schemas: Record;
};
error?: errors.ApplicationError;
}
diff --git a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
index 3c45bb628e..e662d53cb8 100644
--- a/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
+++ b/packages/core/content-manager/admin/src/pages/EditView/EditViewPage.tsx
@@ -205,11 +205,12 @@ const EditViewPage = () => {
-
-
-
-
-
+
+
+
+
+
+
diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
index ac3bc1f5f7..87203ae3f5 100644
--- a/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
+++ b/packages/core/content-manager/admin/src/pages/EditView/components/DocumentActions.tsx
@@ -7,6 +7,8 @@ import {
useAPIErrorHandler,
useQueryParams,
tours,
+ useGuidedTour,
+ GUIDED_TOUR_REQUIRED_ACTIONS,
} from '@strapi/admin/strapi-admin';
import {
Button,
@@ -598,6 +600,8 @@ const PublishAction: DocumentActionComponent = ({
);
const rootDocumentMeta = useRelationModal('PublishAction', (state) => state.rootDocumentMeta);
+ const dispatchGuidedTour = useGuidedTour('PublishAction', (s) => s.dispatch);
+
const { currentDocumentMeta } = useDocumentContext('PublishAction');
const [updateDocumentMutation] = useUpdateDocumentMutation();
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
@@ -759,6 +763,10 @@ const PublishAction: DocumentActionComponent = ({
// Reset form if successful
if ('data' in res) {
resetForm();
+ dispatchGuidedTour({
+ type: 'set_completed_actions',
+ payload: [GUIDED_TOUR_REQUIRED_ACTIONS.contentManager.createContent],
+ });
}
if ('data' in res && collectionType !== SINGLE_TYPES) {
@@ -926,6 +934,7 @@ const UpdateAction: DocumentActionComponent = ({
model,
collectionType,
}) => {
+ const dispatchGuidedTour = useGuidedTour('UpdateAction', (s) => s.dispatch);
const navigate = useNavigate();
const { toggleNotification } = useNotification();
const { _unstableFormatValidationErrors: formatValidationErrors } = useAPIErrorHandler();
@@ -1158,6 +1167,10 @@ const UpdateAction: DocumentActionComponent = ({
}
}
} finally {
+ dispatchGuidedTour({
+ type: 'set_completed_actions',
+ payload: [GUIDED_TOUR_REQUIRED_ACTIONS.contentManager.createContent],
+ });
setSubmitting(false);
if (onPreview) {
onPreview();
@@ -1199,6 +1212,7 @@ const UpdateAction: DocumentActionComponent = ({
schema,
components,
relationalModalSchema,
+ dispatchGuidedTour,
]);
// Auto-save on CMD+S or CMD+Enter on macOS, and CTRL+S or CTRL+Enter on Windows/Linux
diff --git a/packages/core/content-manager/admin/src/pages/ListView/ListViewPage.tsx b/packages/core/content-manager/admin/src/pages/ListView/ListViewPage.tsx
index a0b9c288d2..f87bafcdc3 100644
--- a/packages/core/content-manager/admin/src/pages/ListView/ListViewPage.tsx
+++ b/packages/core/content-manager/admin/src/pages/ListView/ListViewPage.tsx
@@ -237,7 +237,13 @@ const ListViewPage = () => {
{`${contentTypeTitle}`}
: null}
+ primaryAction={
+ canCreate ? (
+
+
+
+ ) : null
+ }
subtitle={formatMessage(
{
id: getTranslation('pages.ListView.header-subtitle'),
@@ -309,7 +315,13 @@ const ListViewPage = () => {
{`${contentTypeTitle}`}
: null}
+ primaryAction={
+ canCreate ? (
+
+
+
+ ) : null
+ }
subtitle={formatMessage(
{
id: getTranslation('pages.ListView.header-subtitle'),
diff --git a/packages/core/content-manager/admin/src/services/documents.ts b/packages/core/content-manager/admin/src/services/documents.ts
index f9d65ffa0d..c0cba3691a 100644
--- a/packages/core/content-manager/admin/src/services/documents.ts
+++ b/packages/core/content-manager/admin/src/services/documents.ts
@@ -349,7 +349,6 @@ const documentApi = contentManagerApi.injectEndpoints({
{ type: 'Document', id: `${model}_LIST` },
'Relations',
'RecentDocumentList',
- 'GuidedTourMeta',
'CountDocuments',
'UpcomingReleasesList',
];
diff --git a/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/ContentTypeBuilderNav.tsx b/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/ContentTypeBuilderNav.tsx
index fc2f3a5a45..3a7030842f 100644
--- a/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/ContentTypeBuilderNav.tsx
+++ b/packages/core/content-type-builder/admin/src/components/ContentTypeBuilderNav/ContentTypeBuilderNav.tsx
@@ -1,6 +1,6 @@
import { Fragment, useState, useEffect } from 'react';
-import { ConfirmDialog, SubNav } from '@strapi/admin/strapi-admin';
+import { ConfirmDialog, SubNav, tours, useGuidedTour } from '@strapi/admin/strapi-admin';
import {
Box,
TextInput,
@@ -101,79 +101,81 @@ export const ContentTypeBuilderNav = () => {
-
- {
- e.preventDefault();
- saveSchema();
- }}
- type="submit"
- disabled={!isModified || !isInDevelopmentMode}
- fullWidth
- size="S"
- >
- {formatMessage({
- id: 'global.save',
- defaultMessage: 'Save',
- })}
-
-
-
+
+ {
+ e.preventDefault();
+ saveSchema();
+ }}
+ type="submit"
+ disabled={!isModified || !isInDevelopmentMode}
+ fullWidth
size="S"
- endIcon={null}
- paddingTop="4px"
- paddingLeft="7px"
- paddingRight="7px"
- variant="tertiary"
>
-
-
- {formatMessage({
- id: 'global.more.actions',
- defaultMessage: 'More actions',
- })}
-
-
-
- }
+ {formatMessage({
+ id: 'global.save',
+ defaultMessage: 'Save',
+ })}
+
+
+
- {formatMessage({
- id: 'global.last-change.undo',
- defaultMessage: 'Undo last change',
- })}
-
- }
- >
- {formatMessage({
- id: 'global.last-change.redo',
- defaultMessage: 'Redo last change',
- })}
-
-
-
-
-
-
- {formatMessage({
- id: 'global.last-changes.discard',
- defaultMessage: 'Discard last changes',
- })}
-
-
-
-
-
-
+
+
+ {formatMessage({
+ id: 'global.more.actions',
+ defaultMessage: 'More actions',
+ })}
+
+
+
+ }
+ >
+ {formatMessage({
+ id: 'global.last-change.undo',
+ defaultMessage: 'Undo last change',
+ })}
+
+ }
+ >
+ {formatMessage({
+ id: 'global.last-change.redo',
+ defaultMessage: 'Redo last change',
+ })}
+
+
+
+
+
+
+ {formatMessage({
+ id: 'global.last-changes.discard',
+ defaultMessage: 'Discard last changes',
+ })}
+
+
+
+
+
+
+
}
diff --git a/packages/core/content-type-builder/admin/src/components/DataManager/DataManagerProvider.tsx b/packages/core/content-type-builder/admin/src/components/DataManager/DataManagerProvider.tsx
index 2c89bb77d2..52a05c9ede 100644
--- a/packages/core/content-type-builder/admin/src/components/DataManager/DataManagerProvider.tsx
+++ b/packages/core/content-type-builder/admin/src/components/DataManager/DataManagerProvider.tsx
@@ -8,6 +8,8 @@ import {
useFetchClient,
useAuth,
adminApi,
+ useGuidedTour,
+ GUIDED_TOUR_REQUIRED_ACTIONS,
} from '@strapi/admin/strapi-admin';
import groupBy from 'lodash/groupBy';
import isEqual from 'lodash/isEqual';
@@ -41,6 +43,7 @@ const selectState = (state: Record) =>
const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
const dispatch = useDispatch();
const state = useSelector(selectState);
+ const dispatchGuidedTour = useGuidedTour('DataManagerProvider', (s) => s.dispatch);
const {
components,
@@ -178,8 +181,6 @@ const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
// Make sure the server has restarted
await serverRestartWatcher();
- // Invalidate the guided tour meta and homepage key statistics widget query cache
- dispatch(adminApi.util.invalidateTags(['GuidedTourMeta', 'HomepageKeyStatistics']));
// refetch and update initial state after the data has been saved
await getDataRef.current();
// Update the app's permissions
@@ -196,6 +197,12 @@ const DataManagerProvider = ({ children }: DataManagerProviderProps) => {
setIsSaving(false);
unlockAppWithAutoreload();
+ dispatch(adminApi.util.invalidateTags(['GuidedTourMeta', 'HomepageKeyStatistics']));
+ dispatchGuidedTour({
+ type: 'set_completed_actions',
+ payload: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.createSchema],
+ });
+
trackUsage('didUpdateCTBSchema', { ...trackingEventProperties, success: true });
}
};
diff --git a/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx b/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx
index 8dff70410b..0fd6d5a31b 100644
--- a/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx
+++ b/packages/core/content-type-builder/admin/src/components/FormModal/FormModal.tsx
@@ -6,6 +6,8 @@ import {
useTracking,
useNotification,
ConfirmDialog,
+ useGuidedTour,
+ GUIDED_TOUR_REQUIRED_ACTIONS,
} from '@strapi/admin/strapi-admin';
import { Button, Divider, Flex, Modal, Tabs, Box, Typography, Dialog } from '@strapi/design-system';
import get from 'lodash/get';
@@ -105,6 +107,8 @@ export const FormModal = () => {
const ctbFormsAPI: any = ctbPlugin?.apis.forms;
const inputsFromPlugins = ctbFormsAPI.components.inputs;
+ const dispatchGuidedTour = useGuidedTour('FormModal', (s) => s.dispatch);
+
const {
addAttribute,
editAttribute,
@@ -1073,6 +1077,10 @@ export const FormModal = () => {
if (checkIsEditingFieldName()) {
trackUsage('didEditFieldNameOnContentType');
}
+ dispatchGuidedTour({
+ type: 'set_completed_actions',
+ payload: [GUIDED_TOUR_REQUIRED_ACTIONS.contentTypeBuilder.addField],
+ });
};
return (
diff --git a/packages/core/content-type-builder/admin/src/components/List.tsx b/packages/core/content-type-builder/admin/src/components/List.tsx
index 237e27708f..d616379a0b 100644
--- a/packages/core/content-type-builder/admin/src/components/List.tsx
+++ b/packages/core/content-type-builder/admin/src/components/List.tsx
@@ -14,7 +14,7 @@ import {
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { useTracking } from '@strapi/admin/strapi-admin';
+import { tours, useTracking } from '@strapi/admin/strapi-admin';
import { Box, Button, EmptyStateLayout } from '@strapi/design-system';
import { Plus } from '@strapi/icons';
import { EmptyDocuments } from '@strapi/icons/symbols';
@@ -158,12 +158,14 @@ export const List = ({
return (
} variant="secondary">
- {formatMessage({
- id: getTrad('table.button.no-fields'),
- defaultMessage: 'Add new field',
- })}
-
+
+ } variant="secondary">
+ {formatMessage({
+ id: getTrad('table.button.no-fields'),
+ defaultMessage: 'Add new field',
+ })}
+
+
}
content={formatMessage(
type.modelType === 'contentType'
diff --git a/packages/core/content-type-builder/admin/src/pages/ListView/ListView.tsx b/packages/core/content-type-builder/admin/src/pages/ListView/ListView.tsx
index 6e0f7a7ace..945fb03a97 100644
--- a/packages/core/content-type-builder/admin/src/pages/ListView/ListView.tsx
+++ b/packages/core/content-type-builder/admin/src/pages/ListView/ListView.tsx
@@ -127,6 +127,7 @@ const ListView = () => {
defaultMessage: 'Edit',
})}
+
}
variant="secondary"
diff --git a/tests/api/core/admin/admin-guided-tour-meta.test.api.ts b/tests/api/core/admin/admin-guided-tour-meta.test.api.ts
index 041784dd8b..dc655fe328 100644
--- a/tests/api/core/admin/admin-guided-tour-meta.test.api.ts
+++ b/tests/api/core/admin/admin-guided-tour-meta.test.api.ts
@@ -4,6 +4,7 @@ import { createTestBuilder } from 'api-tests/builder';
import { Core } from '@strapi/types';
const articleContentType = {
+ collectionName: 'article',
displayName: 'article',
singularName: 'article',
pluralName: 'articles',
@@ -19,53 +20,20 @@ let authRq;
let strapi: Core.Strapi;
const builder = createTestBuilder();
-const restartWithSchema = async () => {
- await strapi.destroy();
- await builder.cleanup();
-
- await builder.addContentType(articleContentType).build();
-
- strapi = await createStrapiInstance();
- authRq = await createAuthRequest({ strapi });
-};
-
describe('Guided Tour Meta', () => {
beforeAll(async () => {
+ await builder.addContentType(articleContentType).build();
strapi = await createStrapiInstance();
authRq = await createAuthRequest({ strapi });
});
- afterEach(async () => {
- // Ensure each test cleans up
- await restartWithSchema();
- });
-
afterAll(async () => {
await strapi.destroy();
await builder.cleanup();
});
describe('GET /admin/guided-tour-meta', () => {
- /**
- * TODO:
- * clean-after-delete.test.api.ts leaks data causing the app
- * to intialize withe a schema and content. We need to ensure that test cleans up after itself
- * Skipping for now.
- */
- test.skip('Returns correct initial state for a new installation', async () => {
- const res = await authRq({
- url: '/admin/guided-tour-meta',
- method: 'GET',
- });
-
- expect(res.status).toBe(200);
- expect(res.body.data).toMatchObject({
- isFirstSuperAdminUser: true,
- completedActions: [],
- });
- });
-
- test('Detects first super admin user', async () => {
+ test('Returns the guided tour meta', async () => {
const res = await authRq({
url: '/admin/guided-tour-meta',
method: 'GET',
@@ -73,6 +41,7 @@ describe('Guided Tour Meta', () => {
expect(res.status).toBe(200);
expect(res.body.data.isFirstSuperAdminUser).toBe(true);
+ expect(Object.keys(res.body.data.schemas)).toContain('api::article.article');
const newUser = {
email: 'second@user.com',
@@ -96,65 +65,5 @@ describe('Guided Tour Meta', () => {
expect(secondSuperAdminUserResponse.status).toBe(200);
expect(secondSuperAdminUserResponse.body.data.isFirstSuperAdminUser).toBe(false);
});
-
- test('Detects created content type schemas', async () => {
- await restartWithSchema();
-
- const res = await authRq({
- url: '/admin/guided-tour-meta',
- method: 'GET',
- });
-
- expect(res.status).toBe(200);
- expect(res.body.data.completedActions).toContain('didCreateContentTypeSchema');
- });
-
- test('Detects created content', async () => {
- await restartWithSchema();
-
- const createdDocument = await strapi.documents('api::article.article').create({
- data: {
- name: 'Article 1',
- },
- });
-
- const res = await authRq({
- url: '/admin/guided-tour-meta',
- method: 'GET',
- });
-
- expect(res.status).toBe(200);
- expect(res.body.data.completedActions).toContain('didCreateContent');
-
- // Cleanup
- await strapi.documents('api::article.article').delete({
- documentId: createdDocument.documentId,
- });
- });
-
- test('Detects created custom API tokens', async () => {
- // Create a custom API token
- const createdToken = await strapi.documents('admin::api-token').create({
- data: {
- name: 'Custom Token',
- type: 'read-only',
- description: 'Test token',
- accessKey: 'beep boop',
- },
- });
-
- const res = await authRq({
- url: '/admin/guided-tour-meta',
- method: 'GET',
- });
-
- expect(res.status).toBe(200);
- expect(res.body.data.completedActions).toContain('didCreateApiToken');
-
- // Cleanup
- await strapi.documents('admin::api-token').delete({
- documentId: createdToken.documentId,
- });
- });
});
});
diff --git a/tests/e2e/app-template/config/admin.js b/tests/e2e/app-template/config/admin.js
index af536974d7..cc8e0f5123 100644
--- a/tests/e2e/app-template/config/admin.js
+++ b/tests/e2e/app-template/config/admin.js
@@ -5,6 +5,9 @@ module.exports = ({ env }) => ({
apiToken: {
salt: env('API_TOKEN_SALT'),
},
+ secrets: {
+ encryptionKey: 'example-key',
+ },
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
diff --git a/tests/e2e/data/with-admin.tar b/tests/e2e/data/with-admin.tar
index 69bedc16d1..91672da55d 100644
Binary files a/tests/e2e/data/with-admin.tar and b/tests/e2e/data/with-admin.tar differ
diff --git a/tests/e2e/tests/admin/guided-tour.spec.ts b/tests/e2e/tests/admin/guided-tour.spec.ts
index b408d7d37d..592888052e 100644
--- a/tests/e2e/tests/admin/guided-tour.spec.ts
+++ b/tests/e2e/tests/admin/guided-tour.spec.ts
@@ -2,12 +2,12 @@ import { test, expect } from '@playwright/test';
import { sharedSetup } from '../../utils/setup';
import { STRAPI_GUIDED_TOUR_CONFIG, setGuidedTourLocalStorage } from '../../utils/global-setup';
import { clickAndWait } from '../../utils/shared';
+import { waitForRestart } from '../../utils/restart';
test.describe('Guided tour', () => {
test.beforeEach(async ({ page }) => {
await setGuidedTourLocalStorage(page, { ...STRAPI_GUIDED_TOUR_CONFIG, enabled: true });
- // Now proceed with the normal setup (login, etc.)
await sharedSetup('guided-tour', page, {
login: true,
resetFiles: true,
@@ -19,9 +19,7 @@ test.describe('Guided tour', () => {
await expect(page.getByRole('heading', { name: 'Discover your application!' })).toBeVisible();
await expect(page.getByRole('listitem', { name: 'Create your schema' })).toBeVisible();
await expect(page.getByRole('listitem', { name: 'Create and publish content' })).toBeVisible();
- await expect(
- page.getByRole('listitem', { name: 'Create and copy an API token' })
- ).toBeVisible();
+ await expect(page.getByRole('listitem', { name: 'Copy an API token' })).toBeVisible();
await expect(
page.getByRole('listitem', { name: 'Deploy your application to Strapi Cloud' })
).toBeVisible();
@@ -39,18 +37,46 @@ test.describe('Guided tour', () => {
);
const nextButton = page.getByRole('button', { name: 'Next' });
const gotItButton = page.getByRole('button', { name: 'Got it' });
- await expect(page.getByRole('dialog', { name: 'Content-Type Builder' })).toBeVisible();
+ await expect(
+ page.getByRole('dialog', { name: 'Welcome to the Content-Type Builder!' })
+ ).toBeVisible();
await nextButton.click();
await expect(page.getByRole('dialog', { name: 'Collection Types' })).toBeVisible();
await nextButton.click();
await expect(page.getByRole('dialog', { name: 'Single Types' })).toBeVisible();
await nextButton.click();
await expect(page.getByRole('dialog', { name: 'Components' })).toBeVisible();
+ await nextButton.click();
+ await expect(page.getByRole('dialog', { name: 'Your turn — Build something!' })).toBeVisible();
+ await nextButton.click();
+
+ // Create collection type
+ await page.getByRole('button', { name: 'Create new collection type' }).click();
+ await page.getByRole('textbox', { name: 'Display name' }).fill('Test');
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ await expect(
+ page.getByRole('dialog', { name: 'Add a field to bring it to life' })
+ ).toBeVisible();
await gotItButton.click();
- await expect(page.getByRole('dialog', { name: "It's time to create content!" })).toBeVisible();
+
+ // Add field to collection type
+ await page.getByRole('button', { name: 'Add new field' }).last().click();
+ await page
+ .getByRole('button', { name: 'Text Small or long text like title or description' })
+ .click();
+ await page.getByRole('textbox', { name: 'Name' }).fill('testField');
+ await page.getByRole('button', { name: 'Finish' }).click();
+
+ await expect(page.getByRole('dialog', { name: "Don't leave without saving!" })).toBeVisible();
+ await gotItButton.click();
+ await page.getByRole('button', { name: 'Save' }).click();
+ await waitForRestart(page);
+
+ await expect(page.getByRole('dialog', { name: 'First Step: Done! 🎉' })).toBeVisible();
await clickAndWait(page, page.getByRole('link', { name: 'Next' }));
- await expect(page).toHaveURL(/.*\/admin\/content-manager.*/);
+ await expect(page).toHaveURL(/.*\/admin\/content-manager\/collection-types\/api::test.test.*/);
await page.goto('/admin');
await expect(
@@ -69,14 +95,16 @@ test.describe('Guided tour', () => {
);
await expect(page.getByRole('dialog', { name: 'Content Manager' })).toBeVisible();
await nextButton.click();
+ await expect(page.getByRole('dialog', { name: 'Create new entry' })).toBeVisible();
+ await nextButton.click();
await clickAndWait(page, page.getByRole('link', { name: 'Create new entry' }));
await expect(page.getByRole('dialog', { name: 'Fields' })).toBeVisible();
await nextButton.click();
await expect(page.getByRole('dialog', { name: 'Publish' })).toBeVisible();
await gotItButton.click();
- await expect(
- page.getByRole('dialog', { name: "It's time to create API tokens!" })
- ).toBeVisible();
+ await page.getByRole('textbox', { name: 'Title' }).fill('Test');
+ await clickAndWait(page, page.getByRole('button', { name: 'Publish' }));
+ await expect(page.getByRole('dialog', { name: 'Time to setup API tokens!' })).toBeVisible();
await clickAndWait(page, page.getByRole('link', { name: 'Next' }));
await expect(page).toHaveURL(/.*\/admin\/settings\/api-tokens.*/);
@@ -92,35 +120,48 @@ test.describe('Guided tour', () => {
*/
await clickAndWait(
page,
- page.getByRole('listitem', { name: 'Create and copy an API token' }).getByRole('link', {
+ page.getByRole('listitem', { name: 'Copy an API token' }).getByRole('link', {
name: 'Start',
})
);
- await expect(page.getByRole('dialog', { name: 'API Tokens' })).toBeVisible();
+ await expect(
+ page.getByRole('dialog', { name: 'Last but not least, API tokens' })
+ ).toBeVisible();
await nextButton.click();
- await expect(page.getByRole('dialog', { name: 'Create an API token' })).toBeVisible();
- await nextButton.click();
- await clickAndWait(page, page.getByRole('link', { name: 'Create new API token' }));
+ await expect(page.getByRole('dialog', { name: 'Manage an API token' })).toBeVisible();
+ await clickAndWait(page, nextButton);
+ await clickAndWait(page, page.getByRole('link', { name: 'Edit Read Only' }));
- await page.getByRole('textbox', { name: 'Name' }).fill('Test token');
- await page.getByRole('combobox', { name: 'Token duration' }).click();
- await page.getByRole('option', { name: '7 days' }).click();
- await page.getByRole('combobox', { name: 'Token type' }).click();
- await page.getByRole('option', { name: 'Read' }).click();
- await clickAndWait(page, page.getByRole('button', { name: 'Save' }));
+ await expect(page.getByRole('dialog', { name: 'View API token' })).toBeVisible();
+ await gotItButton.click();
- await expect(page.getByRole('dialog', { name: 'Copy your new API token' })).toBeVisible();
+ /**
+ * TODO:
+ * Currently the test environment does not work with ENCRYPTION_KEY,
+ * so we have to regenerate the token instead of clicking view token directly.
+ * In a real app generated with create-strapi-app the view token button is enabled by
+ * default.
+ *
+ * Remove the regeneration clicks below and replace with
+ *
+ * await page.getByRole('button', { name: 'View token' }).click();
+ */
+ await page.getByRole('button', { name: 'Regenerate' }).click();
+ // Confirm dialog generate button
+ await page.getByRole('button', { name: 'Regenerate' }).click();
+
+ await expect(page.getByRole('dialog', { name: 'Copy your API token' })).toBeVisible();
await gotItButton.click();
await page.getByRole('button', { name: 'Copy' }).click();
await expect(
- page.getByRole('dialog', { name: "It's time to deploy your application!" })
+ page.getByRole('dialog', { name: "Congratulations, it's time to deploy your application!" })
).toBeVisible();
await clickAndWait(page, page.getByRole('link', { name: 'Next' }));
await expect(page).toHaveURL(/.*\/admin/);
await expect(
- page.getByRole('listitem', { name: 'Create and copy an API token' }).getByText('Done')
+ page.getByRole('listitem', { name: 'Copy an API token' }).getByText('Done')
).toBeVisible();
await expect(page.getByText('75%')).toBeVisible();