refactor(activation): update activation components for improved localization

- Refactored activation-related components to utilize new translation keys for better localization support.
- Updated `ActivationModal`, `ActivationLicenseStep`, `ActivationPluginsStep`, and others to replace hardcoded strings with translation keys.
- Enhanced test cases to reflect changes in localization, ensuring consistency across the activation flow.
- Added new translation keys in `en.json` for various activation steps and messages.

This update improves the internationalization of the activation process, making it easier to manage translations and enhancing the user experience for non-English speakers.
This commit is contained in:
Eli Bosley
2025-10-13 21:03:28 -04:00
parent cb06648d2b
commit 9a06af2b51
12 changed files with 196 additions and 89 deletions
@@ -150,11 +150,15 @@ const mockUpgradeOnboardingStore = {
};
// Mock all imports
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: mockT,
}),
}));
vi.mock('vue-i18n', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('vue-i18n');
return {
...(actual as Record<string, unknown>),
useI18n: () => ({
t: mockT,
}),
} as typeof import('vue-i18n');
});
vi.mock('~/components/Activation/store/activationCodeModal', () => {
const store = {
@@ -241,15 +245,15 @@ describe('Activation/ActivationModal.vue', () => {
it('uses the correct title text', () => {
mountComponent();
expect(mockT("Let's activate your Unraid OS License")).toBe("Let's activate your Unraid OS License");
expect(mockT('activation.activationModal.letSActivateYourUnraidOs')).toBe(
"Let's activate your Unraid OS License"
);
});
it('uses the correct description text', () => {
mountComponent();
const descriptionText = mockT(
`On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.`
);
const descriptionText = mockT('activation.activationModal.onTheFollowingScreenYourLicense');
expect(descriptionText).toBe(
"On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward."
@@ -258,8 +262,8 @@ describe('Activation/ActivationModal.vue', () => {
it('provides documentation links with correct URLs', () => {
mountComponent();
const licensingText = mockT('More about Licensing');
const accountsText = mockT('More about Unraid.net Accounts');
const licensingText = mockT('activation.activationModal.moreAboutLicensing');
const accountsText = mockT('activation.activationModal.moreAboutUnraidNetAccounts');
expect(licensingText).toBe('More about Licensing');
expect(accountsText).toBe('More about Unraid.net Accounts');
@@ -12,6 +12,19 @@ import type { ComposerTranslation } from 'vue-i18n';
import WelcomeModal from '~/components/Activation/WelcomeModal.standalone.vue';
import { testTranslate } from '../../utils/i18n';
type ActivationWelcomeStepStubProps = {
t?: ComposerTranslation;
partnerName?: string | null;
currentVersion?: string;
previousVersion?: string;
onComplete?: () => void;
redirectToLogin?: boolean;
onSkip?: () => void;
onBack?: () => void;
showSkip?: boolean;
showBack?: boolean;
};
vi.mock('@unraid/ui', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
@@ -49,18 +62,66 @@ const mockComponents = {
props: ['steps', 'activeStepIndex'],
},
ActivationWelcomeStep: {
template:
'<div data-testid="welcome-step"><h1>Welcome to Unraid!</h1><button @click="handleClick">Get Started</button></div>',
props: ['t', 'partnerName', 'isInitialSetup', 'onComplete', 'redirectToLogin'],
methods: {
handleClick() {
if (this.redirectToLogin) {
window.location.href = '/login';
} else {
this.onComplete();
props: [
't',
'partnerName',
'currentVersion',
'previousVersion',
'onComplete',
'redirectToLogin',
'onSkip',
'onBack',
'showSkip',
'showBack',
],
setup(props: ActivationWelcomeStepStubProps) {
const translate = props.t ?? mockT;
const buildTitle = () => {
if (props.partnerName) {
return translate('activation.welcomeModal.welcomeToYourNewSystemPowered', [props.partnerName]);
}
},
if (props.currentVersion) {
return translate('activation.welcomeModal.welcomeToUnraidVersion', [props.currentVersion]);
}
return translate('activation.welcomeModal.welcomeToUnraid');
};
const buildDescription = () => {
if (props.previousVersion && props.currentVersion) {
return translate('activation.welcomeModal.youVeUpgradedFromPrevToCurr', [
props.previousVersion,
props.currentVersion,
]);
}
if (props.currentVersion) {
return translate('activation.welcomeModal.welcomeToYourUnraidSystem', [props.currentVersion]);
}
return translate('activation.welcomeModal.getStartedWithYourNewSystem');
};
const handleClick = () => {
if (props.redirectToLogin) {
window.location.href = '/login';
return;
}
props.onComplete?.();
};
return {
title: buildTitle(),
description: buildDescription(),
buttonText: translate('activation.welcomeModal.getStarted'),
handleClick,
};
},
template: `
<div data-testid="welcome-step">
<h1>{{ title }}</h1>
<p>{{ description }}</p>
<button @click="handleClick">{{ buttonText }}</button>
</div>
`,
},
};
@@ -162,7 +223,7 @@ describe('Activation/WelcomeModal.standalone.vue', () => {
it('uses the correct description text', async () => {
const wrapper = await mountComponent();
const description = testTranslate('activation.welcomeModal.firstYouLlCreateYourDevice');
const description = testTranslate('activation.welcomeModal.getStartedWithYourNewSystem');
expect(wrapper.text()).toContain(description);
});
@@ -28,9 +28,9 @@ const { t } = useI18n();
<div class="flex flex-col">
<div class="mx-auto mb-10 flex gap-4">
<BrandButton v-if="canGoBack" :text="t('Back')" variant="outline" @click="onBack?.()" />
<BrandButton v-if="canGoBack" :text="t('common.back')" variant="outline" @click="onBack?.()" />
<BrandButton
:text="t('Activate Now')"
:text="t('activation.activationModal.activateNow')"
:icon-right="ArrowTopRightOnSquareIcon"
@click="purchaseStore.activate"
/>
@@ -79,9 +79,9 @@ const currentDynamicStepIndex = computed(() => {
const modalTitle = computed<string>(() => {
if (shouldShowUpgradeOnboarding.value && upgradeSteps.value.length > 0 && currentVersion.value) {
return t('Welcome to Unraid {version}!', { version: currentVersion.value });
return t('activation.activationModal.welcomeToUnraidVersion', { version: currentVersion.value });
}
return t("Let's activate your Unraid OS License");
return t('activation.activationModal.letSActivateYourUnraidOs');
});
const modalDescription = computed<string>(() => {
@@ -91,14 +91,12 @@ const modalDescription = computed<string>(() => {
previousVersion.value &&
currentVersion.value
) {
return t("You've upgraded from {prev} to {curr}", {
return t('activation.activationModal.youVeUpgradedFromPrevToCurr', {
prev: previousVersion.value,
curr: currentVersion.value,
});
}
return t(
`On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.`
);
return t('activation.activationModal.onTheFollowingScreenYourLicense');
});
const docsButtons = computed<BrandButtonProps[]>(() => {
@@ -109,7 +107,7 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
href: DOCS_URL_LICENSING_FAQ,
iconRight: ArrowTopRightOnSquareIcon,
size: '14px',
text: t('More about Licensing'),
text: t('activation.activationModal.moreAboutLicensing'),
},
{
variant: 'underline',
@@ -117,7 +115,7 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
href: DOCS_URL_ACCOUNT,
iconRight: ArrowTopRightOnSquareIcon,
size: '14px',
text: t('More about Unraid.net Accounts'),
text: t('activation.activationModal.moreAboutUnraidNetAccounts'),
},
];
});
@@ -82,7 +82,7 @@ const handleInstall = async () => {
props.onComplete();
} catch (err) {
error.value = props.t('Failed to install plugins. Please try again.');
error.value = t('activation.pluginsStep.installFailed');
console.error('Failed to install plugins:', err);
} finally {
isInstalling.value = false;
@@ -100,9 +100,11 @@ const handleBack = () => {
<template>
<div class="mx-auto flex w-full max-w-2xl flex-col items-center justify-center">
<h2 class="mb-4 text-xl font-semibold">{{ t('Install Essential Plugins') }}</h2>
<h2 class="mb-4 text-xl font-semibold">
{{ t('activation.pluginsStep.installEssentialPlugins') }}
</h2>
<p class="mb-8 text-center text-sm opacity-75">
{{ t('Select the plugins you want to install. You can always add more later.') }}
{{ t('activation.pluginsStep.selectPluginsDescription') }}
</p>
<div class="mb-8 flex w-full flex-col gap-4">
@@ -133,7 +135,7 @@ const handleBack = () => {
<div class="flex gap-4">
<BrandButton
v-if="onBack && showBack"
:text="t('Back')"
:text="t('common.back')"
variant="outline"
:disabled="isInstalling"
@click="handleBack"
@@ -141,13 +143,17 @@ const handleBack = () => {
<div class="flex-1" />
<BrandButton
v-if="onSkip && showSkip"
:text="t('Skip')"
:text="t('common.skip')"
variant="outline"
:disabled="isInstalling"
@click="handleSkip"
/>
<BrandButton
:text="selectedPlugins.size > 0 ? t('Install & Continue') : t('Continue')"
:text="
selectedPlugins.size > 0
? t('activation.pluginsStep.installAndContinue')
: t('common.continue')
"
:disabled="isInstalling"
:loading="isInstalling"
@click="handleInstall"
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { StepMetadataEntry } from '~/components/Activation/stepRegistry';
import type { ActivationOnboardingQuery, ActivationOnboardingStepId } from '~/composables/gql/graphql';
import { stepMetadata } from '~/components/Activation/stepRegistry';
@@ -22,26 +24,42 @@ interface StepItem {
icon?: string;
}
const { t } = useI18n();
// Ensure translation extractor retains keys used via metadata lookups
t('activation.activationSteps.activateLicense');
t('activation.activationSteps.createAnUnraidNetAccountAnd');
t('activation.pluginsStep.addHelpfulPlugins');
const translateStep = (meta: StepMetadataEntry): StepItem => ({
title: t(meta.titleKey),
description: t(meta.descriptionKey),
icon: meta.icon,
});
const dynamicSteps = computed(() => {
const metadataLookup = stepMetadata as Record<ActivationOnboardingStepId, StepItem>;
const metadataLookup: Record<ActivationOnboardingStepId, StepMetadataEntry> = stepMetadata;
if (props.steps.length === 0) {
return [
metadataLookup.WELCOME,
metadataLookup.TIMEZONE,
metadataLookup.PLUGINS,
metadataLookup.ACTIVATION,
translateStep(metadataLookup.WELCOME),
translateStep(metadataLookup.TIMEZONE),
translateStep(metadataLookup.PLUGINS),
translateStep(metadataLookup.ACTIVATION),
];
}
return props.steps.map(
(step) =>
metadataLookup[step.id] ?? {
title: step.id,
description: '',
icon: 'i-heroicons-circle-stack',
}
);
return props.steps.map((step) => {
const metadata = metadataLookup[step.id];
if (metadata) {
return translateStep(metadata);
}
return {
title: step.id,
description: '',
icon: 'i-heroicons-circle-stack',
};
});
});
const includeInitialStep = computed(() => dynamicSteps.value.length > 0);
@@ -51,8 +69,8 @@ const timelineSteps = computed<StepItem[]>(() => {
if (includeInitialStep.value) {
items.push({
title: 'Create Device Password',
description: 'Secure your device',
title: t('activation.activationSteps.createDevicePassword'),
description: t('activation.activationSteps.secureYourDevice'),
icon: 'i-heroicons-lock-closed',
});
}
@@ -60,8 +78,8 @@ const timelineSteps = computed<StepItem[]>(() => {
items.push(...dynamicSteps.value);
items.push({
title: 'Unleash Your Hardware',
description: 'Device is ready to configure',
title: t('activation.activationSteps.unleashYourHardware'),
description: t('activation.activationSteps.deviceIsReadyToConfigure'),
icon: 'i-heroicons-server-stack',
});
@@ -67,7 +67,7 @@ onMounted(() => {
const handleSubmit = async () => {
if (!selectedTimeZone.value) {
error.value = props.t('Please select a timezone');
error.value = t('activation.timezoneStep.selectTimezoneError');
return;
}
@@ -100,16 +100,16 @@ const handleBack = () => {
<template>
<div class="mx-auto flex w-full max-w-md flex-col items-center justify-center">
<h2 class="mb-4 text-xl font-semibold">{{ t('Set Your Time Zone') }}</h2>
<h2 class="mb-4 text-xl font-semibold">{{ t('activation.timezoneStep.setYourTimeZone') }}</h2>
<p class="mb-6 text-center text-sm opacity-75">
{{ t('Select your time zone to ensure accurate timestamps throughout the system.') }}
{{ t('activation.timezoneStep.selectTimezoneDescription') }}
</p>
<div class="mb-6 w-full">
<Select
v-model="selectedTimeZone"
:items="timeZoneItems"
:placeholder="t('Select a timezone')"
:placeholder="t('activation.timezoneStep.selectTimezonePlaceholder')"
class="w-full"
/>
</div>
@@ -121,7 +121,7 @@ const handleBack = () => {
<div class="flex gap-4">
<BrandButton
v-if="onBack && showBack"
:text="t('Back')"
:text="t('common.back')"
variant="outline"
:disabled="isSaving"
@click="handleBack"
@@ -129,13 +129,13 @@ const handleBack = () => {
<div class="flex-1" />
<BrandButton
v-if="onSkip && showSkip"
:text="t('Skip')"
:text="t('common.skip')"
variant="outline"
:disabled="isSaving"
@click="handleSkip"
/>
<BrandButton
:text="t('Continue')"
:text="t('common.continue')"
:disabled="!selectedTimeZone || isSaving"
:loading="isSaving"
@click="handleSubmit"
@@ -26,34 +26,37 @@ const { t } = useI18n();
const modalTitle = computed<string>(() => {
// Partner context
if (props.partnerName) {
return t('Welcome to your new {0} system, powered by Unraid!', [props.partnerName]);
return t('activation.welcomeModal.welcomeToYourNewSystemPowered', [props.partnerName]);
}
// Version context
if (props.currentVersion) {
return t('Welcome to Unraid {0}!', [props.currentVersion]);
return t('activation.welcomeModal.welcomeToUnraidVersion', [props.currentVersion]);
}
return t('Welcome to Unraid!');
return t('activation.welcomeModal.welcomeToUnraid');
});
const modalDescription = computed<string>(() => {
// Upgrade context (has both previous and current version)
if (props.previousVersion && props.currentVersion) {
return t("You've upgraded from {0} to {1}", [props.previousVersion, props.currentVersion]);
return t('activation.welcomeModal.youVeUpgradedFromPrevToCurr', [
props.previousVersion,
props.currentVersion,
]);
}
// Current version context (has current version but no previous)
if (props.currentVersion) {
return t('Welcome to your Unraid {0} system', [props.currentVersion]);
return t('activation.welcomeModal.welcomeToYourUnraidSystem', [props.currentVersion]);
}
// Default context
return t('Get started with your new Unraid system');
return t('activation.welcomeModal.getStartedWithYourNewSystem');
});
const buttonText = computed<string>(() => {
return t('Get Started');
return t('activation.welcomeModal.getStarted');
});
const handleComplete = () => {
@@ -75,9 +78,9 @@ const handleComplete = () => {
</div>
<div class="flex space-x-4">
<BrandButton v-if="showBack" :text="t('Back')" variant="outline" @click="onBack" />
<BrandButton v-if="showBack" :text="t('common.back')" variant="outline" @click="onBack" />
<BrandButton v-if="showSkip" :text="t('Skip')" variant="outline" @click="onSkip" />
<BrandButton v-if="showSkip" :text="t('common.skip')" variant="outline" @click="onSkip" />
<BrandButton :text="buttonText" @click="handleComplete" />
</div>
@@ -18,7 +18,7 @@ defineOptions({
const { t } = useI18n();
const { partnerInfo, loading, isInitialSetup } = storeToRefs(useWelcomeModalDataStore());
const { partnerInfo, isInitialSetup } = storeToRefs(useWelcomeModalDataStore());
const { setTheme } = useThemeStore();
+15 -12
View File
@@ -13,28 +13,31 @@ export const stepComponents: Record<ActivationOnboardingStepId, Component> = {
ACTIVATION: ActivationLicenseStep,
};
export const stepMetadata: Record<
ActivationOnboardingStepId,
{ title: string; description: string; icon: string }
> = {
export type StepMetadataEntry = {
titleKey: string;
descriptionKey: string;
icon: string;
};
export const stepMetadata: Record<ActivationOnboardingStepId, StepMetadataEntry> = {
WELCOME: {
title: 'Welcome to Unraid',
description: 'Get started with your new Unraid system',
titleKey: 'activation.welcomeModal.welcomeToUnraid',
descriptionKey: 'activation.welcomeModal.getStartedWithYourNewSystem',
icon: 'i-heroicons-sparkles',
},
TIMEZONE: {
title: 'Set Time Zone',
description: 'Configure system time',
titleKey: 'activation.timezoneStep.setYourTimeZone',
descriptionKey: 'activation.timezoneStep.selectTimezoneDescription',
icon: 'i-heroicons-clock',
},
PLUGINS: {
title: 'Install Essential Plugins',
description: 'Add helpful plugins',
titleKey: 'activation.pluginsStep.installEssentialPlugins',
descriptionKey: 'activation.pluginsStep.addHelpfulPlugins',
icon: 'i-heroicons-puzzle-piece',
},
ACTIVATION: {
title: 'Activate License',
description: 'Create an Unraid.net account and activate your key',
titleKey: 'activation.activationSteps.activateLicense',
descriptionKey: 'activation.activationSteps.createAnUnraidNetAccountAnd',
icon: 'i-heroicons-key',
},
};
@@ -1,8 +1,6 @@
import type { StepComponentRegistry } from '~/components/Activation/steps/types';
import ActivationTimezoneStep from '~/components/Activation/ActivationTimezoneStep.vue';
export const timezoneStep = {
id: 'timezone',
component: ActivationTimezoneStep,
} satisfies StepComponentRegistry['timezone'];
} as const;
+18 -2
View File
@@ -4,22 +4,37 @@
"activation.activationModal.moreAboutLicensing": "More about Licensing",
"activation.activationModal.moreAboutUnraidNetAccounts": "More about Unraid.net Accounts",
"activation.activationModal.onTheFollowingScreenYourLicense": "On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.",
"activation.activationModal.welcomeToUnraidVersion": "Welcome to Unraid {version}!",
"activation.activationModal.youVeUpgradedFromPrevToCurr": "You've upgraded from {prev} to {curr}",
"activation.activationSteps.activateLicense": "Activate License",
"activation.activationSteps.createAnUnraidNetAccountAnd": "Create an Unraid.net account and activate your key",
"activation.activationSteps.createDevicePassword": "Create Device Password",
"activation.activationSteps.deviceIsReadyToConfigure": "Device is ready to configure",
"activation.activationSteps.secureYourDevice": "Secure your device",
"activation.activationSteps.unleashYourHardware": "Unleash Your Hardware",
"activation.welcomeModal.createAPassword": "Create a password",
"activation.welcomeModal.firstYouLlCreateYourDevice": "First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS).",
"activation.pluginsStep.addHelpfulPlugins": "Add helpful plugins",
"activation.pluginsStep.installAndContinue": "Install & Continue",
"activation.pluginsStep.installEssentialPlugins": "Install Essential Plugins",
"activation.pluginsStep.installFailed": "Failed to install plugins. Please try again.",
"activation.pluginsStep.selectPluginsDescription": "Select the plugins you want to install. You can always add more later.",
"activation.timezoneStep.selectTimezoneDescription": "Select your time zone to ensure accurate timestamps throughout the system.",
"activation.timezoneStep.selectTimezoneError": "Please select a timezone",
"activation.timezoneStep.selectTimezonePlaceholder": "Select a timezone",
"activation.timezoneStep.setYourTimeZone": "Set Your Time Zone",
"activation.welcomeModal.getStarted": "Get Started",
"activation.welcomeModal.getStartedWithYourNewSystem": "Get started with your new Unraid system",
"activation.welcomeModal.welcomeToUnraid": "Welcome to Unraid!",
"activation.welcomeModal.welcomeToUnraidVersion": "Welcome to Unraid {0}!",
"activation.welcomeModal.welcomeToYourNewSystemPowered": "Welcome to your new {0} system, powered by Unraid!",
"activation.welcomeModal.welcomeToYourUnraidSystem": "Welcome to your Unraid {0} system",
"activation.welcomeModal.youVeUpgradedFromPrevToCurr": "You've upgraded from {0} to {1}",
"apiKey.apiKeyCreate.createApiKey": "Create API Key",
"apiKey.apiKeyCreate.editApiKey": "Edit API Key",
"auth.login.login": "Login",
"auth.login.password": "Password",
"auth.login.passwordRecovery": "Password recovery",
"auth.login.username": "Username",
"common.back": "Back",
"common.cancel": "Cancel",
"common.close": "Close",
"common.closeModal": "Close Modal",
@@ -32,6 +47,7 @@
"common.learnMore": "Learn More",
"common.retry": "Retry",
"common.loading2": "Loading…",
"common.skip": "Skip",
"common.success": "Success!",
"common.unknown": "Unknown",
"composables.dateTime.ago": "ago",