diff --git a/assets/js/app.ts b/assets/js/app.ts index 023d254fc..bef8d56a0 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -6,6 +6,7 @@ import App from "./views/App.vue"; import setupRouter from "./router.ts"; import setupI18n from "./i18n.ts"; import { watchThemeChanges } from "./theme.ts"; +import { applyUrlSettings } from "./urlSettings.ts"; import { appDetection, sendToApp } from "./utils/native"; import store from "./store"; import type { Notification } from "./types/evcc"; @@ -74,6 +75,8 @@ const app = createApp( }) ); +applyUrlSettings(); + const i18n = setupI18n(); const head = createHead(); diff --git a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue index b7997c583..24f07d0ac 100644 --- a/assets/js/components/GlobalSettings/UserInterfaceSettings.vue +++ b/assets/js/components/GlobalSettings/UserInterfaceSettings.vue @@ -94,10 +94,7 @@ import { getThemePreference, setThemePreference } from "@/theme.ts"; import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units"; import { isApp } from "@/utils/native"; import { defineComponent, type PropType } from "vue"; -import { LENGTH_UNIT, THEME, type UiLoadpoint } from "@/types/evcc"; - -const TIME_12H = "12"; -const TIME_24H = "24"; +import { LENGTH_UNIT, THEME, TIME_FORMAT, type UiLoadpoint } from "@/types/evcc"; export default defineComponent({ name: "UserInterfaceSettings", @@ -110,11 +107,11 @@ export default defineComponent({ theme: getThemePreference(), language: getLocalePreference() || "", unit: getUnits(), - timeFormat: is12hFormat() ? TIME_12H : TIME_24H, + timeFormat: is12hFormat() ? TIME_FORMAT.H12 : TIME_FORMAT.H24, fullscreenActive: false, THEMES: Object.values(THEME), UNITS: Object.values(LENGTH_UNIT), - TIME_FORMATS: [TIME_24H, TIME_12H], + TIME_FORMATS: [TIME_FORMAT.H24, TIME_FORMAT.H12], }; }, computed: { @@ -139,7 +136,7 @@ export default defineComponent({ setUnits(value); }, timeFormat(value) { - set12hFormat(value === TIME_12H); + set12hFormat(value === TIME_FORMAT.H12); }, theme(value) { setThemePreference(value); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index bb8305bf5..031e5d2c8 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -289,6 +289,11 @@ export enum LENGTH_UNIT { MILES = "mi", } +export enum TIME_FORMAT { + H12 = "12", + H24 = "24", +} + export interface Loadpoint { name: string; batteryBoost: boolean; diff --git a/assets/js/urlSettings.ts b/assets/js/urlSettings.ts new file mode 100644 index 000000000..bf17ef2f7 --- /dev/null +++ b/assets/js/urlSettings.ts @@ -0,0 +1,41 @@ +import settings from "./settings"; +import { LOCALES } from "./i18n"; +import { LENGTH_UNIT, THEME, TIME_FORMAT } from "./types/evcc"; + +export function applyUrlSettings() { + const url = new URL(window.location.href); + const params = url.searchParams; + let touched = false; + + const theme = params.get("theme"); + if (theme && Object.values(THEME).includes(theme as THEME)) { + settings.theme = theme as THEME; + touched = true; + } + + const lang = params.get("lang"); + if (lang === "auto") { + settings.locale = null; + touched = true; + } else if (lang && lang in LOCALES) { + settings.locale = lang as keyof typeof LOCALES; + touched = true; + } + + const unit = params.get("unit"); + if (unit && Object.values(LENGTH_UNIT).includes(unit as LENGTH_UNIT)) { + settings.unit = unit as LENGTH_UNIT; + touched = true; + } + + const format = params.get("format"); + if (format && Object.values(TIME_FORMAT).includes(format as TIME_FORMAT)) { + settings.is12hFormat = format === TIME_FORMAT.H12; + touched = true; + } + + if (touched) { + ["theme", "lang", "unit", "format"].forEach((k) => params.delete(k)); + window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash); + } +} diff --git a/tests/url-settings.spec.ts b/tests/url-settings.spec.ts new file mode 100644 index 000000000..297d0fa90 --- /dev/null +++ b/tests/url-settings.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start(); +}); +test.afterAll(async () => { + await stop(); +}); + +test.describe("ui settings via url", async () => { + test("apply, persist and strip valid params", async ({ page }) => { + await page.goto("/?theme=dark&lang=de&unit=mi&format=12"); + + await expect(page.locator("html")).toHaveAttribute("data-bs-theme", "dark"); + await expect(page.locator("html")).toHaveAttribute("lang", "de"); + + const stored = await page.evaluate(() => ({ + theme: localStorage["settings_theme"], + locale: localStorage["settings_locale"], + unit: localStorage["settings_unit"], + format: localStorage["settings_12h_format"], + })); + expect(stored).toEqual({ theme: "dark", locale: "de", unit: "mi", format: "true" }); + + // params removed from address bar + expect(new URL(page.url()).search).toBe(""); + }); + + test("ignore invalid value and keep it in url", async ({ page }) => { + await page.goto("/?theme=bogus"); + await expect(page.locator("html")).not.toHaveAttribute("data-bs-theme", "bogus"); + expect(new URL(page.url()).searchParams.get("theme")).toBe("bogus"); + }); +});