enhancement: improve guided tour (#24094)

This commit is contained in:
markkaylor
2025-08-18 09:54:58 +02:00
committed by GitHub
parent 06f5279fc9
commit e4700a5963
40 changed files with 1695 additions and 989 deletions
+43 -23
View File
@@ -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) => (
<Step.Root>
<Step.Title id="tour.welcome" defaultMessage="Welcome!" />
<Step.Content id="tour.intro" defaultMessage="Let's get started with this tour." />
<Step.Actions showStepCount={false} />
</Step.Root>
)
}
```
`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<P extends string> = {
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<PopoverContentProps & { withArrow?: boolean }>;
@@ -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 };
```
+3
View File
@@ -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'),
@@ -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> = T[keyof T];
type NonEmptyValueOf<T> = T extends Record<string, never> ? never : ValueOf<T>;
export type CompletedActions = NonEmptyValueOf<ValueOf<typeof GUIDED_TOUR_REQUIRED_ACTIONS>>[];
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<CompletedActions>;
};
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
type TourState = Record<ValidTourName, { currentStep: number; isCompleted: boolean }>;
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<State>(STORAGE_KEY, {
const [storedTours, setStoredTours] = usePersistentState<State>(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 (
<GuidedTourProviderImpl state={state} dispatch={dispatch}>
@@ -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 (
<Container tag="section" gap={0}>
{/* 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 (
<TourTaskContainer
@@ -0,0 +1,118 @@
import * as React from 'react';
import { Box, Link } from '@strapi/design-system';
import { type 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) => (
<Step.Root side="top" sideOffset={32} withArrow={false}>
<Step.Title
id="tours.apiTokens.Introduction.title"
defaultMessage="Last but not least, API tokens"
/>
<Step.Content
id="tours.apiTokens.Introduction.content"
defaultMessage="Control API access with highly customizable permissions."
/>
<Step.Actions showSkip />
</Step.Root>
);
const ManageAPIToken = ({ Step }: StepContentProps) => (
<Step.Root side="bottom" align="end">
<Step.Title id="tours.apiTokens.ManageAPIToken.title" defaultMessage="Manage an API token" />
<Step.Content
id="tours.apiTokens.ManageAPIToken.content"
defaultMessage='Click the "Pencil" icon to view and update an existing API token.'
/>
<Step.Actions />
</Step.Root>
);
const ViewAPIToken = ({ Step, dispatch }: StepContentProps) => (
<Step.Root side="bottom" align="end">
<Step.Title id="tours.apiTokens.ViewAPIToken.title" defaultMessage="View API token" />
<Step.Content
id="tours.apiTokens.ViewAPIToken.content"
defaultMessage='Click the "View token" button to see your API token.'
/>
<Step.Actions>
<StepCount tourName="apiTokens" />
<GotItAction onClick={() => dispatch({ type: 'next_step', payload: 'apiTokens' })} />
</Step.Actions>
</Step.Root>
);
const CopyAPIToken = ({ Step, dispatch }: StepContentProps) => (
<Step.Root side="bottom" align="start" sideOffset={-5}>
<Step.Title id="tours.apiTokens.CopyAPIToken.title" defaultMessage="Copy your new API token" />
<Step.Content
id="tours.apiTokens.CopyAPIToken.content"
defaultMessage="Copy your API token"
values={{
spacer: <Box paddingTop={2} />,
a: (msg: React.ReactNode) => (
<Link isExternal href="https://docs.strapi.io/cms/features/api-tokens#usage">
{msg}
</Link>
),
}}
/>
<Step.Actions>
<StepCount tourName="apiTokens" />
<GotItAction onClick={() => dispatch({ type: 'next_step', payload: 'apiTokens' })} />
</Step.Actions>
</Step.Root>
);
const Finish = ({ Step }: StepContentProps) => (
<Step.Root side="right" align="start">
<Step.Title
id="tours.apiTokens.FinalStep.title"
defaultMessage="Congratulations, it's time to deploy your application!"
/>
<Step.Content
id="tours.apiTokens.FinalStep.content"
defaultMessage="Your application is ready to be deployed and its content to be shared with the world!"
/>
<Step.Actions showPrevious={false} showStepCount={false} to="/" />
</Step.Root>
);
/* -------------------------------------------------------------------------------------------------
* 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;
@@ -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<DefaultActionsProps, 'tourName'> & {
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 (
<>
<StepCount
tourName="contentManager"
displayedCurrentStep={displayedCurrentStep}
displayedTourLength={displayedTourLength}
/>
<GotItAction onClick={handleNextStep} />
</>
);
}
return (
<>
<StepCount
tourName="contentManager"
displayedCurrentStep={displayedCurrentStep}
displayedTourLength={displayedTourLength}
/>
<DefaultActions
tourName="contentManager"
onNextStep={handleNextStep}
onPreviousStep={handlePreviousStep}
{...props}
/>
</>
);
};
/* -------------------------------------------------------------------------------------------------
* Step Components
* -----------------------------------------------------------------------------------------------*/
const Introduction = ({ Step }: StepContentProps) => {
return (
<Step.Root side="top" sideOffset={33} withArrow={false}>
<Step.Title id="tours.contentManager.Introduction.title" defaultMessage="Content manager" />
<Step.Content
id="tours.contentManager.Introduction.content"
defaultMessage="Create and manage content from your collection types and single types."
/>
<Step.Actions>
<ContentManagerActions showSkip />
</Step.Actions>
</Step.Root>
);
};
const CreateNewEntry = ({ Step }: StepContentProps) => {
return (
<Step.Root side="bottom" align="end">
<Step.Title
id="tours.contentManager.CreateNewEntry.title"
defaultMessage="Create new entry"
/>
<Step.Content
id="tours.contentManager.CreateNewEntry.content"
defaultMessage='Click the "Create new entry" button to create and publish a new entry for this collection type.'
/>
<Step.Actions>
<ContentManagerActions showPrevious />
</Step.Actions>
</Step.Root>
);
};
const Fields = ({ Step }: StepContentProps) => (
<Step.Root sideOffset={-12}>
<Step.Title id="tours.contentManager.Fields.title" defaultMessage="Fields" />
<Step.Content
id="tours.contentManager.Fields.content"
defaultMessage="First, fill in the fields you created in the Content-Type Builder."
/>
<Step.Actions>
<ContentManagerActions showPrevious />
</Step.Actions>
</Step.Root>
);
const Publish = ({ Step }: StepContentProps) => (
<Step.Root side="left" align="center">
<Step.Title id="tours.contentManager.Publish.title" defaultMessage="Publish" />
<Step.Content
id="tours.contentManager.Publish.content"
defaultMessage='Then click the "Publish" button to make your content available through the content API.'
/>
<Step.Actions>
<ContentManagerActions isActionRequired />
</Step.Actions>
</Step.Root>
);
const Finish = ({ Step }: StepContentProps) => (
<Step.Root side="right">
<Step.Title
id="tours.contentManager.FinalStep.title"
defaultMessage="Time to setup API tokens!"
/>
<Step.Content
id="tours.contentManager.FinalStep.content"
defaultMessage="Now that you've created and published an entry, let's setup an API token to manage access to your content."
/>
<Step.Actions showStepCount={false} showPrevious={false} to="/settings/api-tokens" />
</Step.Root>
);
/* -------------------------------------------------------------------------------------------------
* 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;
@@ -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) => (
<Step.Root sideOffset={33} withArrow={false}>
<Step.Title
id="tours.contentTypeBuilder.Introduction.title"
defaultMessage="Content-Type Builder"
/>
<Step.Content
id="tours.contentTypeBuilder.Introduction.content"
defaultMessage="Create and manage your content structure with collection types, single types and components."
/>
<Step.Actions showPrevious={false} />
</Step.Root>
);
const CollectionTypes = ({ Step }: StepContentProps) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title
id="tours.contentTypeBuilder.CollectionTypes.title"
defaultMessage="Collection Types"
/>
<Step.Content
id="tours.contentTypeBuilder.CollectionTypes.content"
defaultMessage="A content structure that can manage multiple entries, such as articles or products."
/>
<Step.Actions />
</Step.Root>
);
const SingleTypes = ({ Step }: StepContentProps) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title id="tours.contentTypeBuilder.SingleTypes.title" defaultMessage="Single Types" />
<Step.Content
id="tours.contentTypeBuilder.SingleTypes.content"
defaultMessage="A content structure that can manage a single entry, such as a homepage or a header."
/>
<Step.Actions />
</Step.Root>
);
const Components = ({ Step }: StepContentProps) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title id="tours.contentTypeBuilder.Components.title" defaultMessage="Components" />
<Step.Content
id="tours.contentTypeBuilder.Components.content"
defaultMessage="A reusable content structure that can be used across multiple content types, such as buttons, sliders or cards."
/>
<Step.Actions />
</Step.Root>
);
const YourTurn = ({ Step }: StepContentProps) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title id="tours.contentTypeBuilder.YourTurn.title" defaultMessage="Your turn" />
<Step.Content
id="tours.contentTypeBuilder.YourTurn.content"
defaultMessage="Create a collection type or single type and configure it."
/>
<Step.Actions />
</Step.Root>
);
const AddFields = ({ Step, dispatch }: StepContentProps) => (
<Step.Root side="bottom">
<Step.Title
id="tours.contentTypeBuilder.AddFields.title"
defaultMessage="Don't forget to add a field to your content type"
/>
<Step.Content
id="tours.contentTypeBuilder.AddFields.content"
defaultMessage="Add the fields your content needs such as text, media and relations."
/>
<Step.Actions>
<StepCount tourName="contentTypeBuilder" />
<GotItAction onClick={() => dispatch({ type: 'next_step', payload: 'contentTypeBuilder' })} />
</Step.Actions>
</Step.Root>
);
const Save = ({ Step, dispatch }: StepContentProps) => (
<Step.Root side="right">
<Step.Title id="tours.contentTypeBuilder.Save.title" defaultMessage="Save before you leave!" />
<Step.Content
id="tours.contentTypeBuilder.Save.content"
defaultMessage="Save the changes you made here before leaving this page."
/>
<Step.Actions>
<StepCount tourName="contentTypeBuilder" />
<GotItAction
onClick={() => {
// 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' });
}}
/>
</Step.Actions>
</Step.Root>
);
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 (
<Step.Root side="right">
<Step.Title
id="tours.contentTypeBuilder.Finish.title"
defaultMessage="It's time to create content!"
/>
<Step.Content
id="tours.contentTypeBuilder.Finish.content"
defaultMessage="Now that you created content types, you'll be able to create content in the content manager."
/>
<Step.Actions showStepCount={false} showPrevious={false} to={to} />
</Step.Root>
);
};
/* -------------------------------------------------------------------------------------------------
* 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;
@@ -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 (
<Typography variant="omega" fontSize="12px">
<FormattedMessage
id="tours.stepCount"
defaultMessage="Step {currentStep} of {tourLength}"
values={{ currentStep, tourLength: displayedStepCount }}
/>
</Typography>
);
};
const GotItAction = ({ onClick }: { onClick: () => void }) => {
return (
<Button onClick={onClick}>
<FormattedMessage id="tours.gotIt" defaultMessage="Got it" />
</Button>
);
};
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 (
<Flex gap={2}>
{showSkip && (
<Button variant="tertiary" onClick={handleSkip}>
<FormattedMessage id="tours.skip" defaultMessage="Skip" />
</Button>
)}
{!showSkip && showPrevious && (
<Button variant="tertiary" onClick={handlePreviousStep}>
<FormattedMessage id="tours.previous" defaultMessage="Previous" />
</Button>
)}
{to ? (
<LinkButton tag={NavLink} to={to} onClick={handleNextStep}>
<FormattedMessage id="tours.next" defaultMessage="Next" />
</LinkButton>
) : (
<Button onClick={handleNextStep}>
<FormattedMessage id="tours.next" defaultMessage="Next" />
</Button>
)}
</Flex>
);
};
/* -------------------------------------------------------------------------------------------------
* 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<typeof Popover.Content> & { withArrow?: boolean }
>;
Title: (props: StepProps) => React.ReactNode;
Content: (props: StepProps) => React.ReactNode;
Content: (
props: StepProps & {
values?: Record<string, React.ReactNode | ((chunks: React.ReactNode) => 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 (
<Typography variant="omega" fontSize="12px">
<FormattedMessage
id="tours.stepCount"
defaultMessage="Step {currentStep} of {tourLength}"
values={{ currentStep, tourLength: displayedLength }}
/>
</Typography>
);
};
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
) : (
<Typography tag="div" variant="omega">
<FormattedMessage id={props.id} defaultMessage={props.defaultMessage} />
<FormattedMessage
id={props.id}
defaultMessage={props.defaultMessage}
values={props.values}
/>
</Typography>
)}
</Box>
),
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 (
<ActionsContainer
width="100%"
@@ -177,22 +268,12 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
) : (
<>
{showStepCount && <StepCount tourName={tourName} />}
<Flex gap={2}>
{showSkip && (
<Button variant="tertiary" onClick={handleSkipAction}>
<FormattedMessage id="tours.skip" defaultMessage="Skip" />
</Button>
)}
{to ? (
<LinkButton tag={NavLink} to={to} onClick={handleNextStep}>
<FormattedMessage id="tours.next" defaultMessage="Next" />
</LinkButton>
) : (
<Button onClick={handleNextStep}>
<FormattedMessage id="tours.next" defaultMessage="Next" />
</Button>
)}
</Flex>
<DefaultActions
tourName={tourName}
showSkip={showSkip}
showPrevious={!showSkip && showPrevious}
to={to}
/>
</>
)}
</ActionsContainer>
@@ -201,4 +282,4 @@ const createStepComponents = (tourName: ValidTourName): Step => ({
});
export type { Step };
export { createStepComponents };
export { createStepComponents, GotItAction, StepCount, DefaultActions };
@@ -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 (
<Button onClick={onClick}>
<FormattedMessage id="tours.gotIt" defaultMessage="Got it" />
</Button>
);
};
const tours = {
contentTypeBuilder: createTour('contentTypeBuilder', [
{
name: 'Introduction',
content: (Step) => (
<Step.Root side="bottom" sideOffset={33} withArrow={false}>
<Step.Title
id="tours.contentTypeBuilder.Introduction.title"
defaultMessage="Content-Type Builder"
/>
<Step.Content
id="tours.contentTypeBuilder.Introduction.content"
defaultMessage="Create and manage your content structure with collection types, single types and components."
/>
<Step.Actions showSkip />
</Step.Root>
),
},
{
name: 'CollectionTypes',
content: (Step) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title
id="tours.contentTypeBuilder.CollectionTypes.title"
defaultMessage="Collection Types"
/>
<Step.Content
id="tours.contentTypeBuilder.CollectionTypes.content"
defaultMessage="A content structure that can manage multiple entries, such as articles or products."
/>
<Step.Actions />
</Step.Root>
),
},
{
name: 'SingleTypes',
content: (Step) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title
id="tours.contentTypeBuilder.SingleTypes.title"
defaultMessage="Single Types"
/>
<Step.Content
id="tours.contentTypeBuilder.SingleTypes.content"
defaultMessage="A content structure that can manage a single entry, such as a homepage or a header."
/>
<Step.Actions />
</Step.Root>
),
},
{
name: 'Components',
content: (Step, { dispatch }) => (
<Step.Root side="right" sideOffset={16}>
<Step.Title id="tours.contentTypeBuilder.Components.title" defaultMessage="Components" />
<Step.Content
id="tours.contentTypeBuilder.Components.content"
defaultMessage="A reusable content structure that can be used across multiple content types, such as buttons, sliders or cards."
/>
<Step.Actions>
<StepCount tourName="contentTypeBuilder" />
<GotItAction
onClick={() => dispatch({ type: 'next_step', payload: 'contentTypeBuilder' })}
/>
</Step.Actions>
</Step.Root>
),
},
{
name: 'Finish',
content: (Step) => (
<Step.Root side="right">
<Step.Title
id="tours.contentTypeBuilder.Finish.title"
defaultMessage="It's time to create content!"
/>
<Step.Content
id="tours.contentTypeBuilder.Finish.content"
defaultMessage="Now that you created content types, you'll be able to create content in the content manager."
/>
<Step.Actions showStepCount={false} to="/content-manager" />
</Step.Root>
),
when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'),
},
]),
contentManager: createTour('contentManager', [
{
name: 'Introduction',
when: (completedActions) => completedActions.includes('didCreateContentTypeSchema'),
content: (Step) => (
<Step.Root side="top" sideOffset={33} withArrow={false}>
<Step.Title
id="tours.contentManager.Introduction.title"
defaultMessage="Content manager"
/>
<Step.Content
id="tours.contentManager.Introduction.content"
defaultMessage="Create and manage content from your collection types and single types."
/>
<Step.Actions showSkip />
</Step.Root>
),
},
{
name: 'Fields',
content: (Step) => (
<Step.Root sideOffset={-12}>
<Step.Title id="tours.contentManager.Fields.title" defaultMessage="Fields" />
<Step.Content
id="tours.contentManager.Fields.content"
defaultMessage="Add content to the fields created in the Content-Type Builder."
/>
<Step.Actions />
</Step.Root>
),
},
{
name: 'Publish',
content: (Step, { dispatch }) => (
<Step.Root side="left" align="center">
<Step.Title id="tours.contentManager.Publish.title" defaultMessage="Publish" />
<Step.Content
id="tours.contentManager.Publish.content"
defaultMessage="Publish entries to make their content available through the Document Service API."
/>
<Step.Actions>
<StepCount tourName="contentManager" />
<GotItAction
onClick={() => dispatch({ type: 'next_step', payload: 'contentManager' })}
/>
</Step.Actions>
</Step.Root>
),
},
{
name: 'Finish',
content: (Step) => (
<Step.Root side="right">
<Step.Title
id="tours.contentManager.FinalStep.title"
defaultMessage="It's time to create API Tokens!"
/>
<Step.Content
id="tours.contentManager.FinalStep.content"
defaultMessage="Now that you've created and published content, time to create API tokens and set up permissions."
/>
<Step.Actions showStepCount={false} to="/settings/api-tokens" />
</Step.Root>
),
when: (completedActions) => completedActions.includes('didCreateContent'),
},
]),
apiTokens: createTour('apiTokens', [
{
name: 'Introduction',
content: (Step) => (
<Step.Root sideOffset={-36} withArrow={false}>
<Step.Title id="tours.apiTokens.Introduction.title" defaultMessage="API tokens" />
<Step.Content
id="tours.apiTokens.Introduction.content"
defaultMessage="Create and manage API tokens with highly customizable permissions."
/>
<Step.Actions showSkip />
</Step.Root>
),
},
{
name: 'CreateAnAPIToken',
content: (Step) => (
<Step.Root side="bottom" align="end" sideOffset={-10}>
<Step.Title
id="tours.apiTokens.CreateAnAPIToken.title"
defaultMessage="Create an API token"
/>
<Step.Content
id="tours.apiTokens.CreateAnAPIToken.content"
defaultMessage="Create a new API token. Choose a name, duration and type."
/>
<Step.Actions />
</Step.Root>
),
},
{
name: 'CopyAPIToken',
content: (Step, { dispatch }) => (
<Step.Root side="bottom" align="start" sideOffset={-5}>
<Step.Title
id="tours.apiTokens.CopyAPIToken.title"
defaultMessage="Copy your new API token"
/>
<Step.Content
id="tours.apiTokens.CopyAPIToken.content"
defaultMessage="Make sure to do it now, you wont be able to see it again. Youll need to generate a new one if you lose it."
/>
<Step.Actions>
<StepCount tourName="apiTokens" />
<GotItAction onClick={() => dispatch({ type: 'next_step', payload: 'apiTokens' })} />
</Step.Actions>
</Step.Root>
),
when: (completedActions) => completedActions.includes('didCreateApiToken'),
},
{
name: 'Finish',
content: (Step) => (
<Step.Root side="right" align="start">
<Step.Title
id="tours.apiTokens.FinalStep.title"
defaultMessage="It's time to deploy your application!"
/>
<Step.Content
id="tours.apiTokens.FinalStep.content"
defaultMessage="Your application is ready to be deployed and its content to be shared with the world!"
/>
<Step.Actions showStepCount={false} to="/" />
</Step.Root>
),
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<Action>;
}
) => React.ReactNode;
export type StepContentProps = {
Step: Step;
state: State;
dispatch: React.Dispatch<Action>;
};
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 <GuidedTourTooltipImpl {...props}>{children}</GuidedTourTooltipImpl>;
@@ -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 = ({
)}
<Popover.Root open={isPopoverOpen}>
<Popover.Anchor>{children}</Popover.Anchor>
{content(Step, { state, dispatch })}
{content({ Step, state, dispatch })}
</Popover.Root>
</>
);
@@ -357,37 +138,53 @@ const GuidedTourTooltipImpl = ({
* Tour factory
* -----------------------------------------------------------------------------------------------*/
type TourStep<P extends string> = {
export type TourStep<P extends string> = {
name: P;
content: Content;
when?: (completedActions: ExtendedCompletedActions) => boolean;
when?: (completedActions: CompletedActions) => boolean;
excludeFromStepCount?: boolean;
};
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
export function createTour<const T extends ReadonlyArray<TourStep<string>>>(
tourName: string,
steps: T
) {
type Components = {
[K in T[number]['name']]: React.ComponentType<{ children: React.ReactNode }>;
};
const tour = steps.reduce((acc, step, index) => {
if (step.name in acc) {
throw Error(`The tour: ${tourName} with step: ${step.name} has already been registered`);
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 (
<GuidedTourTooltip
tourName={tourName as ValidTourName}
step={index}
content={step.content}
when={step.when}
>
{children}
</GuidedTourTooltip>
);
};
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 (
<GuidedTourTooltip
tourName={tourName as ValidTourName}
step={index}
content={step.content}
when={step.when}
>
{children}
</GuidedTourTooltip>
);
};
return acc;
}, {} as Components);
);
return tour;
}
@@ -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,
]);
});
});
@@ -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);
@@ -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 };
@@ -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 };
@@ -131,7 +131,7 @@ const GuidedTourTooltip = ({
case 'models':
return (
<tours.contentTypeBuilder.CollectionTypes>
{children}
<tours.contentTypeBuilder.YourTurn>{children}</tours.contentTypeBuilder.YourTurn>
</tours.contentTypeBuilder.CollectionTypes>
);
case 'singleTypes':
+5
View File
@@ -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
@@ -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 = <TToken extends Token | null>({
/>
)}
{token?.id && toggleToken && (
<Tooltip
label={
!canShowToken &&
formatMessage({
id: 'Settings.tokens.encryptionKeyMissing',
defaultMessage:
'In order to view the token, you need a valid encryption key in the admin configuration',
})
}
>
<Button
type="button"
startIcon={showToken ? <EyeStriked /> : <Eye />}
variant="secondary"
onClick={() => toggleToken?.()}
disabled={!canShowToken}
<tours.apiTokens.ViewAPIToken>
<Tooltip
label={
!canShowToken &&
formatMessage({
id: 'Settings.tokens.encryptionKeyMissing',
defaultMessage:
'In order to view the token, you need a valid encryption key in the admin configuration',
})
}
>
{formatMessage({
id: 'Settings.tokens.viewToken',
defaultMessage: 'View token',
})}
</Button>
</Tooltip>
<Button
type="button"
startIcon={showToken ? <EyeStriked /> : <Eye />}
variant="secondary"
onClick={() => toggleToken?.()}
disabled={!canShowToken}
>
{formatMessage({
id: 'Settings.tokens.viewToken',
defaultMessage: 'View token',
})}
</Button>
</Tooltip>
</tours.apiTokens.ViewAPIToken>
)}
<Button
disabled={isSubmitting}
@@ -17,6 +17,7 @@ import { styled } from 'styled-components';
import { ApiToken } from '../../../../../../shared/contracts/api-token';
import { SanitizedTransferToken } from '../../../../../../shared/contracts/transfer';
import { ConfirmDialog } from '../../../../components/ConfirmDialog';
import { tours } from '../../../../components/GuidedTour/Tours';
import { RelativeTime } from '../../../../components/RelativeTime';
import { Table as TableImpl } from '../../../../components/Table';
import { useTracking } from '../../../../features/Tracking';
@@ -83,59 +84,65 @@ const Table = ({
<TableImpl.Empty />
<TableImpl.Loading />
<TableImpl.Body>
{sortedTokens.map((token) => (
<TableImpl.Row key={token.id} onClick={handleRowClick(token.id)}>
<TableImpl.Cell maxWidth="25rem">
<Typography textColor="neutral800" fontWeight="bold" ellipsis>
{token.name}
</Typography>
</TableImpl.Cell>
<TableImpl.Cell maxWidth="25rem">
<Typography textColor="neutral800" ellipsis>
{token.description}
</Typography>
</TableImpl.Cell>
<TableImpl.Cell>
<Typography textColor="neutral800">
{/* @ts-expect-error One of the tokens doesn't have createdAt */}
<RelativeTime timestamp={new Date(token.createdAt)} />
</Typography>
</TableImpl.Cell>
<TableImpl.Cell>
{token.lastUsedAt && (
<Typography textColor="neutral800">
<RelativeTime
timestamp={new Date(token.lastUsedAt)}
customIntervals={[
{
unit: 'hours',
threshold: 1,
text: formatMessage({
id: 'Settings.apiTokens.lastHour',
defaultMessage: 'last hour',
}),
},
]}
/>
{sortedTokens.map((token) => {
const GuidedTourTooltip =
token.name === 'Read Only' ? tours.apiTokens.ManageAPIToken : React.Fragment;
return (
<TableImpl.Row key={token.id} onClick={handleRowClick(token.id)}>
<TableImpl.Cell maxWidth="25rem">
<Typography textColor="neutral800" fontWeight="bold" ellipsis>
{token.name}
</Typography>
)}
</TableImpl.Cell>
{canUpdate || canRead || canDelete ? (
<TableImpl.Cell>
<Flex justifyContent="end">
{canUpdate && <UpdateButton tokenName={token.name} tokenId={token.id} />}
{canDelete && (
<DeleteButton
tokenName={token.name}
onClickDelete={() => onConfirmDelete?.(token.id)}
tokenType={tokenType}
/>
)}
</Flex>
</TableImpl.Cell>
) : null}
</TableImpl.Row>
))}
<TableImpl.Cell maxWidth="25rem">
<Typography textColor="neutral800" ellipsis>
{token.description}
</Typography>
</TableImpl.Cell>
<TableImpl.Cell>
<Typography textColor="neutral800">
{/* @ts-expect-error One of the tokens doesn't have createdAt */}
<RelativeTime timestamp={new Date(token.createdAt)} />
</Typography>
</TableImpl.Cell>
<TableImpl.Cell>
{token.lastUsedAt && (
<Typography textColor="neutral800">
<RelativeTime
timestamp={new Date(token.lastUsedAt)}
customIntervals={[
{
unit: 'hours',
threshold: 1,
text: formatMessage({
id: 'Settings.apiTokens.lastHour',
defaultMessage: 'last hour',
}),
},
]}
/>
</Typography>
)}
</TableImpl.Cell>
{canUpdate || canRead || canDelete ? (
<TableImpl.Cell>
<Flex justifyContent="end">
<GuidedTourTooltip>
{canUpdate && <UpdateButton tokenName={token.name} tokenId={token.id} />}
</GuidedTourTooltip>
{canDelete && (
<DeleteButton
tokenName={token.name}
onClickDelete={() => onConfirmDelete?.(token.id)}
tokenType={tokenType}
/>
)}
</Flex>
</TableImpl.Cell>
) : null}
</TableImpl.Row>
);
})}
</TableImpl.Body>
</TableImpl.Content>
</TableImpl.Root>
@@ -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) => {
</Typography>
<Typography>
{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',
})}
</Typography>
</Flex>
@@ -82,7 +83,10 @@ export const ApiTokenBox = ({ token, tokenType }: TokenBoxProps) => {
<Button
startIcon={<Duplicate />}
variant="secondary"
onClick={handleClick(token)}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleCopyToken(token);
}}
marginTop={6}
>
{formatMessage({ id: 'Settings.tokens.copy.copy', defaultMessage: 'Copy' })}
@@ -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<ReturnType<typeof setTimeout> | null>(null);
const { trackUsage } = useTracking();
@@ -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 && (
<tours.apiTokens.Introduction>
{/* Invisible Anchor */}
<Box />
</tours.apiTokens.Introduction>
)}
<Page.Title>
{formatMessage(
{ id: 'Settings.PageTitle', defaultMessage: 'Settings - {name}' },
@@ -144,25 +150,23 @@ export const ListView = () => {
})}
primaryAction={
canCreate && (
<tours.apiTokens.CreateAnAPIToken>
<LinkButton
tag={Link}
data-testid="create-api-token-button"
startIcon={<Plus />}
size="S"
onClick={() =>
trackUsage('willAddTokenFromList', {
tokenType: API_TOKEN_TYPE,
})
}
to="/settings/api-tokens/create"
>
{formatMessage({
id: 'Settings.apiTokens.create',
defaultMessage: 'Create new API Token',
})}
</LinkButton>
</tours.apiTokens.CreateAnAPIToken>
<LinkButton
tag={Link}
data-testid="create-api-token-button"
startIcon={<Plus />}
size="S"
onClick={() =>
trackUsage('willAddTokenFromList', {
tokenType: API_TOKEN_TYPE,
})
}
to="/settings/api-tokens/create"
>
{formatMessage({
id: 'Settings.apiTokens.create',
defaultMessage: 'Create new API Token',
})}
</LinkButton>
)
}
/>
@@ -170,51 +174,49 @@ export const ListView = () => {
<Page.NoPermissions />
) : (
<Page.Main aria-busy={isLoading}>
<tours.apiTokens.Introduction>
<Layouts.Content>
{apiTokens.length > 0 && (
<Table
permissions={{ canRead, canDelete, canUpdate }}
headers={headers}
isLoading={isLoading}
onConfirmDelete={handleDelete}
tokens={apiTokens}
tokenType={API_TOKEN_TYPE}
/>
)}
{canCreate && apiTokens.length === 0 ? (
<EmptyStateLayout
icon={<EmptyDocuments width="16rem" />}
content={formatMessage({
id: 'Settings.apiTokens.addFirstToken',
defaultMessage: 'Add your first API Token',
})}
action={
<LinkButton
tag={Link}
variant="secondary"
startIcon={<Plus />}
to="/settings/api-tokens/create"
>
{formatMessage({
id: 'Settings.apiTokens.addNewToken',
defaultMessage: 'Add new API Token',
})}
</LinkButton>
}
/>
) : null}
{!canCreate && apiTokens.length === 0 ? (
<EmptyStateLayout
icon={<EmptyDocuments width="16rem" />}
content={formatMessage({
id: 'Settings.apiTokens.emptyStateLayout',
defaultMessage: 'You dont have any content yet...',
})}
/>
) : null}
</Layouts.Content>
</tours.apiTokens.Introduction>
<Layouts.Content>
{apiTokens.length > 0 && (
<Table
permissions={{ canRead, canDelete, canUpdate }}
headers={headers}
isLoading={isLoading}
onConfirmDelete={handleDelete}
tokens={apiTokens}
tokenType={API_TOKEN_TYPE}
/>
)}
{canCreate && apiTokens.length === 0 ? (
<EmptyStateLayout
icon={<EmptyDocuments width="16rem" />}
content={formatMessage({
id: 'Settings.apiTokens.addFirstToken',
defaultMessage: 'Add your first API Token',
})}
action={
<LinkButton
tag={Link}
variant="secondary"
startIcon={<Plus />}
to="/settings/api-tokens/create"
>
{formatMessage({
id: 'Settings.apiTokens.addNewToken',
defaultMessage: 'Add new API Token',
})}
</LinkButton>
}
/>
) : null}
{!canCreate && apiTokens.length === 0 ? (
<EmptyStateLayout
icon={<EmptyDocuments width="16rem" />}
content={formatMessage({
id: 'Settings.apiTokens.emptyStateLayout',
defaultMessage: 'You dont have any content yet...',
})}
/>
) : null}
</Layouts.Content>
</Page.Main>
)}
</>
@@ -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'],
@@ -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 dont 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 <a>API tokens</a>.",
"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",
@@ -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;
},
@@ -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<GuidedTourCompletedActions>;
const completedActions = requiredActionNames.filter((key) => requiredActions[key]);
@@ -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,
};
-1
View File
@@ -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 = {
@@ -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<UID.ContentType, Struct.ContentTypeSchema>;
};
error?: errors.ApplicationError;
}
@@ -205,11 +205,12 @@ const EditViewPage = () => {
</Tabs.List>
<Grid.Root paddingTop={8} gap={4}>
<Grid.Item col={9} s={12} direction="column" alignItems="stretch">
<tours.contentManager.Fields>
<Tabs.Content value="draft">
<FormLayout layout={layout} document={doc} />
</Tabs.Content>
</tours.contentManager.Fields>
<Tabs.Content value="draft">
<tours.contentManager.Fields>
<Box />
</tours.contentManager.Fields>
<FormLayout layout={layout} document={doc} />
</Tabs.Content>
<Tabs.Content value="published">
<FormLayout layout={layout} document={doc} />
</Tabs.Content>
@@ -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
@@ -237,7 +237,13 @@ const ListViewPage = () => {
<Page.Main>
<Page.Title>{`${contentTypeTitle}`}</Page.Title>
<LayoutsHeaderCustom
primaryAction={canCreate ? <CreateButton /> : null}
primaryAction={
canCreate ? (
<tours.contentManager.CreateNewEntry>
<CreateButton />
</tours.contentManager.CreateNewEntry>
) : null
}
subtitle={formatMessage(
{
id: getTranslation('pages.ListView.header-subtitle'),
@@ -309,7 +315,13 @@ const ListViewPage = () => {
<Page.Main>
<Page.Title>{`${contentTypeTitle}`}</Page.Title>
<LayoutsHeaderCustom
primaryAction={canCreate ? <CreateButton /> : null}
primaryAction={
canCreate ? (
<tours.contentManager.CreateNewEntry>
<CreateButton />
</tours.contentManager.CreateNewEntry>
) : null
}
subtitle={formatMessage(
{
id: getTranslation('pages.ListView.header-subtitle'),
@@ -349,7 +349,6 @@ const documentApi = contentManagerApi.injectEndpoints({
{ type: 'Document', id: `${model}_LIST` },
'Relations',
'RecentDocumentList',
'GuidedTourMeta',
'CountDocuments',
'UpcomingReleasesList',
];
@@ -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 = () => {
<SubNav.Header label={pluginName} />
<Divider background="neutral150" />
<Flex padding={5} gap={3} direction={'column'} alignItems={'stretch'}>
<Flex gap={2}>
<Button
flex={1}
onClick={(e) => {
e.preventDefault();
saveSchema();
}}
type="submit"
disabled={!isModified || !isInDevelopmentMode}
fullWidth
size="S"
>
{formatMessage({
id: 'global.save',
defaultMessage: 'Save',
})}
</Button>
<Menu.Root open={menuIsOpen} onOpenChange={setMenuIsOpen}>
<Menu.Trigger
<tours.contentTypeBuilder.Save>
<Flex gap={2}>
<Button
flex={1}
onClick={(e) => {
e.preventDefault();
saveSchema();
}}
type="submit"
disabled={!isModified || !isInDevelopmentMode}
fullWidth
size="S"
endIcon={null}
paddingTop="4px"
paddingLeft="7px"
paddingRight="7px"
variant="tertiary"
>
<More fill="neutral500" aria-hidden focusable={false} />
<VisuallyHidden tag="span">
{formatMessage({
id: 'global.more.actions',
defaultMessage: 'More actions',
})}
</VisuallyHidden>
</Menu.Trigger>
<Menu.Content zIndex={1}>
<Menu.Item
disabled={!history.canUndo || !isInDevelopmentMode}
onSelect={undoHandler}
startIcon={<ArrowCounterClockwise />}
{formatMessage({
id: 'global.save',
defaultMessage: 'Save',
})}
</Button>
<Menu.Root open={menuIsOpen} onOpenChange={setMenuIsOpen}>
<Menu.Trigger
size="S"
endIcon={null}
paddingTop="4px"
paddingLeft="7px"
paddingRight="7px"
variant="tertiary"
>
{formatMessage({
id: 'global.last-change.undo',
defaultMessage: 'Undo last change',
})}
</Menu.Item>
<Menu.Item
disabled={!history.canRedo || !isInDevelopmentMode}
onSelect={redoHandler}
startIcon={<ArrowClockwise />}
>
{formatMessage({
id: 'global.last-change.redo',
defaultMessage: 'Redo last change',
})}
</Menu.Item>
<Menu.Separator />
<DiscardAllMenuItem
disabled={!history.canDiscardAll || !isInDevelopmentMode}
onSelect={discardHandler}
>
<Flex gap={2}>
<Cross />
<Typography>
{formatMessage({
id: 'global.last-changes.discard',
defaultMessage: 'Discard last changes',
})}
</Typography>
</Flex>
</DiscardAllMenuItem>
</Menu.Content>
</Menu.Root>
</Flex>
<More fill="neutral500" aria-hidden focusable={false} />
<VisuallyHidden tag="span">
{formatMessage({
id: 'global.more.actions',
defaultMessage: 'More actions',
})}
</VisuallyHidden>
</Menu.Trigger>
<Menu.Content zIndex={1}>
<Menu.Item
disabled={!history.canUndo || !isInDevelopmentMode}
onSelect={undoHandler}
startIcon={<ArrowCounterClockwise />}
>
{formatMessage({
id: 'global.last-change.undo',
defaultMessage: 'Undo last change',
})}
</Menu.Item>
<Menu.Item
disabled={!history.canRedo || !isInDevelopmentMode}
onSelect={redoHandler}
startIcon={<ArrowClockwise />}
>
{formatMessage({
id: 'global.last-change.redo',
defaultMessage: 'Redo last change',
})}
</Menu.Item>
<Menu.Separator />
<DiscardAllMenuItem
disabled={!history.canDiscardAll || !isInDevelopmentMode}
onSelect={discardHandler}
>
<Flex gap={2}>
<Cross />
<Typography>
{formatMessage({
id: 'global.last-changes.discard',
defaultMessage: 'Discard last changes',
})}
</Typography>
</Flex>
</DiscardAllMenuItem>
</Menu.Content>
</Menu.Root>
</Flex>
</tours.contentTypeBuilder.Save>
<TextInput
startAction={<Search fill="neutral500" />}
@@ -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<string, unknown>) =>
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 });
}
};
@@ -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 (
@@ -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 (
<EmptyStateLayout
action={
<Button onClick={onClickAddField} size="L" startIcon={<Plus />} variant="secondary">
{formatMessage({
id: getTrad('table.button.no-fields'),
defaultMessage: 'Add new field',
})}
</Button>
<tours.contentTypeBuilder.AddFields>
<Button onClick={onClickAddField} size="L" startIcon={<Plus />} variant="secondary">
{formatMessage({
id: getTrad('table.button.no-fields'),
defaultMessage: 'Add new field',
})}
</Button>
</tours.contentTypeBuilder.AddFields>
}
content={formatMessage(
type.modelType === 'contentType'
@@ -127,6 +127,7 @@ const ListView = () => {
defaultMessage: 'Edit',
})}
</Button>
<Button
startIcon={<Plus />}
variant="secondary"
@@ -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,
});
});
});
});
+3
View File
@@ -5,6 +5,9 @@ module.exports = ({ env }) => ({
apiToken: {
salt: env('API_TOKEN_SALT'),
},
secrets: {
encryptionKey: 'example-key',
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
Binary file not shown.
+65 -24
View File
@@ -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();