diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index c4ae5a4518..fcbb5bb227 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -179,7 +179,9 @@ export default { label: 'Welcome Tour', icon: CursorArrowRaysIcon, tag: 'welcome-tour', - onclick: this.startWelcomeTour + onclick: this.startWelcomeTour, + // The tour walks the classic dashboard, so it has nothing to point at on the Expert home + hidden: !!this.featuresCheck.isExpertAssistantFeatureEnabled }, { label: 'Sign Out', diff --git a/frontend/src/pages/UnverifiedEmail.vue b/frontend/src/pages/UnverifiedEmail.vue index bcd60ee5e4..dccebbe25e 100644 --- a/frontend/src/pages/UnverifiedEmail.vue +++ b/frontend/src/pages/UnverifiedEmail.vue @@ -34,7 +34,6 @@ import userApi from '../api/user.js' import FFLayoutBox from '../layouts/Box.vue' import { useAccountAuthStore } from '@/stores/account-auth.js' -import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useUxToursStore } from '@/stores/ux-tours.js' import { useUxStore } from '@/stores/ux.js' @@ -52,22 +51,17 @@ export default { } }, computed: { - ...mapState(useAccountAuthStore, ['user']), - ...mapState(useAccountSettingsStore, ['featuresCheck']) + ...mapState(useAccountAuthStore, ['user']) }, methods: { ...mapActions(useUxStore, ['setNewlyCreatedUser']), - ...mapActions(useUxToursStore, ['presentTour', 'withdrawTour']), + ...mapActions(useUxToursStore, ['presentTour']), async submitVerificationToken () { try { await userApi.verifyEmailToken(this.token) clearTimeout(this.resendTimeout) - if (this.featuresCheck?.isAiOnboardingFeatureEnabled) { - // clear any tour queued under the standard flow so it cannot fire later - this.withdrawTour() - } else { - this.presentTour() - } + // No team is loaded yet for an unverified user, so the team page decides whether the tour fits + this.presentTour() this.setNewlyCreatedUser() this.$router.go() } catch (err) { diff --git a/frontend/src/pages/team/index.vue b/frontend/src/pages/team/index.vue index 90d5e59d2d..e280a57ccd 100644 --- a/frontend/src/pages/team/index.vue +++ b/frontend/src/pages/team/index.vue @@ -33,6 +33,7 @@ import { useAccountStore } from '@/stores/account.js' import { useContextStore } from '@/stores/context.js' import { useProductExpertStore } from '@/stores/product-expert.js' import { useUxToursStore } from '@/stores/ux-tours.js' +import { useUxStore } from '@/stores/ux.js' export default { name: 'TeamPage', @@ -59,6 +60,7 @@ export default { ...mapState(useAccountAuthStore, ['user', 'isAdminUser']), ...mapState(useUxToursStore, ['shouldPresentTour', 'activeTour', 'shouldShowEducationModal', 'shouldAutoShowAiConnectorModal']), ...mapState(useProductExpertStore, ['shouldWakeUpAssistant']), + ...mapState(useUxStore, ['isOnboarding']), isVisitingAdmin: function () { return (this.teamMembership.role === Roles.Admin) }, @@ -77,6 +79,9 @@ export default { !this.shouldPresentTour && !this.activeTour && !this.shouldShowEducationModal && + // Every new user starts in the onboarding stage, but it only means something on a team + // that gets AI onboarding, where the dialog waits until onboarding is complete + !(this.featuresCheck.isAiOnboardingFeatureEnabled && this.isOnboarding) && this.featuresCheck.isAiFeatureEnabled && this.featuresCheck.isMcpThirdPartyFeatureEnabled && this.shouldAutoShowAiConnectorModal @@ -126,7 +131,7 @@ export default { }, methods: { ...mapActions(useProductExpertStore, ['wakeUpAssistant']), - ...mapActions(useUxToursStore, ['setWelcomeTour', 'openModal']), + ...mapActions(useUxToursStore, ['setWelcomeTour', 'withdrawTour', 'openModal']), checkRoute: async function (route) { const allowedRoutes = [] @@ -157,6 +162,15 @@ export default { this.$nextTick(() => useAiConnectorModal().open()) }, dispatchTour () { + // The welcome tour and the getting started modal walk the classic dashboard, which the + // Expert home doesn't have, and AI onboarding covers the same ground by doing + if (this.featuresCheck.isAiOnboardingFeatureEnabled || this.featuresCheck.isExpertAssistantFeatureEnabled) { + this.withdrawTour() + if (this.shouldWakeUpAssistant) { + this.wakeUpAssistant({ shouldHydrateMessages: true }) + } + return + } return this.setWelcomeTour(() => { if (this.shouldWakeUpAssistant) { this.wakeUpAssistant({ shouldHydrateMessages: true }) diff --git a/test/unit/frontend/components/PageHeader.spec.js b/test/unit/frontend/components/PageHeader.spec.js new file mode 100644 index 0000000000..c6290a226c --- /dev/null +++ b/test/unit/frontend/components/PageHeader.spec.js @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' + +import PageHeader from '../../../../frontend/src/components/PageHeader.vue' + +// navigationOptions only reads a few fields, so it runs against a plain object +function menuTags (featuresCheck) { + const header = { user: { admin: false }, featuresCheck, $router: { push: () => {} } } + return PageHeader.computed.navigationOptions.call(header).map(option => option.tag) +} + +describe('PageHeader user menu', () => { + test('offers the welcome tour on the classic home', () => { + expect(menuTags({})).toContain('welcome-tour') + }) + + test('hides the welcome tour on the Expert home', () => { + const tags = menuTags({ isExpertAssistantFeatureEnabled: true }) + expect(tags).not.toContain('welcome-tour') + expect(tags).toContain('getting-started') + }) +}) diff --git a/test/unit/frontend/pages/UnverifiedEmail.spec.js b/test/unit/frontend/pages/UnverifiedEmail.spec.js index d604e52a76..8d19bb0d6d 100644 --- a/test/unit/frontend/pages/UnverifiedEmail.spec.js +++ b/test/unit/frontend/pages/UnverifiedEmail.spec.js @@ -4,8 +4,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' const mocks = vi.hoisted(() => { return { authStore: { user: { username: 'alice' } }, - settingsStore: { featuresCheck: {} }, - toursStore: { presentTour: vi.fn(), withdrawTour: vi.fn() }, + toursStore: { presentTour: vi.fn() }, uxStore: { setNewlyCreatedUser: vi.fn() } } }) @@ -13,9 +12,6 @@ const mocks = vi.hoisted(() => { vi.mock('@/stores/account-auth.js', () => ({ useAccountAuthStore: () => mocks.authStore })) -vi.mock('@/stores/account-settings.js', () => ({ - useAccountSettingsStore: () => mocks.settingsStore -})) vi.mock('@/stores/ux-tours.js', () => ({ useUxToursStore: () => mocks.toursStore })) @@ -49,9 +45,7 @@ function mountPage () { describe('UnverifiedEmail', () => { beforeEach(() => { - mocks.settingsStore.featuresCheck = {} mocks.toursStore.presentTour.mockClear() - mocks.toursStore.withdrawTour.mockClear() mocks.uxStore.setNewlyCreatedUser.mockClear() userApi.verifyEmailToken.mockClear() userApi.verifyEmailToken.mockResolvedValue({}) @@ -64,26 +58,14 @@ describe('UnverifiedEmail', () => { await flushPromises() } - test('queues the welcome tour on the classic path', async () => { + // No team is loaded for an unverified user, so the team page decides whether the tour fits + test('queues the welcome tour once verified', async () => { const wrapper = mountPage() await verify(wrapper) expect(mocks.toursStore.presentTour).toHaveBeenCalledTimes(1) - expect(mocks.toursStore.withdrawTour).not.toHaveBeenCalled() - }) - - // The tour and the education modal it opens on close both explain concepts - // the onboarding conversation covers by doing. A tour queued on an earlier - // visit persists in localStorage, so it gets actively withdrawn too - test('withdraws the welcome tour when AI onboarding is enabled', async () => { - mocks.settingsStore.featuresCheck = { isAiOnboardingFeatureEnabled: true } - const wrapper = mountPage() - await verify(wrapper) - expect(mocks.toursStore.presentTour).not.toHaveBeenCalled() - expect(mocks.toursStore.withdrawTour).toHaveBeenCalledTimes(1) }) - test('still marks the user as newly created either way', async () => { - mocks.settingsStore.featuresCheck = { isAiOnboardingFeatureEnabled: true } + test('marks the user as newly created', async () => { const wrapper = mountPage() await verify(wrapper) expect(mocks.uxStore.setNewlyCreatedUser).toHaveBeenCalledTimes(1) @@ -96,7 +78,6 @@ describe('UnverifiedEmail', () => { const wrapper = mountPage() await verify(wrapper) expect(mocks.toursStore.presentTour).not.toHaveBeenCalled() - expect(mocks.toursStore.withdrawTour).not.toHaveBeenCalled() expect(mocks.uxStore.setNewlyCreatedUser).not.toHaveBeenCalled() }) }) diff --git a/test/unit/frontend/pages/team/TeamPage.spec.js b/test/unit/frontend/pages/team/TeamPage.spec.js new file mode 100644 index 0000000000..c99a3246ab --- /dev/null +++ b/test/unit/frontend/pages/team/TeamPage.spec.js @@ -0,0 +1,83 @@ +import { describe, expect, test, vi } from 'vitest' + +import TeamPage from '../../../../../frontend/src/pages/team/index.vue' + +// dispatchTour only reads a few fields and calls store actions, so it runs against a plain object +function teamPage ({ featuresCheck = {}, shouldWakeUpAssistant = false } = {}) { + return { + featuresCheck, + shouldWakeUpAssistant, + setWelcomeTour: vi.fn(), + withdrawTour: vi.fn(), + wakeUpAssistant: vi.fn(), + openModal: vi.fn() + } +} + +describe('Team page welcome tour', () => { + test('starts the welcome tour on the classic home', () => { + const page = teamPage() + TeamPage.methods.dispatchTour.call(page) + expect(page.setWelcomeTour).toHaveBeenCalledTimes(1) + expect(page.withdrawTour).not.toHaveBeenCalled() + }) + + test('opens the getting started modal when the classic tour ends', () => { + const page = teamPage() + TeamPage.methods.dispatchTour.call(page) + page.setWelcomeTour.mock.calls[0][0]() + expect(page.openModal).toHaveBeenCalledWith('education') + }) + + test('withdraws the tour on the Expert home without opening the getting started modal', () => { + const page = teamPage({ featuresCheck: { isExpertAssistantFeatureEnabled: true } }) + TeamPage.methods.dispatchTour.call(page) + expect(page.setWelcomeTour).not.toHaveBeenCalled() + expect(page.withdrawTour).toHaveBeenCalledTimes(1) + expect(page.openModal).not.toHaveBeenCalled() + expect(page.wakeUpAssistant).not.toHaveBeenCalled() + }) + + test('withdraws the tour when the team gets AI onboarding', () => { + const page = teamPage({ featuresCheck: { isAiOnboardingFeatureEnabled: true } }) + TeamPage.methods.dispatchTour.call(page) + expect(page.setWelcomeTour).not.toHaveBeenCalled() + expect(page.withdrawTour).toHaveBeenCalledTimes(1) + }) + + test('still wakes the Expert when it is due and the tour is skipped', () => { + const page = teamPage({ featuresCheck: { isExpertAssistantFeatureEnabled: true }, shouldWakeUpAssistant: true }) + TeamPage.methods.dispatchTour.call(page) + expect(page.wakeUpAssistant).toHaveBeenCalledWith({ shouldHydrateMessages: true }) + expect(page.openModal).not.toHaveBeenCalled() + }) +}) + +// canAutoShowAiConnector only reads a few fields, so it runs against a plain object +function connectorReady ({ aiOnboarding = false, isOnboarding = false } = {}) { + const page = { + team: { id: 'team-1' }, + shouldPresentTour: false, + activeTour: null, + shouldShowEducationModal: false, + shouldAutoShowAiConnectorModal: true, + isOnboarding, + featuresCheck: { isAiFeatureEnabled: true, isMcpThirdPartyFeatureEnabled: true, isAiOnboardingFeatureEnabled: aiOnboarding } + } + return TeamPage.computed.canAutoShowAiConnector.call(page) +} + +describe('Team page AI connector dialog', () => { + test('waits while a team with AI onboarding is still onboarding', () => { + expect(connectorReady({ aiOnboarding: true, isOnboarding: true })).toBe(false) + }) + + test('shows once AI onboarding is complete', () => { + expect(connectorReady({ aiOnboarding: true, isOnboarding: false })).toBe(true) + }) + + // Every new user starts in the onboarding stage, which means nothing without AI onboarding + test('is not held by the onboarding stage on a team without AI onboarding', () => { + expect(connectorReady({ aiOnboarding: false, isOnboarding: true })).toBe(true) + }) +})