This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new d92a4805a92 UI: Fix labels that render as raw translation keys (#75326)
d92a4805a92 is described below

commit d92a4805a92c3c99bbb553a1d93c13c9cd8deb30
Author: Pushkal Gupta <[email protected]>
AuthorDate: Tue Oct 27 20:49:56 2026 +0530

    UI: Fix labels that render as raw translation keys (#75326)
    
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 .../airflow/ui/public/i18n/locales/en/browse.json  |  1 +
 .../airflow/ui/public/i18n/locales/en/common.json  |  1 +
 .../ui/public/i18n/locales/en/components.json      |  1 +
 .../src/airflow/ui/src/components/ConfigForm.tsx   |  2 +-
 .../src/airflow/ui/src/i18n/locales.test.ts        | 60 +++++++++++++++++++++-
 .../ui/src/pages/Connections/ConnectionForm.tsx    |  3 +-
 .../src/airflow/ui/src/pages/DagsList/Schedule.tsx | 14 ++---
 .../src/airflow/ui/src/pages/Pools/PoolForm.tsx    |  2 +-
 .../src/airflow/ui/src/pages/XCom/XCom.tsx         |  2 +-
 9 files changed, 74 insertions(+), 12 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
index 977b326313f..58f400c1899 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/browse.json
@@ -69,6 +69,7 @@
     "columns": {
       "dag": "Dag",
       "key": "Key",
+      "timestamp": "Timestamp",
       "value": "Value"
     },
     "delete": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 84d6d786f61..c12d426d6a6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -44,6 +44,7 @@
   "dagBundle_other": "Dag Bundles",
   "dagDetails": {
     "activeRuns": "Active Runs",
+    "bundleVersion": "Bundle Version",
     "catchup": "Catchup",
     "dagRunTimeout": "Dag Run Timeout",
     "defaultArgs": "Default Args",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 40d5f6ffff6..7de3c1683a2 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -152,6 +152,7 @@
     "empty": "No completed task instances in this range",
     "title": "Slowest task instances"
   },
+  "sort": "sort",
   "sortMultiColumnHint": "Shift-click to sort by multiple columns",
   "sortedAscending": "sorted ascending",
   "sortedDescending": "sorted descending",
diff --git a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx 
b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
index 753cea8f369..9a359bbea18 100644
--- a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
@@ -72,7 +72,7 @@ const ConfigForm = <T extends FieldValues = FieldValues>({
 
       return formattedJson;
     } catch (error) {
-      const errorMessage = error instanceof Error ? error.message : 
translate("common:error.unknown");
+      const errorMessage = error instanceof Error ? error.message : 
translate("common:error.defaultMessage");
 
       setErrors((prev) => ({
         ...prev,
diff --git a/airflow-core/src/airflow/ui/src/i18n/locales.test.ts 
b/airflow-core/src/airflow/ui/src/i18n/locales.test.ts
index c0e2cbfd094..54fc2ccd76c 100644
--- a/airflow-core/src/airflow/ui/src/i18n/locales.test.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/locales.test.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { createInstance, type Resource } from "i18next";
+import { createInstance, type i18n, type Resource } from "i18next";
 import { describe, expect, it } from "vitest";
 
 import { i18nBaseOptions } from "./config";
@@ -52,6 +52,64 @@ const localeStrings = 
Object.entries(resources).flatMap(([lng, namespaces]) =>
   flattenStrings(namespaces, lng),
 );
 
+// Every static key the UI passes to `translate(...)` has to exist in the en 
bundles, otherwise
+// i18next falls back to rendering the key itself and the user sees something 
like
+// "pools.form.nameMaxLength" where a sentence belongs.
+const sourceFiles: Record<string, string> = 
import.meta.glob("../**/*.{ts,tsx}", {
+  eager: true,
+  import: "default",
+  query: "?raw",
+});
+
+// The namespaces a file loads, in the order i18next searches them.
+const USE_TRANSLATION = 
/useTranslation\(\s*(?<names>\[[^\]]*\]|"[^"]+"|'[^']+')/gu;
+// Only literal keys; anything interpolated is resolved at runtime and cannot 
be checked here.
+const TRANSLATE_CALL = /\b(?:translate|t)\(\s*"(?<key>[^"{}`$]+)"/gu;
+const QUOTED = /["'](?<name>[^"']+)["']/gu;
+// The glob is relative to this file, so each key arrives with one leading 
"../" to strip.
+const SOURCE_PREFIX = /^\.\.\//u;
+
+const namespacesOf = (source: string): Array<string> =>
+  [...source.matchAll(USE_TRANSLATION)].flatMap((match) =>
+    [...(match.groups?.names ?? "").matchAll(QUOTED)].map((quoted) => 
quoted.groups?.name ?? ""),
+  );
+
+const findUnresolvedKeys = (file: string, source: string, instance: i18n): 
Array<string> => {
+  const namespaces = namespacesOf(source);
+
+  if (namespaces.length === 0) {
+    return [];
+  }
+
+  // i18next falls back to defaultNS, so it is always a candidate.
+  const candidates = [...new Set([...namespaces, i18nBaseOptions.defaultNS])];
+
+  const unresolved = [...source.matchAll(TRANSLATE_CALL)]
+    .map((match) => match.groups?.key ?? "")
+    .filter((key) => {
+      const prefixed = key.includes(":") ? [key] : candidates.map((ns) => 
`${ns}:${key}`);
+
+      // `count` lets i18next resolve plural-only keys such as `foo_one` / 
`foo_other`.
+      return !prefixed.some((candidate) => instance.exists(candidate, { count: 
1 }));
+    });
+
+  return unresolved.map((key) => `${file.replace(SOURCE_PREFIX, "")}: ${key}`);
+};
+
+describe("translation keys used in the UI", () => {
+  it("every static key resolves in the en locale", async () => {
+    const instance = createInstance();
+
+    await instance.init({ ...i18nBaseOptions, lng: "en", resources });
+
+    const missing = Object.entries(sourceFiles)
+      .filter(([file]) => !file.includes(".test."))
+      .flatMap(([file, source]) => findUnresolvedKeys(file, source, instance));
+
+    expect(missing).toEqual([]);
+  });
+});
+
 describe("locale counter placeholders", () => {
   it("formats every counter placeholder as a number", () => {
     const unformatted = localeStrings
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx 
b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
index 54dc6be1dbb..6e0a92e6c0d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
@@ -135,7 +135,8 @@ const ConnectionForm = ({
 
       return formattedJson;
     } catch (error_) {
-      const errorMessage = error_ instanceof Error ? error_.message : 
translate("common:error.unknown");
+      const errorMessage =
+        error_ instanceof Error ? error_.message : 
translate("common:error.defaultMessage");
 
       setErrors((prev) => ({
         ...prev,
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
index af942c5c1c4..ac60fc3065a 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
@@ -17,7 +17,6 @@
  * under the License.
  */
 import { Text } from "@chakra-ui/react";
-import { useTranslation } from "react-i18next";
 import { FiCalendar } from "react-icons/fi";
 
 import { Tooltip } from "src/system-components";
@@ -26,6 +25,10 @@ import type { ExpressionType } from 
"src/components/AssetExpression";
 
 import { AssetSchedule } from "./AssetSchedule";
 
+// Mirrors AssetTriggeredTimetable.summary on the server. It is a fixed 
literal sent as-is,
+// so it must be compared literally rather than through a translated string.
+const ASSET_TIMETABLE_SUMMARY = "Asset";
+
 type Props = {
   readonly assetExpression: ExpressionType | null | undefined;
   readonly dagId: string;
@@ -40,11 +43,9 @@ export const Schedule = ({
   timetableDescription,
   timetablePartitioned,
   timetableSummary,
-}: Props) => {
-  const { t: translate } = useTranslation("dags");
-
-  return Boolean(timetableSummary) ? (
-    Boolean(assetExpression) || timetableSummary === 
translate("schedule.asset") ? (
+}: Props) =>
+  Boolean(timetableSummary) ? (
+    Boolean(assetExpression) || timetableSummary === ASSET_TIMETABLE_SUMMARY ? 
(
       <AssetSchedule
         assetExpression={assetExpression}
         dagId={dagId}
@@ -59,4 +60,3 @@ export const Schedule = ({
       </Tooltip>
     )
   ) : undefined;
-};
diff --git a/airflow-core/src/airflow/ui/src/pages/Pools/PoolForm.tsx 
b/airflow-core/src/airflow/ui/src/pages/Pools/PoolForm.tsx
index 417449c633a..fe38d719b34 100644
--- a/airflow-core/src/airflow/ui/src/pages/Pools/PoolForm.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Pools/PoolForm.tsx
@@ -84,7 +84,7 @@ const PoolForm = ({ error, initialPool, isPending, 
manageMutate, setError }: Poo
         )}
         rules={{
           required: translate("pools.form.nameRequired"),
-          validate: (_value) => _value.length <= 256 || 
translate("pools.form.rules.nameMaxLength"),
+          validate: (_value) => _value.length <= 256 || 
translate("pools.form.nameMaxLength"),
         }}
       />
 
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx 
b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
index bbf4eba432a..118bf242aa8 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
@@ -148,7 +148,7 @@ const getColumns = ({
   {
     accessorKey: "timestamp",
     cell: ({ row: { original } }) => <Time datetime={original.timestamp} />,
-    header: translate("dashboard:timestamp"),
+    header: translate("xcom.columns.timestamp"),
   },
   {
     cell: ({ row: { original } }) => (

Reply via email to