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.
This commit is contained in:
Eli Bosley
2025-10-15 16:02:56 -04:00
parent f612aaa710
commit 3f4ac7a926
4 changed files with 96 additions and 28 deletions

View File

@@ -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();

View File

@@ -120,24 +120,52 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
];
});
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();
}
}
"
>
<div class="flex flex-col items-center justify-start">
<div v-if="partnerInfo?.hasPartnerLogo && !shouldShowUpgradeOnboarding">

View File

@@ -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

View File

@@ -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<boolean>(UPGRADE_ONBOARDING_HIDDEN_KEY, false);
const onboardingData = computed<ActivationOnboardingQuery['activationOnboarding'] | undefined>(
() => 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,
};
});