Feat(settings): add renderer for settings configuration #142

Merged
jakob.scheid merged 88 commits from feature/settings-renderer into main 2026-07-28 20:14:50 +02:00
37 changed files with 3728 additions and 142 deletions
+1
View File
@@ -13,3 +13,4 @@
- Internationalization and some major languages
- Color scheme
- Search results view with error message which indicates that the search is not available
- Settings renderer
+900 -129
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -11,14 +11,17 @@
"test:run": "vitest run"
},
"dependencies": {
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"terser": "^5.47.1",
"vue": "^3.5.32",
"vue-i18n": "^11.4.4",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@vue/test-utils": "^2.4.6",
"@pinia/testing": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"jsdom": "^29.1.1",
"vite": "^8.0.10",
"vite-plugin-html": "^3.2.2",
+26
View File
@@ -0,0 +1,26 @@
<!--
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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12 l6 8 l8 -16"
/>
</svg>

After

Width:  |  Height:  |  Size: 832 B

+26
View File
@@ -0,0 +1,26 @@
<!--
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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
d="M8 20 l8 -8 l-8 -8"
/>
</svg>

After

Width:  |  Height:  |  Size: 833 B

+1 -1
View File
@@ -35,7 +35,7 @@ const copyrightPeriod =
<template>
<footer class="global-footer">
<div class="footer-segment">
<RouterLink to="settings" class="link">
<RouterLink to="/settings" class="link">
{{ t('preferences.settings') }}
</RouterLink>
<LanguageSwitchButton />
@@ -0,0 +1,126 @@
<!--
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.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const normalizeSelectedValue = function normalizeSelectedValue (value) {
if (props.setting.allowMultiple) {
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
};
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const optionType = computed(() => props.setting.allowMultiple ? 'checkbox' : 'radio');
const selected = ref(normalizeSelectedValue(store.get(props.path) ?? props.setting.default));
watch(selected, (newValue) => {
store.set(props.path, newValue);
});
const labelId = useId();
</script>
<template>
<div>
<div class="label" :id="labelId">
{{ t(props.setting.i18n) }}
</div>
<ul class="options-list" :aria-labelledby="labelId">
<li v-for="option in props.setting.options" :key="option.name">
<label
class="settings-button"
:title="t(option.i18n)"
:aria-label="t('settings.selection.option.ariaLabel', { option: t(option.i18n) })"
>
<input
:type="optionType"
:name="props.path"
:value="option.name"
class="option-input"
v-model="selected"
>
<div class="option-check-mark-wrapper">
<Icon class="option-check-mark" name="check-mark" size="1em" />
</div>
<div class="input-label">
{{ t(option.i18n) }}
</div>
</label>
</li>
</ul>
</div>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.options-list {
list-style: none;
padding: 0;
margin: 0;
margin-top: 0.5em;
}
.option-input {
appearance: none;
display: none;
}
.option-check-mark-wrapper {
width: 1em;
height: 1em;
display: flex;
justify-content: center;
align-items: center;
}
.option-check-mark {
visibility: hidden;
}
.option-input:checked + .option-check-mark-wrapper .option-check-mark {
visibility: visible;
}
</style>
@@ -0,0 +1,19 @@
.settings-button {
--padding-x: 0.7em;
padding: 0.3em var(--padding-x);
margin-top: 0.2em;
width: calc(100% - 2 * var(--padding-x));
border-radius: 0.8em;
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
background-color: var(--light-bg);
user-select: none;
-moz-user-select: none;
}
.settings-button:hover {
background-color: var(--light-hover);
}
@@ -0,0 +1,215 @@
<!--
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.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsStore } from '../stores/settingsStore';
import { computed, ref, useId, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const store = useSettingsStore();
const inputModel = ref(store.get(props.path) ?? props.setting.default);
let oldInputValue = inputModel.value;
const doneButtonInitial = ref(true);
const inputId = useId();
const inputLabelId = useId();
const inputType = computed(() => ({
number: 'number',
string: 'text'
}[props.setting.type]
));
const hasChanged = computed(() =>
inputModel.value !== store.get(props.path)
);
watch(store, (newValue) => {
const newInputValue = newValue.get(props.path);
if (inputModel.value === oldInputValue) {
inputModel.value = newInputValue;
oldInputValue = newInputValue;
};
});
watch(inputModel, () => {
doneButtonInitial.value = false;
});
const apply = function apply () {
store.set(props.path, inputModel.value);
};
</script>
<template>
<div class="input-setting-container">
<label :for="inputId" :id="inputLabelId">
{{ t(props.setting.i18n) }}
</label>
<form
@submit.prevent="apply"
class="input-container"
:class="{ 'done-button-shown': hasChanged }"
>
<input
:type="inputType"
:id="inputId"
:aria-labelledby="inputLabelId"
v-model="inputModel"
class="input"
/>
<button
ref="doneButton"
class="done-button button"
:class="{ initial: doneButtonInitial }"
type="submit"
:title="t('settings.input.done.title')"
:aria-label="t('settings.input.done.ariaLabel')"
>
<Icon name="check-mark" />
</button>
</form>
</div>
</template>
<style scoped>
.input-setting-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
}
.input-container {
position: relative;
width: 50%;
white-space: nowrap;
--total-animation-duration: 0.3s;
}
.input {
box-sizing: border-box;
width: 100%;
height: calc(var(--input-height) + 2 * var(--input-padding-y));
display: inline;
vertical-align: middle;
--input-height: 24px;
transition: width calc(var(--total-animation-duration) / 3);
}
.input-container:not(.done-button-shown) .input {
transition-delay: calc((2 / 3) * var(--total-animation-duration));
}
.input-container.done-button-shown .input {
width: calc(100% - 1.5 * var(--input-padding) - 26px - 0.75 * var(--input-height) + 1.5px);
}
.done-button {
position: absolute;
right: 0;
top: 50%;
border: 1px solid var(--border);
min-height: var(--input-height);
padding: var(--input-padding);
transform: translateY(-50%) scale(0);
transform-origin: center;
vertical-align: middle;
align-items: center;
justify-content: center;
border-radius: calc(var(--input-padding) + 13px);
font-size: 1em;
font-family: inherit;
}
.done-button:not(.initial) {
animation: done-button-disappear var(--total-animation-duration);
}
.input-container.done-button-shown .done-button {
animation: done-button-appear var(--total-animation-duration);
display: inline-flex;
transform: translateY(-50%) scale(1);
}
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(0);
}
10% {
transform: translateY(-50%) scale(0);
}
95% {
transform: translateY(-50%) scale(1.05);
}
100% {
transform: translateY(-50%) scale(1);
}
}
@keyframes done-button-disappear {
0% {
transform: translateY(-50%) scale(1);
}
100% {
transform: translateY(-50%) scale(0);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes done-button-appear {
0% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
10% {
transform: translateY(-50%) scale(1);
opacity: 0;
}
100% {
transform: translateY(-50%) scale(1);
opacity: 1;
}
}
@keyframes done-button-disappear {
0% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50%) scale(1);
}
}
}
</style>
@@ -0,0 +1,93 @@
<!--
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.
-->
<script setup>
import Switch from './Switch.vue';
import SettingsInput from './SettingsInput.vue';
import Selection from './Selection.vue';
import SettingsPageLink from './SettingsPageLink.vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
</script>
<template>
<div>
<h2 class="section-heading">
{{ t(props.setting.i18n) }}
</h2>
<div
v-for="setting in props.setting.content"
:key="setting"
class="setting"
>
<Switch
v-if="setting.type === 'bool'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsInput
v-if="setting.type === 'string' || setting.type === 'number'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<Selection
v-if="setting.type === 'selection'"
:setting="setting"
:path="`${props.path}.${setting.name}`"
/>
<SettingsPageLink
v-if="setting.type === 'section'"
:display-name="t(setting.i18n)"
:target="`${props.path}.${setting.name}`"
/>
</div>
</div>
</template>
<style scoped>
.setting {
margin-bottom: 1.2em;
}
.section-heading {
--size: 1.2em;
font-size: var(--size);
margin-bottom: calc(var(--size) - 0.2em);
}
.section-heading[aria-level="2"] {
--size: 1.8em;
}
.section-heading[aria-level="3"] {
--size: 1.6em;
}
.section-heading[aria-level="4"] {
--size: 1.4em;
}
</style>
@@ -0,0 +1,61 @@
<!--
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.
-->
<script setup>
import Icon from '@/features/icons/components/Icon.vue';
import { useSettingsPage } from '../composables/useSettingsPage';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const { goToSettingsPage } = useSettingsPage();
const props = defineProps({
target: {
required: true,
type: String
},
displayName: {
required: true,
type: String
}
});
const followLink = function followLink () {
goToSettingsPage(props.target);
};
</script>
<template>
<button
class="settings-button button settings-page-link"
:title="props.displayName"
:aria-label="t('settings.pages.link.ariaLabel', { page: props.displayName })"
@click="followLink"
>
{{ props.displayName }}
<Icon name="chevron-right" size="1em" />
</button>
</template>
<style scoped src="./SettingsButton.css"></style>
<style scoped>
.settings-page-link {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<!--
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.
-->
<script setup>
import { useSettingsStore } from '../stores/settingsStore';
import { useId } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
setting: {
required: true
},
path: {
required: true
}
});
const labelId = useId();
const switchId = useId();
const store = useSettingsStore();
const toggle = function toggle () {
store.set(props.path, !store.get(props.path));
};
</script>
<template>
<div class="switch-container" @click="toggle">
<label :for="switchId" :id="labelId" class="switch-label">
{{ t(props.setting.i18n) }}
</label>
<div
class="switch-wrapper"
:class="{ enabled: store.get(props.path) }"
:title="t('settings.switch.title')"
:aria-label="t('settings.switch.ariaLabel')"
:aria-labelledby="labelId"
>
<div class="switch-point"></div>
</div>
</div>
</template>
<style scoped>
.switch-container {
display: flex;
justify-content: space-between;
width: 100%;
align-items: center;
cursor: pointer;
}
.switch-label {
cursor: inherit;
}
.switch-wrapper {
--point-size: 0.9em;
--padding: 2px;
--transition-duration: 0.2s;
background-color: var(--light-bg);
outline: 1px solid var(--border);
width: calc(2 * var(--point-size));
padding: var(--padding);
border-radius: calc(0.5 * var(--point-size) + var(--padding));
transition: background-color var(--transition-duration) ease;
}
.switch-point {
height: var(--point-size);
width: var(--point-size);
background-color: var(--border);
border-radius: calc(0.5 * var(--point-size));
margin-left: 0;
transition: margin-left var(--transition-duration) ease;
}
.switch-wrapper.enabled {
background-color: var(--primary-color);
}
.switch-wrapper.enabled .switch-point {
margin-left: var(--point-size);
}
</style>
@@ -0,0 +1,267 @@
/*
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 Selection from '../Selection.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({
i18n = 'selection',
options = [],
allowMultiple = false,
defaultValue = undefined,
translations = {},
piniaOptions = {}
} = {}) {
return mountComponent(Selection, {
attrs: {
setting: {
name: 'selection',
type: 'selection',
i18n,
options,
allowMultiple,
default: defaultValue
},
path: 'selection'
}
}, translations, [], piniaOptions);
};
const getChecked = function getChecked (inputs) {
return inputs.map((input) => input.element.matches(':checked'));
};
const exampleOptions = [
{ name: 'o0', i18n: 'o0' },
{ name: 'o1', i18n: 'o1' },
{ name: 'o2', i18n: 'o2' }
];
describe('Selection', () => {
test('displays options correctly', () => {
const wrapper = getWrapper({ options: exampleOptions });
const listItems = wrapper.findAll('li');
expect(listItems.length).toBe(3);
for (let i = 0; i <= 2; i++) {
const option = exampleOptions[i];
const listItem = listItems[i];
expect(listItem.find('.input-label').text()).toBe(option.i18n);
const input = listItem.find('input');
expect(input.attributes('type')).toBe('radio');
expect(input.attributes('name')).toBe('selection');
expect(input.element.matches(':checked')).toBe(false);
};
});
test('shows value from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('shows multiple values from store', () => {
const wrapper = getWrapper({
options: exampleOptions,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', ['o1', 'o2']);
}
},
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, true]);
});
test('shows default value', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: 'o2'
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, false, true]);
});
test('shows multiple default values', () => {
const wrapper = getWrapper({
options: exampleOptions,
defaultValue: ['o0', 'o1'],
allowMultiple: true
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([true, true, false]);
});
test('shows store value even if default value is set', () => {
const wrapper = getWrapper({
options: exampleOptions,
default: 'o2',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.set('selection', 'o1');
}
}
});
const inputs = wrapper.findAll('input');
const inputsChecked = getChecked(inputs);
expect(inputsChecked).toStrictEqual([false, true, false]);
});
test('stores one value', async () => {
const wrapper = getWrapper({ options: exampleOptions });
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await inputO0.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o0');
await inputO1.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o1');
await inputO2.setValue(true);
await nextTick();
expect(store.get('selection')).toBe('o2');
expect(inputO2.element.matches(':checked')).toBe(true);
});
test('stores multiple values', async () => {
const setValue = async function setValue (input, value) {
input.setValue(value);
await nextTick();
};
const check = function check (input) {
return setValue(input, true);
};
const uncheck = function uncheck (input) {
return setValue(input, false);
};
const expectSelectionValue = function expectSelectionValue (value, options = exampleOptions) {
value.sort();
expect(store.get('selection').sort()).toStrictEqual(value);
const checkedInputs = getChecked(inputs);
expect(
options
.map((option) => option.name)
.filter((entry, i) => checkedInputs[i])
).toStrictEqual(value);
};
const wrapper = getWrapper({
options: exampleOptions,
allowMultiple: true
});
const store = useSettingsStore();
expect(store.get('selection')).toBeUndefined();
const inputs = wrapper.findAll('input');
const inputO0 = inputs[0];
const inputO1 = inputs[1];
const inputO2 = inputs[2];
await check(inputO0);
expectSelectionValue(['o0'])
await uncheck(inputO0);
expectSelectionValue([]);
await check(inputO1);
expectSelectionValue(['o1']);
await check(inputO0);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO2);
expectSelectionValue(['o0', 'o1']);
await check(inputO2);
expectSelectionValue(['o0', 'o1', 'o2']);
await uncheck(inputO1);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(true);
expectSelectionValue(['o0', 'o2']);
await inputO0.setValue(false);
expectSelectionValue(['o2']);
await inputO1.setValue(true);
expectSelectionValue(['o1', 'o2']);
await inputO2.setValue(false);
expectSelectionValue(['o1']);
await inputO1.setValue(true);
expectSelectionValue(['o1']);
await inputO1.setValue(false);
expectSelectionValue([]);
});
test('shows correct translation', () => {
const translation = 'Selection setting';
const wrapper = getWrapper({
translations: {
selection: translation
}
});
const label = wrapper.find('.label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,210 @@
/*
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 SettingsInput from '../SettingsInput.vue';
import { mountComponent } from '@/test-utils/mountComponent';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper({ type = 'string', i18n = 'input', defaultValue = undefined, piniaOptions = {} } = {}) {
return mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
default: defaultValue,
i18n,
type
},
path: 'input'
}
}, {}, [], piniaOptions);
}
describe('SettingsInput', () => {
test('uses correct input type', () => {
const stringSettingWrapper = getWrapper();
const stringInputElement = stringSettingWrapper.find('input');
expect(stringInputElement.attributes('type')).toBe('text');
const numberSettingWrapper = getWrapper({ type: 'number' });
const numberInputElement = numberSettingWrapper.find('input');
expect(numberInputElement.attributes('type')).toBe('number');
});
test('shows empty value', () => {
const stringSettingWrapper = getWrapper();
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.attributes('value')).toBeUndefined();
});
test('shows value from store', () => {
const value = 'example value';
const stringSettingWrapper = getWrapper({
type: 'string',
i18n: 'input',
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = value;
}
}
});
const inputElement = stringSettingWrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('shows default value', () => {
const value = 'example value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: value
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(value);
});
test('store value instead of default value', () => {
const storeValue = 'store value';
const defaultValue = 'default value';
const wrapper = getWrapper({
type: 'string',
i18n: 'input',
defaultValue: defaultValue,
piniaOptions: {
setupStores: () => {
const store = useSettingsStore();
store.settings.input = storeValue;
}
}
});
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe(storeValue);
});
test('stores value', async () => {
const value = 'example value';
const wrapper = getWrapper();
const store = useSettingsStore();
expect(store.get('input')).toBeUndefined();
const inputElement = wrapper.find('input');
await inputElement.setValue(value);
const form = wrapper.find('form');
await form.trigger('submit');
expect(store.get('input')).toBe(value);
});
test('reacts on store change', async () => {
const value = 'example value';
const value2 = 'example value 2';
const anotherValue = 'another value';
const wrapper = getWrapper();
const inputElement = wrapper.find('input');
expect(inputElement.element.value).toBe('');
const store = useSettingsStore();
store.set('input', value);
await nextTick();
expect(inputElement.element.value).toBe(value);
await inputElement.setValue(anotherValue);
expect(inputElement.element.value).toBe(anotherValue);
// input does not update if there are unsaved changes
store.set('input', value2);
expect(inputElement.element.value).toBe(anotherValue);
const form = wrapper.find('form');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(store.get('input')).toBe(anotherValue);
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows done button correctly', async () => {
const wrapper = getWrapper();
const form = wrapper.find('form');
const inputElement = wrapper.find('input');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('value');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('Hello, World!');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
expect(form.classes('done-button-shown')).toBeTruthy();
await form.trigger('submit');
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('a');
expect(form.classes('done-button-shown')).toBeTruthy();
const store = useSettingsStore();
store.set('input', 'a');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
await inputElement.setValue('');
store.set('input', '');
await nextTick();
expect(form.classes('done-button-shown')).toBeFalsy();
});
test('shows correct translation', () => {
const translation = 'Text input';
const wrapper = mountComponent(SettingsInput, {
attrs: {
setting: {
name: 'input',
i18n: 'input',
type: 'string',
},
path: 'input'
}
}, {
input: translation
});
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,160 @@
/*
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 Selection from '../Selection.vue';
import SettingsInput from '../SettingsInput.vue';
import SettingsPage from '../SettingsPage.vue';
import SettingsPageLink from '../SettingsPageLink.vue';
import Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { describe, expect, test } from 'vitest';
const getWrapper = function getWrapper({ content = [], translations = {}, piniaOptions = {} }) {
return mountComponent(SettingsPage, {
attrs: {
setting: {
name: 'section',
type: 'section',
i18n: 'section',
content: content
},
path: 'section'
}
}, translations, [], piniaOptions);
};
const exampleSettings = [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.section.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.section1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.section.se0',
options: [
{ name: 'o0', i18n: 'settings.section.se0.o0' },
{ name: 'o1', i18n: 'settings.section.se0.o1' },
{ name: 'o2', i18n: 'settings.section.se0.o2' },
{ name: 'o3', i18n: 'settings.section.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.section.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.section.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.section.s1.b0',
default: true
}
]
}
];
describe('SettingsPage', () => {
test('shows correct heading', () => {
const translation = 'Example section';
const wrapper = getWrapper({
translations: { section: translation }
});
const heading = wrapper.find('.section-heading');
expect(heading.text()).toBe(translation);
});
test('shows settings correctly', () => {
const wrapper = getWrapper({
content: exampleSettings
});
const settingsWrappers = wrapper.findAll('.setting');
expect(settingsWrappers.length).toBe(exampleSettings.length);
exampleSettings.forEach((expectedSetting, index) => {
const settingWrapper = settingsWrappers[index];
const switchSetting = settingWrapper.findComponent(Switch);
const inputSetting = settingWrapper.findComponent(SettingsInput);
const selectionSetting = settingWrapper.findComponent(Selection);
const settingsPageLink = settingWrapper.findComponent(SettingsPageLink);
let setting;
if (expectedSetting.type === 'bool') {
expect(switchSetting.exists()).toBe(true);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = switchSetting;
} else if (expectedSetting.type === 'string' || expectedSetting.type === 'number') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(true);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(false);
setting = inputSetting;
} else if (expectedSetting.type === 'selection') {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(true);
expect(settingsPageLink.exists()).toBe(false);
setting = selectionSetting;
} else {
expect(switchSetting.exists()).toBe(false);
expect(inputSetting.exists()).toBe(false);
expect(selectionSetting.exists()).toBe(false);
expect(settingsPageLink.exists()).toBe(true);
setting = settingsPageLink;
}
if (
expectedSetting.type === 'bool' ||
expectedSetting.type === 'string' ||
expectedSetting.type === 'number' ||
expectedSetting.type === 'selection'
) {
expect(setting.props('setting')).toMatchObject(expectedSetting);
expect(setting.props('path')).toBe(`section.${expectedSetting.name}`);
} else {
expect(setting.props('displayName')).toBe(expectedSetting.i18n);
expect(setting.props('target')).toBe(`section.${expectedSetting.name}`);
}
});
});
});
@@ -0,0 +1,59 @@
/*
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 SettingsPageLink from '../SettingsPageLink.vue';
import { mount } from '@vue/test-utils';
import { describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
const goToSettingsPage = vi.fn();
vi.mock('../../composables/useSettingsPage.js', () => ({
useSettingsPage: () => ({
goToSettingsPage
})
}));
const getWrapper = function getWrapper({ target, displayName }) {
return mount(SettingsPageLink, {
attrs: {
target,
displayName
},
global: {
plugins: [
createI18n()
]
}
});
};
describe('SettingsPageLink', () => {
test('shows correct display name', () => {
const displayName = 'A settings page link';
const wrapper = getWrapper({ displayName });
expect(wrapper.text()).toBe(displayName);
});
test('follows link', async () => {
const target = 'section1.subsection2';
const wrapper = getWrapper({ target });
const button = wrapper.find('button');
await button.trigger('click');
expect(goToSettingsPage).toHaveBeenCalledWith(target);
});
});
@@ -0,0 +1,83 @@
/*
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 Switch from '../Switch.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { useSettingsStore } from '../../stores/settingsStore.js';
import { expect, describe, test } from 'vitest';
import { nextTick } from 'vue';
const getWrapper = function getWrapper ({ i18n = 'switch1', translations = {} } = {}) {
return mountComponent(Switch, {
attrs: {
setting: {
type: 'bool',
name: 'switch',
i18n
},
path: 'switch'
}
}, translations);
};
describe('Switch', () => {
test('shows value from store', async () => {
const wrapper = getWrapper();
const store = useSettingsStore();
store.set('switch', true);
await nextTick();
const switchElement = wrapper.find('.switch-wrapper');
const switchElementClasses = switchElement.classes();
expect(switchElementClasses).toContain('enabled');
store.set('switch', false);
await nextTick();
const switchElementClasses2 = switchElement.classes();
expect(switchElementClasses2).not.toContain('enabled');
});
test('toggles value in store', async () => {
const wrapper = getWrapper();
const switchElement = wrapper.find('.switch-wrapper');
const store = useSettingsStore();
expect(store.get('switch')).toBeUndefined();
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(true);
switchElement.trigger('click');
await nextTick();
expect(store.get('switch')).toBe(false);
});
test('shows correct translation', () => {
const translation = 'Switch';
const wrapper = getWrapper({
translations: { switch1: translation }
})
const label = wrapper.find('label');
expect(label.text()).toBe(translation);
});
});
@@ -0,0 +1,40 @@
/*
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 { useSettingsPage } from '../useSettingsPage.js';
import { describe, expect, test, vi } from 'vitest';
const pushRoute = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({
push: pushRoute
})
}));
describe('useSettingsPage', () => {
describe('goToSettingsPage', () => {
test('pushes correct route', async () => {
const { goToSettingsPage } = useSettingsPage();
await goToSettingsPage('..a.b...c.d..e......');
expect(pushRoute).toHaveBeenCalledWith({
name: 'settings',
params: {
rest: ['a', 'b', 'c', 'd', 'e']
}
});
});
});
});
@@ -0,0 +1,33 @@
/*
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 { getSettingsPagePathSegments } from '../utils/settingsPage';
import { useRouter } from 'vue-router';
export const useSettingsPage = function useSettingsPage () {
const router = useRouter();
const goToSettingsPage = function goToSettingsPage (pagePath) {
return router.push({
name: 'settings',
params: {
rest: getSettingsPagePathSegments(pagePath)
}
});
};
return { goToSettingsPage };
};
@@ -0,0 +1,42 @@
/*
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 { defineStore } from 'pinia';
import { reactive } from 'vue';
export const useSettingsStore = defineStore('settings', () => {
const settings = reactive({});
const set = function set (key, value) {
settings[key] = value;
};
const get = function get (key) {
return settings[key];
};
const remove = function remove (key) {
delete settings[key];
};
const clear = function clear () {
Object.keys(settings).forEach(key => delete settings[key]);
};
return { set, get, remove, clear, settings };
}, {
persist: true
});
@@ -0,0 +1,526 @@
/*
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 { getSetting, getSettingRecursively } from '../getSetting.js';
import { test, expect } from 'vitest';
test.for([
({ settings: [], name: '', expected: undefined }),
({ settings: [], name: 'a', expected: undefined }),
({ settings: [
{
type: 'bool',
name: 'a',
i18n: 'a'
}
], name: 'a', expected: {
type: 'bool',
name: 'a',
i18n: 'a'
} })
])('returns $expected', ({ settings, name, expected }) => {
expect(getSetting(name, settings)).toStrictEqual(expected);
});
const veryNestedSettingsList = [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'bool',
name: '3a',
i18n: '3a'
},
{
type: 'section',
name: '3b',
i18n: '3b',
content: [
{
type: 'bool',
name: '4a',
i18n: '4a'
},
{
type: 'section',
name: '4b',
i18n: '4b',
content: [
{
type: 'bool',
name: '5a',
i18n: '5a'
},
{
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
}
]
}
]
}
]
}
]
}
];
test.for([
({ settings: [], path: ['a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1a'], expected: {
type: 'bool',
name: '1a',
i18n: '1a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
}
]
}
], path: ['1b', '2a'], expected: {
type: 'bool',
name: '2a',
i18n: '2a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'number',
name: '2b',
i18n: '2b',
default: 42
}
]
}
], path: ['1b', '2b'], expected: {
type: 'number',
name: '2b',
i18n: '2b',
default: 42
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3a'], expected: {
type: 'string',
name: '3a',
i18n: '3a'
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b'], expected: {
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b'], expected: {
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
} }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2b', '3b'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2a', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1b', '2c', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1a', '2b', '3a'], expected: undefined }),
({ settings: [
{
type: 'bool',
name: '1a',
i18n: '1a'
},
{
type: 'section',
name: '1b',
i18n: '1b',
content: [
{
type: 'bool',
name: '2a',
i18n: '2a'
},
{
type: 'section',
name: '2b',
i18n: '2b',
content: [
{
type: 'string',
name: '3a',
i18n: '3a'
}
]
}
]
}
], path: ['1c', '2b', '3a'], expected: undefined }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b', '6b', '7a'], expected: {
type: 'bool',
name: '7a',
i18n: '7a'
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4b', '5b'], expected: {
type: 'section',
name: '5b',
i18n: '5b',
content: [
{
type: 'bool',
name: '6a',
i18n: '6a'
},
{
type: 'section',
name: '6b',
i18n: '6b',
content: [
{
type: 'bool',
name: '7a',
i18n: '7a'
}
]
}
]
} }),
({ settings: veryNestedSettingsList, path: ['1b', '2b', '3b', '4ba', '5b', '6b', '7a'], expected: undefined })
])('returns $expected', ({ settings, path, expected }) => {
expect(getSettingRecursively(path, settings)).toStrictEqual(expected);
});
@@ -0,0 +1,63 @@
/*
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 { getSettingsPagePathSegments } from '../settingsPage.js';
import { describe, expect, test } from 'vitest';
describe('settingsPage', () => {
describe('getSettingsPagePathSegments', () => {
test.for([
{ path: 'a.b.c', expected: ['a', 'b', 'c'] },
{ path: 'a', expected: ['a'] },
{ path: 'a.b', expected: ['a', 'b'] },
{ path: 'a..b', expected: ['a', 'b'] },
{ path: '.a.b', expected: ['a', 'b'] },
{ path: '..a.b', expected: ['a', 'b'] },
{ path: '.a.b.', expected: ['a', 'b'] },
{ path: '..a.b..', expected: ['a', 'b'] },
{ path: 'a.b.', expected: ['a', 'b'] },
{ path: 'a.b..', expected: ['a', 'b'] },
{ path: '.a.b..', expected: ['a', 'b'] },
{ path: '..a.b.', expected: ['a', 'b'] },
{ path: '.a..b', expected: ['a', 'b'] },
{ path: '..a..b', expected: ['a', 'b'] },
{ path: '.a..b.', expected: ['a', 'b'] },
{ path: '..a..b..', expected: ['a', 'b'] },
{ path: 'a..b.', expected: ['a', 'b'] },
{ path: 'a..b..', expected: ['a', 'b'] },
{ path: '.a..b..', expected: ['a', 'b'] },
{ path: '..a..b.', expected: ['a', 'b'] },
{ path: '..a.....b.c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c', expected: ['a', 'b', 'c'] },
{ path: 'a.#b.c/', expected: ['a', 'b', 'c'] },
{ path: 'a1.#b.c/', expected: ['a1', 'b', 'c'] },
{ path: 'a-1.#b.c/', expected: ['a-1', 'b', 'c'] },
{ path: 'a-1.b@.c', expected: ['a-1', 'b', 'c'] },
{ path: '....@a/#...)!&§[b.#§c..d....', expected: ['a', 'b', 'c', 'd'] },
{ path: '....@a/#...)!&§[b.#§c..dä....', expected: ['a', 'b', 'c', 'd'] },
{ path: '..,...~..@a/#.+..)!&§[b.#§c..dä..)..', expected: ['a', 'b', 'c', 'd'] },
{ path: 'a.@.b', expected: ['a', 'b'] },
{ path: 1, expected: [] },
{ path: false, expected: [] },
{ path: true, expected: [] },
{ path: null, expected: [] },
{ path: undefined, expected: [] }
])('returns $expected', async ({ path, expected }) => {
expect(getSettingsPagePathSegments(path)).toStrictEqual(expected);
});
})
})
+42
View File
@@ -0,0 +1,42 @@
/*
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 getSetting = function getSetting (name, settings) {
return settings.filter((setting) => setting.name === name)[0];
};
export const getSettingRecursively = function getSettingRecursively (sectionPath, settings) {
const oneSettingsLevel = function oneSettingsLevel (cursor, acc) {
const setting = getSetting(cursor[0], acc.content);
if (!setting) return;
if (setting.type === undefined) setting.type = 'section';
if (setting.type === 'section') {
const newCursor = cursor.slice(1, cursor.length);
if (newCursor.length !== 0) {
return oneSettingsLevel(cursor.slice(1, cursor.length), setting);
} else {
return setting;
};
} else if (cursor.length <= 1) {
return setting;
} else {
return;
};
};
return oneSettingsLevel(sectionPath, { content: settings });
};
@@ -0,0 +1,31 @@
/*
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.
*/
/**
* Extracts the segments of a settings page path and normalizes each segment.
* @param {string} path - The settings page path.
* @returns {Array} The settings page path segments.
*/
export const getSettingsPagePathSegments = function getSettingsPagePathSegments (path) {
if (typeof path !== 'string') return [];
return path
.replace(/^\.+|\.+$/g, '')
.split('.')
.map(
(segment) => segment.replace(/[^a-zA-Z0-9-]/g, '')
)
.filter(Boolean);
};
@@ -16,10 +16,51 @@ limitations under the License.
<script setup>
import LeftSidebarLayout from '@/layouts/LeftSidebarLayout.vue';
import SettingsPage from '../components/SettingsPage.vue';
import { getSettingRecursively } from '../utils/getSetting.js';
import { loadSettingsConfig } from '../utils/settingsParser';
import { onMounted, ref, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const settingsLoaded = ref(false)
const settings = ref([]);
const activeSectionContent = ref({});
const activeSection = ref(null);
watchEffect(() => {
const segments = route.path
.split('/')
.filter(Boolean);
activeSection.value = segments.slice(1, segments.length).join('.');
});
const updateSettings = function updateSettings () {
if (activeSection.value) {
const setting = getSettingRecursively(activeSection.value.split('.'), settings.value);
if (!setting) {
router.push('/settings');
} else {
activeSectionContent.value = setting;
};
} else {
activeSectionContent.value = { content: [] };
};
};
onMounted(async () => {
settings.value = (await loadSettingsConfig()).contents;
watchEffect(() => {
updateSettings();
});
settingsLoaded.value = true;
});
</script>
<template>
@@ -31,7 +72,29 @@ const { t } = useI18n();
</header>
<LeftSidebarLayout class="layout">
<template #sidebar>
<ul class="sidebar-sections-list">
<li v-for="section in settings">
<RouterLink
:to="`/settings/${section.name}`"
class="button button-link link sidebar-section"
:class="{ active: activeSection === section.name }"
>
{{ t(section.i18n) }}
</RouterLink>
</li>
</ul>
</template>
<div v-if="!settingsLoaded" class="main-content--loading">
{{ t('loading') }}
</div>
<div v-else-if="activeSection" class="main-content--loaded">
<div class="settings-main-content">
<SettingsPage :setting="activeSectionContent" :path="activeSection" />
</div>
</div>
<div v-else class="main-content--no-section">
{{ t('settings.noSectionSelected') }}
</div>
</LeftSidebarLayout>
</div>
</template>
@@ -46,6 +109,10 @@ const { t } = useI18n();
flex-direction: column;
}
.active {
background-color: var(--light-hover);
}
.header {
padding: var(--main-content-padding-y) var(--main-content-padding-x);
}
@@ -53,4 +120,31 @@ const { t } = useI18n();
.header h1 {
margin: 0;
}
.sidebar-sections-list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-section {
--padding: 0.8em;
border-radius: var(--padding);
padding: var(--padding);
margin-bottom: 4px;
width: calc(100% - 2 * var(--padding));
text-align: start;
font-size: 1rem;
display: block;
}
.settings-main-content {
padding: 0 30%;
}
@media (max-width: 80em) {
.settings-main-content {
padding: 0;
}
}
</style>
@@ -0,0 +1,306 @@
/*
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 SettingsView from '../SettingsView.vue';
import { mountComponent } from '@/test-utils/mountComponent.js';
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const settingsHoisted = vi.hoisted(() => [
{
name: 's0',
i18n: 'settings.s0',
content: []
},
{
name: 's1',
i18n: 'settings.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.b0'
},
{
type: 'number',
name: 'n0',
i18n: 'settings.s1.n0'
},
{
type: 'string',
name: 'st0',
i18n: 'settings.s1.st0'
},
{
type: 'selection',
name: 'se0',
i18n: 'settings.s1.se0',
options: [
{ name: 'o0', i18n: 'settings.s1.se0.o0' },
{ name: 'o1', i18n: 'settings.s1.se0.o1' },
{ name: 'o2', i18n: 'settings.s1.se0.o2' },
{ name: 'o3', i18n: 'settings.s1.se0.o3' }
]
},
{
type: 'section',
name: 's0',
i18n: 'settings.s1.s0',
content: []
},
{
type: 'section',
name: 's1',
i18n: 'settings.s1.s1',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s1.s1.b0',
default: true
}
]
}
]
},
{
name: 's2',
i18n: 'settings.s2',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s2.s0',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s2.s0.b0',
default: true
}
]
}
]
},
{
name: 's3',
i18n: 'settings.s3',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s3.s0',
content: []
}
]
},
{
name: 's4',
i18n: 'settings.s4',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s4.se0',
options: [
{ name: 'o0', i18n: 'settings.s4.se0.o0' },
{ name: 'o1', i18n: 'settings.s4.se0.o1' },
{ name: 'o2', i18n: 'settings.s4.se0.o2' },
{ name: 'o3', i18n: 'settings.s4.se0.o3' }
]
}
]
},
{
name: 's5',
i18n: 'settings.s5',
content: [
{
type: 'string',
name: 'st0',
i18n: 'settings.s5.st0'
}
]
},
{
name: 's6',
i18n: 'settings.s6',
content: [
{
type: 'number',
name: 'n0',
i18n: 'settings.s6.n0'
}
]
},
{
name: 's7',
i18n: 'settings.s7',
content: [
{
type: 'bool',
name: 'b0',
i18n: 'settings.s7.b0'
}
]
},
{
name: 's8',
i18n: 'settings.s8',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0',
content: [
{
type: 'section',
name: 's0',
i18n: 'settings.s8.s0.s0.s0',
content: [
{
type: 'selection',
name: 'se0',
i18n: 'settings.s8.s0.s0.s0.se0',
allowMultiple: true,
options: []
}
]
}
]
}
]
}
]
}
]);
const settings = settingsHoisted.values();
const originalCurrentSection = 's0';
let currentSection = originalCurrentSection;
vi.mock('../../utils/settingsParser', () => ({
loadSettingsConfig: vi.fn().mockResolvedValue({
contents: settingsHoisted
})
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useRoute: () => ({
path: `/settings/${currentSection}`
})
};
});
const getWrapper = function getWrapper ({ translations = {} } = {}) {
return mountComponent(SettingsView, {}, translations, [], {});
};
describe('SettingsView', () => {
describe('heading', () => {
test('shows correct translation', () => {
const translation = 'Settings';
const wrapper = getWrapper({
translations: {
'preferences.settings': translation
}
});
const header = wrapper.find('header');
expect(header.text()).toBe(translation);
});
});
describe('sidebar', () => {
test('shows all top-level sections', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const sidebarItems = wrapper.findAll('.sidebar-sections-list > li');
settings.forEach((setting, index) => {
const sidebarItem = sidebarItems[index];
expect(sidebarItem.text()).toBe(setting.i18n);
const link = sidebarItem.find('*');
expect(link.attributes('href')).toBe(`/settings/${setting.name}`);
expect(link.classes('active')).toBe(setting.name === currentSection);
});
});
});
describe('main', () => {
describe('correct element', () => {
test('shows that it is loading', () => {
const wrapper = getWrapper();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(true);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(false);
});
test('shows that no section is selected', async () => {
currentSection = '';
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(false);
expect(mainContentNoSection.exists()).toBe(true);
});
test('shows main content', async () => {
const wrapper = getWrapper();
await flushPromises();
await wrapper.vm.$nextTick();
const mainContentLoading = wrapper.find('.main-content--loading');
const mainContentLoaded = wrapper.find('.main-content--loaded');
const mainContentNoSection = wrapper.find('.main-content--no-section');
expect(mainContentLoading.exists()).toBe(false);
expect(mainContentLoaded.exists()).toBe(true);
expect(mainContentNoSection.exists()).toBe(false);
});
});
});
});
beforeEach(() => {
currentSection = originalCurrentSection;
});
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Laden ...",
"search": {
"searchBar": {
"submit": "Suchen",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Gebaut zum Suchen."
"slogan": "Gebaut zum Suchen.",
"settings": {
"input": {
"done": {
"title": "Wert speichern",
"ariaLabel": "Speichert den eingegebenen Wert"
}
},
"switch": {
"title": "Wert umschalten",
"ariaLabel": "Schaltet den Wert um"
},
"pages": {
"link": {
"ariaLabel": "Geht zur Einstellungsseite {page}"
}
},
"noSectionSelected": "Kein Einstellungsabschnitt ist ausgewählt.",
"selection": {
"option": {
"ariaLabel": "Wählt die Option {option} aus"
}
}
}
}
+25 -1
View File
@@ -1,4 +1,5 @@
{
"loading": "Loading ...",
"search": {
"searchBar": {
"submit": "Search",
@@ -37,5 +38,28 @@
}
}
},
"slogan": "Built to search."
"slogan": "Built to search.",
"settings": {
"input": {
"done": {
"title": "Save value",
"ariaLabel": "Saves the entered value"
}
},
"switch": {
"title": "Toggle value",
"ariaLabel": "Toggles the value"
},
"pages": {
"link": {
"ariaLabel": "Goes to settings page {page}"
}
},
"noSectionSelected": "No settings section is selected.",
"selection": {
"option": {
"ariaLabel": "Selects option {option}"
}
}
}
}
+1
View File
@@ -1,4 +1,5 @@
{
"loading": "Cargando ...",
"search": {
"searchBar": {
"submit": "Buscar",
+1
View File
@@ -1,4 +1,5 @@
{
"loading": "Chargement ...",
"search": {
"searchBar": {
"submit": "Rechercher",
+1
View File
@@ -1,4 +1,5 @@
{
"loading": "Caricamento ...",
"search": {
"searchBar": {
"submit": "Cerca",
+1
View File
@@ -1,4 +1,5 @@
{
"loading": "A carregar ...",
"search": {
"searchBar": {
"submit": "Pesquisar",
+12 -3
View File
@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { createApp } from 'vue';
import App from './App.vue';
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue'
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/variables/colors.css'
import './styles/variables/dimensions.css';
import './styles/common.css';
import './styles/variables/colors.css';
@@ -27,8 +32,12 @@ import './styles/variables/colors.css';
await loadLanguage(fallbackLocale);
await loadLanguage(getCurrentLanguage());
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App)
.use(router)
.use(i18n)
.use(pinia)
.mount('#app');
})();
+13 -1
View File
@@ -40,7 +40,7 @@ const routes = [
}
},
{
path: '/settings',
path: '/settings/:rest(.*)*',
name: 'settings',
component: SettingsView,
meta: {
@@ -72,4 +72,16 @@ export const updatePageTitle = function updatePageTitle (route) {
};
};
// remove trailing slash(es)
router.beforeEach((route) => {
if (route.path !== '/' && route.path.endsWith('/')) {
return {
path: route.path.replace(/\/+$/, ''),
query: route.query,
hash: route.hash,
replace: true
};
};
});
export default router;
+50 -1
View File
@@ -25,15 +25,64 @@ body {
text-decoration: none;
}
.link:hover:not(.button-link) {
.link:hover:not(.button-link), .link:focus:not(.button-link) {
outline: none;
text-decoration: underline;
}
.button-link {
color: var(--dark);
}
.button-link:hover {
color: var(--dark);
}
.button-link:visited {
color: var(--dark);
}
input {
color: var(--dark);
}
.input {
padding: var(--input-padding);
display: block;
border-radius: calc(var(--input-padding) + 0.5 * var(--input-height) + 1px);
height: var(--input-height);
border: 1px solid var(--border);
background: none;
transition: border-color 0.2s ease;
color: inherit;
font-family: inherit;
font-size: inherit;
}
.input:focus-visible {
border-color: var(--primary-color);
outline: none;
}
.main-content-padding {
padding: var(--main-content-padding);
width: calc(100% - var(--main-content-padding-x) * 2);
}
.button {
background-color: var(--light-bg);
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
.button:focus {
background-color: var(--light-hover);
outline: none;
}
.button:hover {
background-color: var(--light-hover);
}
+4
View File
@@ -0,0 +1,4 @@
:root {
--input-padding: 0.7em;
--input-height: 1em;
}
+62
View File
@@ -0,0 +1,62 @@
/*
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 { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createI18n } from 'vue-i18n';
import { createMemoryHistory, createRouter } from 'vue-router';
export const mountComponent = function mountComponent (component, options = {}, i18nMessages = {}, routes = [], piniaOptions = {}) {
const {
setupStores,
...testingPiniaOptions
} = piniaOptions;
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: i18nMessages
}
});
const router = createRouter({
history: createMemoryHistory(),
routes
});
const testingPinia = createTestingPinia({
...testingPiniaOptions,
stubActions: false,
createSpy: vi.fn
});
if (setupStores) {
setupStores();
}
return mount(component, {
global: {
plugins: [
i18n,
router,
testingPinia
],
},
...options
});
};