Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"columns": {
"dag": "Dag",
"key": "Key",
"timestamp": "Timestamp",
"value": "Value"
},
"delete": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"dagBundle_other": "Dag Bundles",
"dagDetails": {
"activeRuns": "Active Runs",
"bundleVersion": "Bundle Version",
"catchup": "Catchup",
"dagRunTimeout": "Dag Run Timeout",
"defaultArgs": "Default Args",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
60 changes: 59 additions & 1 deletion airflow-core/src/airflow/ui/src/i18n/locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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}
Expand All @@ -59,4 +60,3 @@ export const Schedule = ({
</Tooltip>
)
) : undefined;
};
2 changes: 1 addition & 1 deletion airflow-core/src/airflow/ui/src/pages/Pools/PoolForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}}
/>

Expand Down
2 changes: 1 addition & 1 deletion airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }) => (
Expand Down
Loading