future: add reset guided tour to profile (#23984)

Co-authored-by: Simone <startae14@gmail.com>
This commit is contained in:
markkaylor 2025-07-21 10:51:08 +02:00 committed by GitHub
parent 68865f630a
commit 0fe043296d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 504 additions and 250 deletions

View File

@ -3,7 +3,6 @@ import * as React from 'react';
import { produce } from 'immer';
import { GetGuidedTourMeta } from '../../../../shared/contracts/admin';
import { useTracking } from '../../features/Tracking';
import { usePersistentState } from '../../hooks/usePersistentState';
import { createContext } from '../Context';
@ -35,6 +34,9 @@ type Action =
}
| {
type: 'skip_all_tours';
}
| {
type: 'reset_all_tours';
};
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
@ -49,6 +51,17 @@ const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
dispatch: React.Dispatch<Action>;
}>('UnstableGuidedTour');
const initialTourState = Object.keys(guidedTours).reduce((acc, tourName) => {
const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length;
acc[tourName as ValidTourName] = {
currentStep: 0,
length: tourLength,
isCompleted: false,
};
return acc;
}, {} as Tour);
function reducer(state: State, action: Action): State {
return produce(state, (draft) => {
if (action.type === 'next_step') {
@ -68,6 +81,12 @@ function reducer(state: State, action: Action): State {
if (action.type === 'skip_all_tours') {
draft.enabled = false;
}
if (action.type === 'reset_all_tours') {
draft.enabled = true;
draft.tours = initialTourState;
draft.completedActions = [];
}
});
}
@ -79,17 +98,6 @@ const UnstableGuidedTourContext = ({
children: React.ReactNode;
enabled?: boolean;
}) => {
const initialTourState = Object.keys(guidedTours).reduce((acc, tourName) => {
const tourLength = Object.keys(guidedTours[tourName as ValidTourName]).length;
acc[tourName as ValidTourName] = {
currentStep: 0,
length: tourLength,
isCompleted: false,
};
return acc;
}, {} as Tour);
const [tours, setTours] = usePersistentState<State>(STORAGE_KEY, {
tours: initialTourState,
enabled,

View File

@ -140,13 +140,15 @@ const WaveIcon = () => {
export const UnstableGuidedTourOverview = () => {
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const tours = unstableUseGuidedTour('Overview', (s) => s.state.tours);
const dispatch = unstableUseGuidedTour('Overview', (s) => s.dispatch);
const enabled = unstableUseGuidedTour('Overview', (s) => s.state.enabled);
const completedActions = unstableUseGuidedTour('Overview', (s) => s.state.completedActions);
const { data: guidedTourMeta } = useGetGuidedTourMetaQuery();
const tourNames = Object.keys(tours) as ValidTourName[];
const { trackUsage } = useTracking();
const tourNames = Object.keys(tours) as ValidTourName[];
const completedTours = tourNames.filter((tourName) => tours[tourName].isCompleted);
const completionPercentage =
tourNames.length > 0 ? Math.round((completedTours.length / tourNames.length) * 100) : 0;
@ -160,7 +162,7 @@ export const UnstableGuidedTourOverview = () => {
dispatch({ type: 'skip_all_tours' });
};
const handleClickOnLink = (tourName: ValidTourName) => {
const handleClickStrapiCloud = (tourName: ValidTourName) => {
trackUsage('didCompleteGuidedTour', { name: tourName });
dispatch({ type: 'skip_tour', payload: tourName });
};
@ -186,7 +188,7 @@ export const UnstableGuidedTourOverview = () => {
</Flex>
<Flex
direction="column"
alignItems="start"
alignItems="flex-start"
width="100%"
paddingTop={5}
paddingBottom={8}
@ -225,6 +227,9 @@ export const UnstableGuidedTourOverview = () => {
{TASK_CONTENT.map((task) => {
const tourName = task.tourName as ValidTourName;
const tour = tours[tourName];
const isLinkDisabled =
tourName !== 'contentTypeBuilder' &&
!completedActions.includes('didCreateContentTypeSchema');
return (
<TourTaskContainer key={tourName} alignItems="center" justifyContent="space-between">
@ -251,14 +256,16 @@ export const UnstableGuidedTourOverview = () => {
{task.isExternal ? (
<Link
isExternal
disabled={isLinkDisabled}
href={task.link.to}
onClick={() => handleClickOnLink(task.tourName as ValidTourName)}
onClick={() => handleClickStrapiCloud(task.tourName as ValidTourName)}
>
{formatMessage(task.link.label)}
</Link>
) : (
<Link
endIcon={<ChevronRight />}
disabled={isLinkDisabled}
to={task.link.to}
tag={NavLink}
onClick={() =>

View File

@ -717,4 +717,199 @@ describe('GuidedTour | reducer', () => {
expect(reducer(initialState, action)).toEqual(expectedState);
});
});
describe('reset_all_tours', () => {
it('should reset when all tours have been completed', () => {
const initialState = {
tours: {
contentTypeBuilder: {
currentStep: 5,
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,
};
const action: Action = {
type: 'reset_all_tours',
};
const expectedState = {
tours: {
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,
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
it('should reset when some tours have been completed', () => {
const initialState = {
tours: {
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,
};
const action: Action = {
type: 'reset_all_tours',
};
const expectedState = {
tours: {
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,
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
it('should reset when tour is disabled', () => {
const initialState = {
tours: {
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,
};
const action: Action = {
type: 'reset_all_tours',
};
const expectedState = {
tours: {
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,
};
expect(reducer(initialState, action)).toEqual(expectedState);
});
});
});

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { Box, Button, Flex, useNotifyAT, Grid, Typography } from '@strapi/design-system';
import { Box, Button, Flex, useNotifyAT, Grid, Typography, FlexProps } from '@strapi/design-system';
import { Check } from '@strapi/icons';
import upperFirst from 'lodash/upperFirst';
import { useIntl } from 'react-intl';
@ -10,6 +10,7 @@ import { Form, FormHelpers } from '../components/Form';
import { InputRenderer } from '../components/FormInputs/Renderer';
import { Layouts } from '../components/Layouts/Layout';
import { Page } from '../components/PageHelpers';
import { unstableUseGuidedTour } from '../components/UnstableGuidedTour/Context';
import { useTypedDispatch, useTypedSelector } from '../core/store/hooks';
import { useAuth } from '../features/Auth';
import { useNotification } from '../features/Notifications';
@ -47,6 +48,24 @@ const PROFILE_VALIDTION_SCHEMA = yup.object().shape({
* ProfilePage
* -----------------------------------------------------------------------------------------------*/
const Panel = ({ children, ...flexProps }: FlexProps) => {
return (
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4} {...flexProps}>
{children}
</Flex>
</Box>
);
};
const ProfilePage = () => {
const localeNames = useTypedSelector((state) => state.admin_app.language.localeNames);
const { formatMessage } = useIntl();
@ -196,7 +215,7 @@ const ProfilePage = () => {
</Button>
}
/>
<Box paddingBottom={10}>
<Box paddingBottom={6}>
<Layouts.Content>
<Flex direction="column" alignItems="stretch" gap={6}>
<UserInfoSection />
@ -208,6 +227,11 @@ const ProfilePage = () => {
</>
)}
</Form>
<Box paddingBottom={10}>
<Layouts.Content>
<GuidedTourSection />
</Layouts.Content>
</Box>
</Page.Main>
);
};
@ -220,67 +244,57 @@ const PasswordSection = () => {
const { formatMessage } = useIntl();
return (
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'global.change-password',
defaultMessage: 'Change password',
})}
</Typography>
{[
[
{
label: formatMessage({
id: 'Auth.form.currentPassword.label',
defaultMessage: 'Current Password',
}),
name: 'currentPassword',
size: 6,
type: 'password' as const,
},
],
[
{
autoComplete: 'new-password',
label: formatMessage({
id: 'global.password',
defaultMessage: 'Password',
}),
name: 'password',
size: 6,
type: 'password' as const,
},
{
autoComplete: 'new-password',
label: formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
}),
name: 'confirmPassword',
size: 6,
type: 'password' as const,
},
],
].map((row, index) => (
<Grid.Root key={index} gap={5}>
{row.map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
))}
</Flex>
</Box>
<Panel>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'global.change-password',
defaultMessage: 'Change password',
})}
</Typography>
{[
[
{
label: formatMessage({
id: 'Auth.form.currentPassword.label',
defaultMessage: 'Current Password',
}),
name: 'currentPassword',
size: 6,
type: 'password' as const,
},
],
[
{
autoComplete: 'new-password',
label: formatMessage({
id: 'global.password',
defaultMessage: 'Password',
}),
name: 'password',
size: 6,
type: 'password' as const,
},
{
autoComplete: 'new-password',
label: formatMessage({
id: 'Auth.form.confirmPassword.label',
defaultMessage: 'Confirm Password',
}),
name: 'confirmPassword',
size: 6,
type: 'password' as const,
},
],
].map((row, index) => (
<Grid.Root key={index} gap={5}>
{row.map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
))}
</Panel>
);
};
@ -297,121 +311,111 @@ const PreferencesSection = ({ localeNames }: PreferencesSectionProps) => {
const themesToDisplay = useTypedSelector((state) => state.admin_app.theme.availableThemes);
return (
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex direction="column" alignItems="stretch" gap={1}>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'Settings.profile.form.section.experience.title',
defaultMessage: 'Experience',
})}
</Typography>
<Typography>
{formatMessage(
{
id: 'Settings.profile.form.section.experience.interfaceLanguageHelp',
defaultMessage:
'Preference changes will apply only to you. More information is available {here}.',
},
{
here: (
<Box
tag="a"
color="primary600"
target="_blank"
rel="noopener noreferrer"
href="https://docs.strapi.io/developer-docs/latest/development/admin-customization.html#locales"
>
{formatMessage({
id: 'Settings.profile.form.section.experience.here',
defaultMessage: 'here',
})}
</Box>
),
}
)}
</Typography>
</Flex>
<Grid.Root gap={5}>
{[
<Panel>
<Flex direction="column" alignItems="stretch" gap={1}>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'Settings.profile.form.section.experience.title',
defaultMessage: 'Experience',
})}
</Typography>
<Typography>
{formatMessage(
{
hint: formatMessage({
id: 'Settings.profile.form.section.experience.interfaceLanguage.hint',
defaultMessage: 'This will only display your own interface in the chosen language.',
}),
label: formatMessage({
id: 'Settings.profile.form.section.experience.interfaceLanguage',
defaultMessage: 'Interface language',
}),
name: 'preferedLanguage',
options: Object.entries(localeNames).map(([value, label]) => ({
label,
value,
})),
placeholder: formatMessage({
id: 'global.select',
defaultMessage: 'Select',
}),
size: 6,
type: 'enumeration' as const,
id: 'Settings.profile.form.section.experience.interfaceLanguageHelp',
defaultMessage:
'Preference changes will apply only to you. More information is available {here}.',
},
{
hint: formatMessage({
id: 'Settings.profile.form.section.experience.mode.hint',
defaultMessage: 'Displays your interface in the chosen mode.',
}),
label: formatMessage({
id: 'Settings.profile.form.section.experience.mode.label',
defaultMessage: 'Interface mode',
}),
name: 'currentTheme',
options: [
{
label: formatMessage({
id: 'Settings.profile.form.section.experience.mode.option-system-label',
defaultMessage: 'Use system settings',
}),
value: 'system',
},
...themesToDisplay.map((theme) => ({
label: formatMessage(
{
id: 'Settings.profile.form.section.experience.mode.option-label',
defaultMessage: '{name} mode',
},
{
name: formatMessage({
id: theme,
defaultMessage: upperFirst(theme),
}),
}
),
value: theme,
})),
],
placeholder: formatMessage({
id: 'components.Select.placeholder',
defaultMessage: 'Select',
}),
size: 6,
type: 'enumeration' as const,
},
].map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
here: (
<Box
tag="a"
color="primary600"
target="_blank"
rel="noopener noreferrer"
href="https://docs.strapi.io/developer-docs/latest/development/admin-customization.html#locales"
>
{formatMessage({
id: 'Settings.profile.form.section.experience.here',
defaultMessage: 'here',
})}
</Box>
),
}
)}
</Typography>
</Flex>
</Box>
<Grid.Root gap={5}>
{[
{
hint: formatMessage({
id: 'Settings.profile.form.section.experience.interfaceLanguage.hint',
defaultMessage: 'This will only display your own interface in the chosen language.',
}),
label: formatMessage({
id: 'Settings.profile.form.section.experience.interfaceLanguage',
defaultMessage: 'Interface language',
}),
name: 'preferedLanguage',
options: Object.entries(localeNames).map(([value, label]) => ({
label,
value,
})),
placeholder: formatMessage({
id: 'global.select',
defaultMessage: 'Select',
}),
size: 6,
type: 'enumeration' as const,
},
{
hint: formatMessage({
id: 'Settings.profile.form.section.experience.mode.hint',
defaultMessage: 'Displays your interface in the chosen mode.',
}),
label: formatMessage({
id: 'Settings.profile.form.section.experience.mode.label',
defaultMessage: 'Interface mode',
}),
name: 'currentTheme',
options: [
{
label: formatMessage({
id: 'Settings.profile.form.section.experience.mode.option-system-label',
defaultMessage: 'Use system settings',
}),
value: 'system',
},
...themesToDisplay.map((theme) => ({
label: formatMessage(
{
id: 'Settings.profile.form.section.experience.mode.option-label',
defaultMessage: '{name} mode',
},
{
name: formatMessage({
id: theme,
defaultMessage: upperFirst(theme),
}),
}
),
value: theme,
})),
],
placeholder: formatMessage({
id: 'components.Select.placeholder',
defaultMessage: 'Select',
}),
size: 6,
type: 'enumeration' as const,
},
].map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
</Panel>
);
};
@ -423,70 +427,106 @@ const UserInfoSection = () => {
const { formatMessage } = useIntl();
return (
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Panel>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'global.profile',
defaultMessage: 'Profile',
})}
</Typography>
<Grid.Root gap={5}>
{[
{
label: formatMessage({
id: 'Auth.form.firstname.label',
defaultMessage: 'First name',
}),
name: 'firstname',
required: true,
size: 6,
type: 'string' as const,
},
{
label: formatMessage({
id: 'Auth.form.lastname.label',
defaultMessage: 'Last name',
}),
name: 'lastname',
size: 6,
type: 'string' as const,
},
{
label: formatMessage({
id: 'Auth.form.email.label',
defaultMessage: 'Email',
}),
name: 'email',
required: true,
size: 6,
type: 'email' as const,
},
{
label: formatMessage({
id: 'Auth.form.username.label',
defaultMessage: 'Username',
}),
name: 'username',
size: 6,
type: 'string' as const,
},
].map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
</Panel>
);
};
/* -------------------------------------------------------------------------------------------------
* GuidedTourSection
* -----------------------------------------------------------------------------------------------*/
const GuidedTourSection = () => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const dispatch = unstableUseGuidedTour('ProfilePage', (s) => s.dispatch);
const onClickReset = () => {
dispatch({ type: 'reset_all_tours' });
toggleNotification({
type: 'success',
message: formatMessage({
id: 'tours.profile.notification.success.reset',
defaultMessage: 'Guided tour reset',
}),
});
};
return (
<Panel alignItems="start">
<Flex direction="column" alignItems="start" gap={1}>
<Typography variant="delta" tag="h2">
{formatMessage({
id: 'global.profile',
defaultMessage: 'Profile',
id: 'tours.profile.title',
defaultMessage: 'Guided tour',
})}
</Typography>
<Typography variant="pi">
{formatMessage({
id: 'tours.profile.description',
defaultMessage: 'You can reset the guided tour at any time.',
})}
</Typography>
<Grid.Root gap={5}>
{[
{
label: formatMessage({
id: 'Auth.form.firstname.label',
defaultMessage: 'First name',
}),
name: 'firstname',
required: true,
size: 6,
type: 'string' as const,
},
{
label: formatMessage({
id: 'Auth.form.lastname.label',
defaultMessage: 'Last name',
}),
name: 'lastname',
size: 6,
type: 'string' as const,
},
{
label: formatMessage({
id: 'Auth.form.email.label',
defaultMessage: 'Email',
}),
name: 'email',
required: true,
size: 6,
type: 'email' as const,
},
{
label: formatMessage({
id: 'Auth.form.username.label',
defaultMessage: 'Username',
}),
name: 'username',
size: 6,
type: 'string' as const,
},
].map(({ size, ...field }) => (
<Grid.Item key={field.name} col={size} direction="column" alignItems="stretch">
<InputRenderer {...field} />
</Grid.Item>
))}
</Grid.Root>
</Flex>
</Box>
<Button variant="tertiary" onClick={onClickReset}>
{formatMessage({
id: 'tours.profile.reset',
defaultMessage: 'Reset guided tour',
})}
</Button>
</Panel>
);
};

View File

@ -840,5 +840,9 @@
"tours.overview.tour.link": "Start",
"tours.overview.tour.done": "Done",
"tours.overview.close.description": "Are you sure you want to close the guided tour?",
"tours.profile.title": "Guided tour",
"tours.profile.description": "You can reset the guided tour at any time.",
"tours.profile.reset": "Reset guided tour",
"tours.profile.notification.success.reset": "Guided tour reset",
"widget.profile.title": "Profile"
}