From 0fe043296dd67f404eeff05c82f6599e0731b46a Mon Sep 17 00:00:00 2001 From: markkaylor Date: Mon, 21 Jul 2025 10:51:08 +0200 Subject: [PATCH] future: add reset guided tour to profile (#23984) Co-authored-by: Simone --- .../components/UnstableGuidedTour/Context.tsx | 32 +- .../UnstableGuidedTour/Overview.tsx | 17 +- .../UnstableGuidedTour/tests/reducer.test.ts | 195 +++++++ .../admin/admin/src/pages/ProfilePage.tsx | 506 ++++++++++-------- .../core/admin/admin/src/translations/en.json | 4 + 5 files changed, 504 insertions(+), 250 deletions(-) diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx index 15ead14a76..8d99eef41f 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Context.tsx @@ -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; @@ -49,6 +51,17 @@ const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{ dispatch: React.Dispatch; }>('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(STORAGE_KEY, { tours: initialTourState, enabled, diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx b/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx index 005073b734..6a3acad238 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/Overview.tsx @@ -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 = () => { { {TASK_CONTENT.map((task) => { const tourName = task.tourName as ValidTourName; const tour = tours[tourName]; + const isLinkDisabled = + tourName !== 'contentTypeBuilder' && + !completedActions.includes('didCreateContentTypeSchema'); return ( @@ -251,14 +256,16 @@ export const UnstableGuidedTourOverview = () => { {task.isExternal ? ( handleClickOnLink(task.tourName as ValidTourName)} + onClick={() => handleClickStrapiCloud(task.tourName as ValidTourName)} > {formatMessage(task.link.label)} ) : ( } + disabled={isLinkDisabled} to={task.link.to} tag={NavLink} onClick={() => diff --git a/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts index 1601c14197..0f8a07dc65 100644 --- a/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts +++ b/packages/core/admin/admin/src/components/UnstableGuidedTour/tests/reducer.test.ts @@ -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); + }); + }); }); diff --git a/packages/core/admin/admin/src/pages/ProfilePage.tsx b/packages/core/admin/admin/src/pages/ProfilePage.tsx index 2f015405c1..8572c01884 100644 --- a/packages/core/admin/admin/src/pages/ProfilePage.tsx +++ b/packages/core/admin/admin/src/pages/ProfilePage.tsx @@ -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 ( + + + {children} + + + ); +}; + const ProfilePage = () => { const localeNames = useTypedSelector((state) => state.admin_app.language.localeNames); const { formatMessage } = useIntl(); @@ -196,7 +215,7 @@ const ProfilePage = () => { } /> - + @@ -208,6 +227,11 @@ const ProfilePage = () => { )} + + + + + ); }; @@ -220,67 +244,57 @@ const PasswordSection = () => { const { formatMessage } = useIntl(); return ( - - - - {formatMessage({ - id: 'global.change-password', - defaultMessage: 'Change password', - })} - - {[ - [ - { - 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) => ( - - {row.map(({ size, ...field }) => ( - - - - ))} - - ))} - - + + + {formatMessage({ + id: 'global.change-password', + defaultMessage: 'Change password', + })} + + {[ + [ + { + 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) => ( + + {row.map(({ size, ...field }) => ( + + + + ))} + + ))} + ); }; @@ -297,121 +311,111 @@ const PreferencesSection = ({ localeNames }: PreferencesSectionProps) => { const themesToDisplay = useTypedSelector((state) => state.admin_app.theme.availableThemes); return ( - - - - - {formatMessage({ - id: 'Settings.profile.form.section.experience.title', - defaultMessage: 'Experience', - })} - - - {formatMessage( - { - id: 'Settings.profile.form.section.experience.interfaceLanguageHelp', - defaultMessage: - 'Preference changes will apply only to you. More information is available {here}.', - }, - { - here: ( - - {formatMessage({ - id: 'Settings.profile.form.section.experience.here', - defaultMessage: 'here', - })} - - ), - } - )} - - - - {[ + + + + {formatMessage({ + id: 'Settings.profile.form.section.experience.title', + defaultMessage: 'Experience', + })} + + + {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 }) => ( - - - - ))} - + here: ( + + {formatMessage({ + id: 'Settings.profile.form.section.experience.here', + defaultMessage: 'here', + })} + + ), + } + )} + - + + {[ + { + 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 }) => ( + + + + ))} + + ); }; @@ -423,70 +427,106 @@ const UserInfoSection = () => { const { formatMessage } = useIntl(); return ( - - + + + {formatMessage({ + id: 'global.profile', + defaultMessage: 'Profile', + })} + + + {[ + { + 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 }) => ( + + + + ))} + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * 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 ( + + {formatMessage({ - id: 'global.profile', - defaultMessage: 'Profile', + id: 'tours.profile.title', + defaultMessage: 'Guided tour', + })} + + + {formatMessage({ + id: 'tours.profile.description', + defaultMessage: 'You can reset the guided tour at any time.', })} - - {[ - { - 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 }) => ( - - - - ))} - - + + ); }; diff --git a/packages/core/admin/admin/src/translations/en.json b/packages/core/admin/admin/src/translations/en.json index 5345a79d68..ec6c2427ab 100644 --- a/packages/core/admin/admin/src/translations/en.json +++ b/packages/core/admin/admin/src/translations/en.json @@ -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" }