Merge pull request 'Feat(i18n): make language a setting' (#185) from feature/make-language-a-setting into main
Deploy on dev / Deploy on dev (push) Successful in 39s

Reviewed-on: #185
Reviewed-by: Jakob Gregory
This commit was merged in pull request #185.
This commit is contained in:
2026-08-07 11:44:32 +02:00
committed by Gitea
17 changed files with 211 additions and 161 deletions
+7 -1
View File
@@ -18,9 +18,11 @@ limitations under the License.
import Navbar from './features/nav/components/Navbar.vue'; import Navbar from './features/nav/components/Navbar.vue';
import Footer from './features/footer/components/Footer.vue'; import Footer from './features/footer/components/Footer.vue';
import { LANGUAGE_PATH } from './config/featureSettings.js';
import { updatePageTitle } from './router'; import { updatePageTitle } from './router';
import { useSettings } from './features/settings/composables/useSettings.js'; import { useSettings } from './features/settings/composables/useSettings.js';
import { computed, watch, watchEffect } from 'vue'; import { setLocale } from './i18n';
import { computed, onMounted, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
@@ -51,6 +53,10 @@ watch(colorScheme, (newColorScheme) => {
updateColorScheme(colorScheme.value); updateColorScheme(colorScheme.value);
watchEffect(() => updatePageTitle(route)); watchEffect(() => updatePageTitle(route));
onMounted(() => {
setLocale(getSetting(LANGUAGE_PATH));
});
</script> </script>
<template> <template>
+17
View File
@@ -0,0 +1,17 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const LANGUAGE_PATH = ['general', 'language'];
@@ -17,23 +17,25 @@ limitations under the License.
<script setup> <script setup>
import Icon from '@/features/icons/components/Icon.vue'; import Icon from '@/features/icons/components/Icon.vue';
import { ref } from 'vue'; import { LANGUAGE_PATH } from '@/config/featureSettings';
import { useSettings } from '@/features/settings/composables/useSettings';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { loadLanguage, LANGUAGES_RTL, SUPPORTED_LANGUAGES } from '@/i18n'; import { setLocale, SUPPORTED_LANGUAGES } from '@/i18n';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setSetting, getSetting } = useSettings();
const isOpen = ref(false); const isOpen = ref(false);
const languageDropdown = ref(null); const languageDropdown = ref(null);
async function selectLanguage(code) { const selectLanguage = function selectLanguage (code) {
await loadLanguage(code); setSetting(LANGUAGE_PATH, code);
localStorage.setItem('locale', code);
document.documentElement.lang = code;
document.documentElement.dir = LANGUAGES_RTL.includes(code) ? 'rtl' : 'ltr';
close(); close();
}; };
watch(() => getSetting(LANGUAGE_PATH), setLocale);
const close = function () { const close = function () {
document.removeEventListener('click', closeWrapperOnClickOutsite); document.removeEventListener('click', closeWrapperOnClickOutsite);
isOpen.value = false; isOpen.value = false;
@@ -14,41 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { expect, test, describe, vi, beforeEach } from 'vitest'; import { LANGUAGE_PATH } from '@/config/featureSettings.js';
import { mount } from '@vue/test-utils'; import { useSettings } from '@/features/settings/composables/useSettings.js';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { expect, test, describe } from 'vitest';
import LanguageSwitchButton from '../LanguageSwitchButton.vue'; import LanguageSwitchButton from '../LanguageSwitchButton.vue';
import { loadLanguage } from '@/i18n';
vi.mock('@/i18n', () => ({ const getWrapper = function getWrapper () {
loadLanguage: vi.fn(() => Promise.resolve()), return mountComponent(LanguageSwitchButton);
LANGUAGES_RTL: ['ar', 'he'], };
SUPPORTED_LANGUAGES: ['en', 'de', 'ar']
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: 'de' }
})
}));
vi.mock('@/features/icons/components/Icon.vue', () => ({
default: {
name: 'Icon',
template: '<span>Icon</span>'
}
}));
describe('LanguageSwitchButton', () => { describe('LanguageSwitchButton', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
document.documentElement.lang = '';
document.documentElement.dir = '';
});
test('renders correctly with initial state closed', () => { test('renders correctly with initial state closed', () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
expect(wrapper.find('.language-button').exists()).toBe(true); expect(wrapper.find('.language-button').exists()).toBe(true);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
@@ -56,7 +34,7 @@ describe('LanguageSwitchButton', () => {
}); });
test('opens the dropdown when language button is clicked', async () => { test('opens the dropdown when language button is clicked', async () => {
const wrapper = mount(LanguageSwitchButton); const wrapper = getWrapper();
const button = wrapper.find('.language-button'); const button = wrapper.find('.language-button');
await button.trigger('click'); await button.trigger('click');
@@ -65,33 +43,28 @@ describe('LanguageSwitchButton', () => {
expect(button.attributes('aria-expanded')).toBe('true'); expect(button.attributes('aria-expanded')).toBe('true');
}); });
const languageTestCases = [ test('sets the language when clicking it', async () => {
{ code: 'en', expectedDir: 'ltr' }, const { getSetting } = useSettings();
{ code: 'de', expectedDir: 'ltr' },
{ code: 'ar', expectedDir: 'rtl' }
];
test.for(languageTestCases)('selectLanguage($code) sets localStorage, html attributes and changes layout direction to $expectedDir', async ({ code, expectedDir }) => { const wrapper = getWrapper();
const wrapper = mount(LanguageSwitchButton);
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
const options = wrapper.findAll('.language-dropdown li'); const options = wrapper.findAll('.language-dropdown li');
const optionToClick = options.find(opt => opt.text().includes(code));
await optionToClick.trigger('click'); const enOption = options.find(opt => opt.text().includes('en'));
await enOption.trigger('click');
expect(getSetting(LANGUAGE_PATH)).toBe('en');
expect(wrapper.find('.language-dropdown').exists()).toBe(false);
expect(loadLanguage).toHaveBeenCalledWith(code); const deOption = options.find(opt => opt.text().includes('de'));
expect(localStorage.getItem('locale')).toBe(code); await deOption.trigger('click');
expect(document.documentElement.lang).toBe(code); expect(getSetting(LANGUAGE_PATH)).toBe('de');
expect(document.documentElement.dir).toBe(expectedDir);
expect(wrapper.find('.language-dropdown').exists()).toBe(false); expect(wrapper.find('.language-dropdown').exists()).toBe(false);
}); });
test('closes the dropdown when clicking outside the component', async () => { test('closes the dropdown when clicking outside the component', async () => {
const wrapper = mount(LanguageSwitchButton, { const wrapper = getWrapper();
attachTo: document.body
});
await wrapper.find('.language-button').trigger('click'); await wrapper.find('.language-button').trigger('click');
expect(wrapper.find('.language-dropdown').exists()).toBe(true); expect(wrapper.find('.language-dropdown').exists()).toBe(true);
@@ -0,0 +1,51 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../navigatorLanguage';
import { describe, expect, test } from 'vitest';
describe('navigatorLanguage', () => {
test.for([
{ navigatorLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US', expected: 'en'},
{ navigatorLanguage: 'en-AU', expected: 'en'},
{ navigatorLanguage: 'de-DE', expected: 'de'},
{ navigatorLanguage: 'fr-FR', expected: 'fr'},
{ navigatorLanguage: 'en-us', expected: 'en'},
{ navigatorLanguage: 'en-au', expected: 'en'},
{ navigatorLanguage: 'de-de', expected: 'de'},
{ navigatorLanguage: 'fr-fr', expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', expected: 'zh'}
])('returns language $expected with navigator language $navigatorLanguage', ({ navigatorLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
expect(getNavigatorLanguage()).toBe(expected);
});
});
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export default function getCurrentLanguage () { export const getNavigatorLanguage = function getNavigatorLanguage () {
const saved = localStorage.getItem('locale');
if (saved) return saved;
const locale = new Intl.Locale(navigator.language); const locale = new Intl.Locale(navigator.language);
return locale.language; return locale.language;
}; };
@@ -22,7 +22,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingDefaultValue = vi.hoisted(() => 42); const settingDefaultValue = vi.hoisted(() => 42);
const settingPath = ['test', 'number']; const settingPath = ['test', 'number'];
vi.mock('../../settings.json', () => ({ vi.mock('../../config.js', () => ({
default: { default: {
contents: [ contents: [
{ {
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import settings from '../settings.json'; import settings from '../config.js';
import { useSettingsStore } from '../stores/settingsStore'; import { useSettingsStore } from '../stores/settingsStore';
import { getSettingRecursively } from '../utils/getSetting'; import { getSettingRecursively } from '../utils/getSetting';
+75
View File
@@ -0,0 +1,75 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getNavigatorLanguage } from '../i18n/utils/navigatorLanguage';
import { fallbackLocale, SUPPORTED_LANGUAGES } from '@/i18n';
let defaultLanguage;
{
const navigatorLanguage = getNavigatorLanguage();
if (SUPPORTED_LANGUAGES.includes(navigatorLanguage)) {
defaultLanguage = navigatorLanguage;
} else {
defaultLanguage = fallbackLocale;
}
}
export default {
contents: [
{
name: 'general',
i18n: 'settings.settings.general.title',
content: [
{
name: 'language',
type: 'selection',
i18n: 'settings.settings.general.settings.language.title',
default: defaultLanguage,
options: SUPPORTED_LANGUAGES.map((language) => ({
name: language,
i18n: `preferences.locale.languages.${language}`
}))
}
]
},
{
name: 'appearance',
i18n: 'settings.settings.appearance.title',
content: [
{
name: 'colorScheme',
type: 'selection',
i18n: 'settings.settings.appearance.contents.colorScheme.title',
default: 'auto',
options: [
{
name: 'auto',
i18n: 'settings.settings.appearance.contents.colorScheme.options.auto'
},
{
name: 'light',
i18n: 'settings.settings.appearance.contents.colorScheme.options.light'
},
{
name: 'dark',
i18n: 'settings.settings.appearance.contents.colorScheme.options.dark'
}
]
}
]
}
]
};
-30
View File
@@ -1,30 +0,0 @@
{
"contents": [
{
"name": "appearance",
"i18n": "settings.settings.appearance.title",
"content": [
{
"name": "colorScheme",
"type": "selection",
"i18n": "settings.settings.appearance.contents.colorScheme.title",
"default": "auto",
"options": [
{
"name": "auto",
"i18n": "settings.settings.appearance.contents.colorScheme.options.auto"
},
{
"name": "light",
"i18n": "settings.settings.appearance.contents.colorScheme.options.light"
},
{
"name": "dark",
"i18n": "settings.settings.appearance.contents.colorScheme.options.dark"
}
]
}
]
}
]
}
+1 -1
View File
@@ -18,7 +18,7 @@ limitations under the License.
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue'; import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue'; import SettingsPage from '../components/SettingsPage.vue';
import settingsConfiguration from '../settings.json'; import settingsConfiguration from '../config.js';
import { getSettingRecursively } from '../utils/getSetting.js'; import { getSettingRecursively } from '../utils/getSetting.js';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -197,7 +197,7 @@ const settings = settingsHoisted.values();
const originalCurrentSection = 's0'; const originalCurrentSection = 's0';
let currentSection = originalCurrentSection; let currentSection = originalCurrentSection;
vi.mock('../../settings.json', () => ({ vi.mock('../../config.js', () => ({
default: { contents: settingsHoisted } default: { contents: settingsHoisted }
})); }));
+7 -2
View File
@@ -15,7 +15,6 @@ limitations under the License.
*/ */
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import getCurrentLanguage from './utils/currentLanguage';
export const fallbackLocale = 'en'; export const fallbackLocale = 'en';
@@ -34,7 +33,7 @@ export const SUPPORTED_LANGUAGES = [
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: getCurrentLanguage(), locale: fallbackLocale,
fallbackLocale: fallbackLocale, fallbackLocale: fallbackLocale,
messages: {} messages: {}
}); });
@@ -58,3 +57,9 @@ export async function loadLanguage (locale) {
loadedLanguages.add(locale); loadedLanguages.add(locale);
}; };
export const setLocale = async function setLocale (locale) {
await loadLanguage(locale);
document.documentElement.lang = locale;
document.documentElement.dir = LANGUAGES_RTL.includes(locale) ? 'rtl' : 'ltr';
};
+8
View File
@@ -74,6 +74,14 @@
} }
} }
} }
},
"general": {
"title": "Allgemein",
"settings": {
"language": {
"title": "Sprache"
}
}
} }
} }
}, },
+8
View File
@@ -74,6 +74,14 @@
} }
} }
} }
},
"general": {
"title": "General",
"settings": {
"language": {
"title": "Language"
}
}
} }
} }
}, },
+1 -3
View File
@@ -20,7 +20,6 @@ import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue' import App from './App.vue'
import { i18n, loadLanguage, fallbackLocale } from './i18n'; import { i18n, loadLanguage, fallbackLocale } from './i18n';
import getCurrentLanguage from './utils/currentLanguage';
import router from './router' import router from './router'
import './styles/common.css' import './styles/common.css'
import './styles/variables/colors.css' import './styles/variables/colors.css'
@@ -30,7 +29,7 @@ import rawFonts from './styles/fonts.json';
import './styles/common.css'; import './styles/common.css';
import './styles/variables/colors.css'; import './styles/variables/colors.css';
import settings from './features/settings/settings.json'; import settings from './features/settings/config.js';
import { validateSettingsConfig } from './features/settings/utils/settingsValidator'; import { validateSettingsConfig } from './features/settings/utils/settingsValidator';
(async () => { (async () => {
@@ -53,7 +52,6 @@ import { validateSettingsConfig } from './features/settings/utils/settingsValida
fonts.forEach((font) => document.fonts.add(font)); fonts.forEach((font) => document.fonts.add(font));
await loadLanguage(fallbackLocale); await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia(); const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); pinia.use(piniaPluginPersistedstate);
@@ -1,61 +0,0 @@
/*
Copyright 2026 Seekra
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, expect, test } from 'vitest';
import getCurrentLanguage from '../currentLanguage';
describe('currentLanguage', () => {
test.for([
{ navigatorLanguage: 'en', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-US', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-AU', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-FR', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'en-us', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'en-au', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'fr-fr', localStorageLanguage: null, expected: 'fr'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'zh-Hant-TW', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'uz-Latn-UZ', localStorageLanguage: null, expected: 'uz'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: null, expected: 'en'},
{ navigatorLanguage: 'de-DE-u-co-phonebk', localStorageLanguage: null, expected: 'de'},
{ navigatorLanguage: 'zh-Hant-TW-u-co-phonebk', localStorageLanguage: null, expected: 'zh'},
{ navigatorLanguage: 'en', localStorageLanguage: 'de', expected: 'de'},
{ navigatorLanguage: 'de-DE', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'de-de', localStorageLanguage: 'en', expected: 'en'},
{ navigatorLanguage: 'zh-Hans-CN', localStorageLanguage: 'fr', expected: 'fr'},
{ navigatorLanguage: 'en-US-u-ca-gregory', localStorageLanguage: 'zh', expected: 'zh'}
])('returns the language $expected (navigator: $navigatorLanguage; local storage: $localStorageLanguage)', ({ navigatorLanguage, localStorageLanguage, expected }) => {
Object.defineProperty(navigator, 'language', {
value: navigatorLanguage,
configurable: true
});
if (localStorageLanguage) {
localStorage.setItem('locale', localStorageLanguage);
};
expect(getCurrentLanguage()).toBe(expected);
});
});