From 3f4ac7a92621915d2f5cd4be84c37b73fa937ebf Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Oct 2025 16:02:56 -0400 Subject: [PATCH] feat(activation): enhance upgrade step handling in ActivationModal - Updated the `ActivationModal` component to include a new method for completing pending upgrade steps, improving the onboarding experience. - Modified the `closeModal` function to ensure pending steps are marked as complete before closing the modal. - Enhanced tests to verify the correct behavior of upgrade step completion and modal interactions. - Updated documentation to reflect changes in upgrade onboarding behavior. This update improves the user experience by ensuring that all pending upgrade steps are properly handled during the activation process. --- .../Activation/ActivationModal.test.ts | 57 +++++++++++++++++-- .../components/Activation/ActivationModal.vue | 50 +++++++++++++--- .../Activation/UPGRADE_ONBOARDING.md | 4 +- .../Activation/store/upgradeOnboarding.ts | 13 +---- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/web/__test__/components/Activation/ActivationModal.test.ts b/web/__test__/components/Activation/ActivationModal.test.ts index cba60701f..3928b3482 100644 --- a/web/__test__/components/Activation/ActivationModal.test.ts +++ b/web/__test__/components/Activation/ActivationModal.test.ts @@ -3,7 +3,7 @@ */ import { ref } from 'vue'; -import { mount } from '@vue/test-utils'; +import { flushPromises, mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -111,7 +111,7 @@ const mockPurchaseStore = { const mockStepDefinitions = [ { - id: 'timezone', + id: 'TIMEZONE', required: true, completed: false, introducedIn: '7.0.0', @@ -120,7 +120,7 @@ const mockStepDefinitions = [ icon: 'i-heroicons-clock', }, { - id: 'plugins', + id: 'PLUGINS', required: false, completed: false, introducedIn: '7.0.0', @@ -129,7 +129,7 @@ const mockStepDefinitions = [ icon: 'i-heroicons-puzzle-piece', }, { - id: 'activation', + id: 'ACTIVATION', required: true, completed: false, introducedIn: '7.0.0', @@ -145,10 +145,17 @@ const mockUpgradeOnboardingStore = { allUpgradeSteps: ref(mockStepDefinitions), currentVersion: ref('7.0.0'), previousVersion: ref('6.12.0'), - setIsHidden: vi.fn(), - refetchActivationOnboarding: vi.fn(), + refetchActivationOnboarding: vi.fn().mockResolvedValue(undefined), }; +const mutateMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('@vue/apollo-composable', () => ({ + useMutation: () => ({ + mutate: mutateMock, + }), +})); + // Mock all imports vi.mock('vue-i18n', async (importOriginal) => { const actual = (await importOriginal()) as typeof import('vue-i18n'); @@ -216,6 +223,8 @@ window.addEventListener = vi.fn((event: string, handler: EventListenerOrEventLis describe('Activation/ActivationModal.vue', () => { beforeEach(() => { vi.clearAllMocks(); + mutateMock.mockClear(); + mockUpgradeOnboardingStore.refetchActivationOnboarding.mockClear(); mockActivationCodeDataStore.partnerInfo.value = { hasPartnerLogo: false, @@ -231,6 +240,11 @@ describe('Activation/ActivationModal.vue', () => { }); handleKeydown = null; + mockUpgradeOnboardingStore.shouldShowUpgradeOnboarding.value = false; + mockUpgradeOnboardingStore.upgradeSteps.value = mockStepDefinitions.map((step) => ({ ...step })); + mockUpgradeOnboardingStore.allUpgradeSteps.value = mockStepDefinitions.map((step) => ({ + ...step, + })); }); const mountComponent = () => { @@ -340,6 +354,37 @@ describe('Activation/ActivationModal.vue', () => { expect(wrapper.find('[role="dialog"]').exists()).toBe(false); }); + it('marks pending upgrade steps complete when the modal is closed', async () => { + mockUpgradeOnboardingStore.shouldShowUpgradeOnboarding.value = true; + mockUpgradeOnboardingStore.upgradeSteps.value = [ + { + id: 'TIMEZONE', + required: true, + completed: false, + introducedIn: '7.0.0', + }, + { + id: 'PLUGINS', + required: false, + completed: false, + introducedIn: '7.0.0', + }, + ]; + mockUpgradeOnboardingStore.allUpgradeSteps.value = mockUpgradeOnboardingStore.upgradeSteps.value; + + const wrapper = mountComponent(); + const dialog = wrapper.findComponent({ name: 'Dialog' }); + expect(dialog.exists()).toBe(true); + + dialog.vm.$emit('update:modelValue', false); + await flushPromises(); + + expect(mutateMock).toHaveBeenCalledTimes(2); + expect(mutateMock).toHaveBeenNthCalledWith(1, { input: { stepId: 'TIMEZONE' } }); + expect(mutateMock).toHaveBeenNthCalledWith(2, { input: { stepId: 'PLUGINS' } }); + expect(mockUpgradeOnboardingStore.refetchActivationOnboarding).toHaveBeenCalledTimes(1); + }); + it('renders activation steps with correct active step', () => { const wrapper = mountComponent(); diff --git a/web/src/components/Activation/ActivationModal.vue b/web/src/components/Activation/ActivationModal.vue index 9ce5d36f9..b467742ff 100644 --- a/web/src/components/Activation/ActivationModal.vue +++ b/web/src/components/Activation/ActivationModal.vue @@ -120,24 +120,52 @@ const docsButtons = computed(() => { ]; }); -const closeModal = () => { - upgradeStore.setIsHidden(true); - modalStore.setIsHidden(true); +type MarkUpgradeStepOptions = { + skipRefetch?: boolean; }; const { mutate: completeUpgradeStepMutation } = useMutation(COMPLETE_UPGRADE_STEP_MUTATION); -const markUpgradeStepCompleted = async (stepId: StepId | null) => { +const markUpgradeStepCompleted = async (stepId: StepId | null, options: MarkUpgradeStepOptions = {}) => { if (!stepId) return; try { await completeUpgradeStepMutation({ input: { stepId } }); - await refetchActivationOnboarding(); + if (!options.skipRefetch) { + await refetchActivationOnboarding(); + } } catch (error) { console.error('[ActivationModal] Failed to mark upgrade step completed', error); } }; +const completePendingUpgradeSteps = async () => { + if (!shouldShowUpgradeOnboarding.value) { + return; + } + + const pendingSteps = upgradeSteps.value.map((step) => step.id as StepId); + if (pendingSteps.length === 0) { + return; + } + + try { + for (const stepId of pendingSteps) { + await markUpgradeStepCompleted(stepId, { skipRefetch: true }); + } + await refetchActivationOnboarding(); + } catch (error) { + console.error('[ActivationModal] Failed to complete pending upgrade steps', error); + } +}; + +const closeModal = async () => { + if (shouldShowUpgradeOnboarding.value) { + await completePendingUpgradeSteps(); + } + modalStore.setIsHidden(true); +}; + const goToNextStep = async () => { if (availableSteps.value.length > 0) { // Only mark as completed if the current step is not already completed @@ -151,12 +179,12 @@ const goToNextStep = async () => { currentStepIndex.value++; } else { // If we're at the last step, close the modal - closeModal(); + await closeModal(); } return; } - closeModal(); + await closeModal(); }; const goToPreviousStep = () => { @@ -286,7 +314,13 @@ watch( :show-close-button="isHidden === false || shouldShowUpgradeOnboarding" size="full" class="bg-background" - @update:model-value="(value) => !value && closeModal()" + @update:model-value=" + async (value) => { + if (!value) { + await closeModal(); + } + } + " >
diff --git a/web/src/components/Activation/UPGRADE_ONBOARDING.md b/web/src/components/Activation/UPGRADE_ONBOARDING.md index ea5d4ccd4..de4906f32 100644 --- a/web/src/components/Activation/UPGRADE_ONBOARDING.md +++ b/web/src/components/Activation/UPGRADE_ONBOARDING.md @@ -52,7 +52,7 @@ This system shows contextual onboarding steps to users when they upgrade their U - Automatically detects which mode based on system state - Displays relevant steps for each mode - Reuses existing step components (timezone, plugins) - - Persists "hidden" state per mode to session storage + - Relies on recorded completion status from the onboarding tracker ## Adding New Steps @@ -131,6 +131,6 @@ To test the upgrade flow: - Fresh installs (no `lastTrackedVersion`) won't trigger upgrade onboarding until steps exist - The modal automatically switches between fresh install and upgrade modes -- Each mode can be dismissed independently (stored in sessionStorage) +- Dismissal is tracked through the onboarding mutations so all browsers stay in sync - Version comparison uses semver for reliable ordering - The same modal component handles both modes for consistency diff --git a/web/src/components/Activation/store/upgradeOnboarding.ts b/web/src/components/Activation/store/upgradeOnboarding.ts index 21fc49564..b90990f6d 100644 --- a/web/src/components/Activation/store/upgradeOnboarding.ts +++ b/web/src/components/Activation/store/upgradeOnboarding.ts @@ -1,14 +1,11 @@ import { computed } from 'vue'; import { defineStore } from 'pinia'; import { useQuery } from '@vue/apollo-composable'; -import { useSessionStorage } from '@vueuse/core'; import type { ActivationOnboardingQuery } from '~/composables/gql/graphql'; import { ACTIVATION_ONBOARDING_QUERY } from '~/components/Activation/activationOnboarding.query'; -const UPGRADE_ONBOARDING_HIDDEN_KEY = 'upgrade-onboarding-hidden'; - export const useUpgradeOnboardingStore = defineStore('upgradeOnboarding', () => { const { result: activationOnboardingResult, @@ -16,8 +13,6 @@ export const useUpgradeOnboardingStore = defineStore('upgradeOnboarding', () => refetch, } = useQuery(ACTIVATION_ONBOARDING_QUERY, {}, { errorPolicy: 'all' }); - const isHidden = useSessionStorage(UPGRADE_ONBOARDING_HIDDEN_KEY, false); - const onboardingData = computed( () => activationOnboardingResult.value?.activationOnboarding ); @@ -38,13 +33,9 @@ export const useUpgradeOnboardingStore = defineStore('upgradeOnboarding', () => ); const shouldShowUpgradeOnboarding = computed(() => { - return !isHidden.value && (onboardingData.value?.hasPendingSteps ?? false); + return onboardingData.value?.hasPendingSteps ?? false; }); - const setIsHidden = (value: boolean) => { - isHidden.value = value; - }; - return { loading: computed(() => activationOnboardingLoading.value), isUpgrade, @@ -54,8 +45,6 @@ export const useUpgradeOnboardingStore = defineStore('upgradeOnboarding', () => allUpgradeSteps, upgradeSteps, shouldShowUpgradeOnboarding, - isHidden, - setIsHidden, refetchActivationOnboarding: refetch, }; });