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 } }) => (