This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new f2495b85ce8 [v3-3-test] UI: Translate durations and relative times in
the selected language (#72309) (#72334)
f2495b85ce8 is described below
commit f2495b85ce81e95cd4319d047a834bcf65e6cebe
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Aug 31 15:05:21 2026 -0400
[v3-3-test] UI: Translate durations and relative times in the selected
language (#72309) (#72334)
dayjs keeps one global locale and bundles only English, and the UI never
told it which language i18next had resolved. Sentences assembled from both
catalogues came out half translated: a deadline tooltip read "Must complete
within 2 hours of queue time" with everything but the interval in Arabic,
and every "Next Run" relative time stayed English too. No translation key
was missing, so the completeness checks could never surface it.
The locale data is registered eagerly rather than imported on demand
because react-i18next re-renders synchronously on languageChanged; a
dynamic import would resolve after that render and leave the previous
language's durations on screen.
(cherry picked from commit 7818b16048cdfa03d1b4b4cefc64fef226ae9b7c)
Co-authored-by: Baha Bouali <[email protected]>
---
.pre-commit-config.yaml | 1 +
airflow-core/src/airflow/ui/src/i18n/config.ts | 6 ++
.../src/airflow/ui/src/i18n/dayjsLocale.test.ts | 86 +++++++++++++++++++++
.../src/airflow/ui/src/i18n/dayjsLocale.ts | 90 ++++++++++++++++++++++
4 files changed, 183 insertions(+)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b5025cf01c0..ede8f23230e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -771,6 +771,7 @@ repos:
^providers/common/ai/src/airflow/providers/common/ai/plugins/www/pnpm-lock\.yaml$|
^airflow-core/src/airflow/ui/public/i18n/locales/de/README\.md$|
^airflow-core/src/airflow/ui/src/i18n/config\.ts$|
+ ^airflow-core/src/airflow/ui/src/i18n/dayjsLocale\.ts$|
^\.agents/skills/airflow-translations/|
^\.agents/skills/magpie-setup/|
^airflow-core/src/airflow/utils/db\.py$|
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 8b4ce747c6e..d9e7fae2ee1 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -23,6 +23,8 @@ import { initReactI18next } from "react-i18next";
import { VersionService } from "openapi/requests/services.gen";
+import { registerDayjsLocaleSync } from "./dayjsLocale";
+
export const supportedLanguages = [
{ code: "en", name: "English" },
{ code: "ar", name: "العربية" },
@@ -132,6 +134,10 @@ export const i18nBaseOptions = {
const initI18n = (version: string) => {
const queryString = version ? `?v=${version}` : "";
+ // Subscribed before init so it precedes every react-i18next component
listener, and
+ // because i18next emits `languageChanged` from init itself for the detected
language.
+ registerDayjsLocaleSync(i18n);
+
void i18n
.use(Backend)
.use(LanguageDetector)
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
new file mode 100644
index 00000000000..892f189a852
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.test.ts
@@ -0,0 +1,86 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 dayjs from "dayjs";
+import dayjsDuration from "dayjs/plugin/duration";
+import relativeTime from "dayjs/plugin/relativeTime";
+import { createInstance } from "i18next";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { i18nBaseOptions, supportedLanguages } from "./config";
+import { dayjsLocaleCodes, registerDayjsLocaleSync, syncDayjsLocale } from
"./dayjsLocale";
+
+dayjs.extend(dayjsDuration);
+dayjs.extend(relativeTime);
+
+const humanizeTwoHours = () => dayjs.duration(2, "hours").humanize();
+
+// dayjs keeps the locale in module-global state, so a test that switched it
would
+// otherwise decide what every later test formats.
+afterEach(() => {
+ dayjs.locale("en");
+});
+
+describe("dayjs locale", () => {
+ it("covers exactly the languages the UI offers", () => {
+ expect([...dayjsLocaleCodes].sort()).toStrictEqual(
+ supportedLanguages.map((language) => language.code).sort(),
+ );
+ });
+
+ it.each(supportedLanguages.filter((language) => language.code !== "en"))(
+ "humanizes a duration in $code rather than English",
+ ({ code }) => {
+ syncDayjsLocale(code);
+
+ expect(humanizeTwoHours()).not.toBe("2 hours");
+ },
+ );
+
+ it("renders the reported Arabic case", () => {
+ syncDayjsLocale("ar");
+
+ expect(humanizeTwoHours()).toBe("2 ساعات");
+ });
+
+ it("falls back to English for a language dayjs does not ship", () => {
+ syncDayjsLocale("ar");
+ syncDayjsLocale("cy");
+
+ expect(humanizeTwoHours()).toBe("2 hours");
+ });
+
+ it("applies the language i18next resolves during init", async () => {
+ const instance = createInstance();
+
+ registerDayjsLocaleSync(instance);
+ await instance.init({ ...i18nBaseOptions, lng: "ar", resources: {} });
+
+ expect(humanizeTwoHours()).toBe("2 ساعات");
+ });
+
+ it("follows a later language switch", async () => {
+ const instance = createInstance();
+
+ registerDayjsLocaleSync(instance);
+ await instance.init({ ...i18nBaseOptions, lng: "ar", resources: {} });
+ await instance.changeLanguage("zh-CN");
+
+ expect(humanizeTwoHours()).toBe("2 小时");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
new file mode 100644
index 00000000000..223606a3fb5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/dayjsLocale.ts
@@ -0,0 +1,90 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 dayjs from "dayjs";
+import "dayjs/locale/ar";
+import "dayjs/locale/ca";
+import "dayjs/locale/de";
+import "dayjs/locale/el";
+import "dayjs/locale/es";
+import "dayjs/locale/fr";
+import "dayjs/locale/he";
+import "dayjs/locale/hi";
+import "dayjs/locale/hu";
+import "dayjs/locale/it";
+import "dayjs/locale/ja";
+import "dayjs/locale/ko";
+import "dayjs/locale/nl";
+import "dayjs/locale/pl";
+import "dayjs/locale/pt";
+import "dayjs/locale/ru";
+import "dayjs/locale/th";
+import "dayjs/locale/tr";
+import "dayjs/locale/zh-cn";
+import "dayjs/locale/zh-tw";
+import type { i18n as I18nInstance } from "i18next";
+
+// dayjs holds a single global locale and bundles only `en`, so `.humanize()`
and
+// `.fromNow()` rendered English durations ("2 hours") inside otherwise
translated
+// sentences. The locale data is registered eagerly rather than fetched per
language:
+// a dynamic import resolves after react-i18next has already re-rendered on
+// `languageChanged`, which would leave the previous language's durations on
screen
+// until something else triggered a render. The twenty files cost ~7.6 kB
gzipped.
+//
+// Keys are i18next codes from `supportedLanguages`; values are dayjs locale
names,
+// which are lower-cased and so differ for the Chinese variants. `pt` maps to
European
+// Portuguese because that is what the bare `pt` code asks for.
+const DAYJS_LOCALES: Record<string, string> = {
+ ar: "ar",
+ ca: "ca",
+ de: "de",
+ el: "el",
+ en: "en",
+ es: "es",
+ fr: "fr",
+ he: "he",
+ hi: "hi",
+ hu: "hu",
+ it: "it",
+ ja: "ja",
+ ko: "ko",
+ nl: "nl",
+ pl: "pl",
+ pt: "pt",
+ ru: "ru",
+ th: "th",
+ tr: "tr",
+ "zh-CN": "zh-cn",
+ "zh-TW": "zh-tw",
+};
+
+export const dayjsLocaleCodes = Object.keys(DAYJS_LOCALES);
+
+const FALLBACK_DAYJS_LOCALE = "en";
+
+export const syncDayjsLocale = (language: string): void => {
+ dayjs.locale(DAYJS_LOCALES[language] ?? FALLBACK_DAYJS_LOCALE);
+};
+
+// Register this on i18next before `init()`: the emitter runs `languageChanged`
+// callbacks in subscription order and react-i18next subscribes each component
as it
+// mounts, so subscribing first is what guarantees dayjs has switched by the
time any
+// component re-renders in the new language.
+export const registerDayjsLocaleSync = (instance: I18nInstance): void => {
+ instance.on("languageChanged", syncDayjsLocale);
+};