From 7961ca28c7a50bd8a5a3eb8d1917d5f4031363c6 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Wed, 1 Oct 2025 13:03:46 -0400 Subject: [PATCH 1/2] Avoid using rem for icons for safari compatibility --- airflow-core/src/airflow/ui/eslint.config.js | 2 + airflow-core/src/airflow/ui/rules/rem.js | 150 ++++++++++++++++++ .../airflow/ui/src/components/SearchBar.tsx | 2 +- .../ui/src/layouts/Details/Grid/TaskNames.tsx | 2 +- .../ui/src/layouts/Details/PanelButtons.tsx | 2 +- .../ui/src/layouts/Nav/AdminButton.tsx | 2 +- .../ui/src/layouts/Nav/BrowseButton.tsx | 2 +- .../airflow/ui/src/layouts/Nav/DocsButton.tsx | 2 +- .../src/airflow/ui/src/layouts/Nav/Nav.tsx | 6 +- .../ui/src/layouts/Nav/PluginMenuItem.tsx | 6 +- .../ui/src/layouts/Nav/SecurityButton.tsx | 2 +- .../ui/src/layouts/Nav/TimezoneMenuItem.tsx | 2 +- .../ui/src/layouts/Nav/UserSettingsButton.tsx | 22 +-- .../pages/Dashboard/Stats/DAGImportErrors.tsx | 2 +- .../Dashboard/Stats/PluginImportErrors.tsx | 2 +- 15 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 airflow-core/src/airflow/ui/rules/rem.js diff --git a/airflow-core/src/airflow/ui/eslint.config.js b/airflow-core/src/airflow/ui/eslint.config.js index 8a9c5f67c0b36..d85f4fde83c93 100644 --- a/airflow-core/src/airflow/ui/eslint.config.js +++ b/airflow-core/src/airflow/ui/eslint.config.js @@ -27,6 +27,7 @@ import { jsoncRules } from "./rules/jsonc.js"; import { perfectionistRules } from "./rules/perfectionist.js"; import { prettierRules } from "./rules/prettier.js"; import { reactRules } from "./rules/react.js"; +import { remRules } from "./rules/rem.js"; import { stylisticRules } from "./rules/stylistic.js"; import { typescriptRules } from "./rules/typescript.js"; import { unicornRules } from "./rules/unicorn.js"; @@ -46,6 +47,7 @@ export default /** @type {const} @satisfies {ReadonlyArray} * prettierRules, reactRules, stylisticRules, + remRules, unicornRules, i18nextRules, i18nRules, diff --git a/airflow-core/src/airflow/ui/rules/rem.js b/airflow-core/src/airflow/ui/rules/rem.js new file mode 100644 index 0000000000000..404be4f3a6bf5 --- /dev/null +++ b/airflow-core/src/airflow/ui/rules/rem.js @@ -0,0 +1,150 @@ +/*! + * 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 { AST_NODE_TYPES } from "@typescript-eslint/utils"; + +export const remNamespace = "rem"; + +/** + * Check if a value contains rem units + * @param {string} value + * @returns {boolean} + */ +const containsRem = (value) => /\d+\.?\d*rem/u.test(value); + +/** + * Convert rem value to pixels (1rem = 16px) + * @param {string} value + * @returns {string} + */ +const convertRemToPixels = (value) => + value.replaceAll(/(?\d+\.?\d*)rem/gu, (_, number) => { + if (typeof number === "string") { + const pixels = parseFloat(number) * 16; + + return `${pixels}px`; + } + + return value; + }); + +export const remPlugin = { + rules: { + "no-rem-in-props": { + /** @param {import('@typescript-eslint/utils').TSESLint.RuleContext<'noRemInProps', []>} context */ + create(context) { + /** @param {import('@typescript-eslint/utils').TSESTree.JSXOpeningElement} node */ + const checkAttributes = (node) => { + // Check all attributes for rem values in size, width, height props (but not style) + node.attributes.forEach((attr) => { + if (attr.type !== AST_NODE_TYPES.JSXAttribute || !attr.value) { + return; + } + + const attrName = attr.name.name; + + // Skip style attributes - rem is allowed there + if (attrName === "style") { + return; + } + + // Only check size, width, height attributes + if (attrName !== "height" && attrName !== "size" && attrName !== "width") { + return; + } + + let attrValue = undefined; + + // Handle different attribute value types + if (attr.value.type === AST_NODE_TYPES.Literal) { + attrValue = attr.value.value; + } else if ( + attr.value.type === AST_NODE_TYPES.JSXExpressionContainer && + attr.value.expression.type === AST_NODE_TYPES.Literal + ) { + attrValue = attr.value.expression.value; + } + + // Check for rem values + if (typeof attrValue === "string" && containsRem(attrValue)) { + const fixedValue = convertRemToPixels(attrValue); + + context.report({ + data: { + attribute: attrName, + fixedValue, + value: attrValue, + }, + fix(fixer) { + // For string literals, replace the entire value + if (attr.value !== null && attr.value.type === AST_NODE_TYPES.Literal) { + return fixer.replaceText(attr.value, `{${fixedValue}}`); + } + // For JSX expressions with literal values, replace just the literal + if ( + attr.value !== null && + attr.value.type === AST_NODE_TYPES.JSXExpressionContainer && + attr.value.expression.type === AST_NODE_TYPES.Literal + ) { + return fixer.replaceText(attr.value.expression, fixedValue); + } + + // eslint-disable-next-line unicorn/no-null + return null; + }, + messageId: "noRemInProps", + node: attr, + }); + } + }); + }; + + return { + /** @param {import('@typescript-eslint/utils').TSESTree.JSXOpeningElement} node */ + JSXOpeningElement(node) { + checkAttributes(node); + }, + }; + }, + meta: { + docs: { + category: "Best Practices", + description: "Disallow rem units in size, width, and height attributes (but allow in style)", + recommended: "error", + }, + fixable: "code", + messages: { + noRemInProps: + "Avoid using rem units in {{attribute}} attribute. Use numeric pixel values instead of '{{value}}'. Auto-fix available: {{fixedValue}}", + }, + type: "problem", + }, + }, + }, +}; + +/** @type {import("@typescript-eslint/utils/ts-eslint").FlatConfig.Config} */ +export const remRules = { + files: ["**/*.tsx"], + plugins: { + [remNamespace]: remPlugin, + }, + rules: { + [`${remNamespace}/no-rem-in-props`]: "error", + }, +}; diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx index 8a9d9547b8577..18ab19f767d1b 100644 --- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx +++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx @@ -85,7 +85,7 @@ export const SearchBar = ({ /> ) : undefined} {Boolean(hideAdvanced) ? undefined : ( - )} diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx index c807458c77196..da76cfc8595b8 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx @@ -117,7 +117,7 @@ export const TaskNames = ({ nodes, onRowClick }: Props) => { px={1} > diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx index 349a270e886ed..ddc01d9db28ee 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx @@ -79,7 +79,7 @@ export const AdminButton = ({ return ( - } title={translate("nav.admin")} /> + } title={translate("nav.admin")} /> {menuItems} diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx index e24700e045172..170f89a94f5fb 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx @@ -70,7 +70,7 @@ export const BrowseButton = ({ return ( - } title={translate("nav.browse")} /> + } title={translate("nav.browse")} /> {menuItems} diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx index a33cc3724e9f3..d73b323b3e122 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx @@ -61,7 +61,7 @@ export const DocsButton = ({ return ( - } title={translate("nav.docs")} /> + } title={translate("nav.docs")} /> {links diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx index 2d843b1556c02..9e781ed17bf8c 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx @@ -151,16 +151,16 @@ export const Nav = () => { - } title={translate("nav.home")} to="/" /> + } title={translate("nav.home")} to="/" /> } + icon={} title={translate("nav.dags")} to="dags" /> } + icon={} title={translate("nav.assets")} to="assets" /> diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx index d28df1dff2342..a77ec78521e29 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx @@ -46,11 +46,11 @@ export const PluginMenuItem = ({ const displayIcon = colorMode === "dark" && typeof iconDarkMode === "string" ? iconDarkMode : icon; const pluginIcon = typeof displayIcon === "string" ? ( - + ) : urlRoute === "legacy-fab-views" ? ( - + ) : ( - + ); const isExternal = urlRoute === undefined || urlRoute === null; diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx index 38f67ddd9f631..6d9fdca68f0e2 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx @@ -36,7 +36,7 @@ export const SecurityButton = () => { return ( - } title={translate("nav.security")} /> + } title={translate("nav.security")} /> {authLinks.extra_menu_items.map(({ text }) => { diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx index 360560ca199f9..596cf79bb1ba1 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx @@ -48,7 +48,7 @@ export const TimezoneMenuItem = ({ onOpen }: { readonly onOpen: () => void }) => return ( - + {translate("timezone")}: {dayjs(time).tz(selectedTimezone).format("HH:mm z (Z)")} ); diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx index c8cc8108945a8..31e2dd0078a60 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx @@ -67,21 +67,21 @@ export const UserSettingsButton = ({ externalViews }: { readonly externalViews: return ( - } title={translate("user")} /> + } title={translate("user")} /> - + {translate("selectLanguage")} - + {translate("appearance.appearance")} {isRTL ? ( - + ) : ( - + )} @@ -90,17 +90,17 @@ export const UserSettingsButton = ({ externalViews }: { readonly externalViews: value={theme} > - + {translate("appearance.lightMode")} - + {translate("appearance.darkMode")} - + {translate("appearance.systemMode")} @@ -113,12 +113,12 @@ export const UserSettingsButton = ({ externalViews }: { readonly externalViews: > {dagView === "grid" ? ( <> - + {translate("defaultToGraphView")} ) : ( <> - + {translate("defaultToGridView")} )} @@ -129,7 +129,7 @@ export const UserSettingsButton = ({ externalViews }: { readonly externalViews: ))} {translate("logout")} diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx index e6fc9d1f3e267..87b9bf917e0c6 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx @@ -55,7 +55,7 @@ export const DAGImportErrors = ({ iconOnly = false }: { readonly iconOnly?: bool onClick={onOpen} title={translate("importErrors.dagImportError", { count: importErrorsCount })} > - + {importErrorsCount} ) : ( diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/PluginImportErrors.tsx b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/PluginImportErrors.tsx index cea92bed6d794..ed9643c35c1da 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/PluginImportErrors.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/PluginImportErrors.tsx @@ -58,7 +58,7 @@ export const PluginImportErrors = ({ iconOnly = false }: { readonly iconOnly?: b onClick={onOpen} title={translate("plugins.importError", { count: importErrorsCount })} > - + {importErrorsCount} ) : ( From 1e77be6cbca48d11792add2e66ebcd5ee2666ff7 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Wed, 1 Oct 2025 13:11:02 -0400 Subject: [PATCH 2/2] Fix poorly sized custom icon --- airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx | 6 +++--- .../src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx index 9e781ed17bf8c..b313a4ff63484 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx @@ -151,16 +151,16 @@ export const Nav = () => { - } title={translate("nav.home")} to="/" /> + } title={translate("nav.home")} to="/" /> } + icon={} title={translate("nav.dags")} to="dags" /> } + icon={} title={translate("nav.assets")} to="assets" /> diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx index a77ec78521e29..c7372703237a5 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx @@ -46,11 +46,11 @@ export const PluginMenuItem = ({ const displayIcon = colorMode === "dark" && typeof iconDarkMode === "string" ? iconDarkMode : icon; const pluginIcon = typeof displayIcon === "string" ? ( - + ) : urlRoute === "legacy-fab-views" ? ( - + ) : ( - + ); const isExternal = urlRoute === undefined || urlRoute === null;