This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-2-test by this push:
new d736ea7450a [v3-2-test] Allow UI theme config without tokens (CSS-only
or empty fallback) (#64552) (#64852)
d736ea7450a is described below
commit d736ea7450a608af0e0400eb87ed71bc0be4cf92
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Apr 7 13:51:38 2026 -0400
[v3-2-test] Allow UI theme config without tokens (CSS-only or empty
fallback) (#64552) (#64852)
* Add .worktrees/ to .gitignore
* Make Theme tokens field optional to allow CSS-only or empty theme configs
* Add integration test: CSS-only theme passes config endpoint validation
* Docs: note that Theme tokens is optional, empty config restores OSS
defaults
* Improve CSS-only theme test: also assert icon fields absent from response
* Add newsfragment for optional theme tokens
* Rename newsfragment to PR #64552
* Fix TypeScript errors in createTheme for optional tokens support
The API schema for Theme became opaque ({[key: string]: unknown}) after
@model_serializer was added to Theme in Python to exclude None values.
This broke theme.ts in two ways:
- userTheme.tokens typed as unknown, not assignable to TokenDefinition
- mergeConfigs() called with undefined, which it doesn't accept
Fix by conditionally spreading tokens/globalCss (guarding for CSS-only
themes) and casting to Record<string, unknown> which is assignable to
TokenDefinition. Use conditional mergeConfigs call to avoid passing
undefined.
* Fix Theme OpenAPI schema collapsing to untyped object
The @model_serializer(mode="wrap") on Theme and ThemeColors caused
Pydantic to generate additionalProperties:true for both models in the
OpenAPI spec, losing typed fields (tokens, globalCss, icon) and
breaking TypeScript types to {[key: string]: unknown}.
Fix: remove wrap serializers from Theme/ThemeColors; add
@field_serializer("theme") to ConfigResponse with
model_dump(exclude_none=True)
to preserve None-exclusion behavior scoped to the theme field only.
Add ConfigDict(json_schema_mode_override="validation") to ConfigResponse
so schema generation uses type annotations rather than the field
serializer's dict return type, keeping the $ref: Theme link intact.
Also removes unrelated .worktrees/ entry from .gitignore.
(cherry picked from commit a06896fe1de2213b5c1c2d07c8310f4a85ea507c)
Co-authored-by: Constance Martineau <[email protected]>
---
airflow-core/docs/howto/customize-ui.rst | 1 +
airflow-core/newsfragments/64552.improvement.rst | 1 +
.../src/airflow/api_fastapi/common/types.py | 8 +-
.../api_fastapi/core_api/datamodels/ui/config.py | 10 ++
.../api_fastapi/core_api/openapi/_private_ui.yaml | 85 ++++++++++++++--
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 109 +++++++++++++++++++--
.../airflow/ui/openapi-gen/requests/types.gen.ts | 26 ++++-
airflow-core/src/airflow/ui/src/theme.ts | 20 ++--
.../tests/unit/api_fastapi/common/test_types.py | 31 +++++-
.../api_fastapi/core_api/routes/ui/test_config.py | 24 +++++
10 files changed, 277 insertions(+), 38 deletions(-)
diff --git a/airflow-core/docs/howto/customize-ui.rst
b/airflow-core/docs/howto/customize-ui.rst
index 3d696f52969..b9d03cdf94d 100644
--- a/airflow-core/docs/howto/customize-ui.rst
+++ b/airflow-core/docs/howto/customize-ui.rst
@@ -71,6 +71,7 @@ We can provide a JSON configuration to customize the UI.
.. important::
- You can customize the ``brand``, ``gray``, ``black``, and ``white`` color
tokens, ``globalCss``, and the navigation icon via ``icon`` (and
``icon_dark_mode``).
+ - All top-level fields (``tokens``, ``globalCss``, ``icon``,
``icon_dark_mode``) are **optional** — you can supply any combination,
including an empty ``{}`` to restore OSS defaults.
- All color tokens are **optional** — you can override any subset without
supplying the others.
- ``brand`` and ``gray`` each accept an 11-shade scale with keys
``50``–``950``.
- ``black`` and ``white`` each accept a single color: ``{ "value":
"oklch(...)" }``.
diff --git a/airflow-core/newsfragments/64552.improvement.rst
b/airflow-core/newsfragments/64552.improvement.rst
new file mode 100644
index 00000000000..ae70554cd22
--- /dev/null
+++ b/airflow-core/newsfragments/64552.improvement.rst
@@ -0,0 +1 @@
+Allow UI theme config with only CSS overrides, icon only, or empty ``{}`` to
restore OSS defaults. The ``tokens`` field is now optional in the theme
configuration.
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py
b/airflow-core/src/airflow/api_fastapi/common/types.py
index bd4176a9fd9..7d2a944c822 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -20,7 +20,7 @@ import re
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
-from typing import Annotated, Any, Literal
+from typing import Annotated, Literal
from pydantic import (
AfterValidator,
@@ -208,15 +208,11 @@ class ThemeColors(BaseModel):
raise ValueError("At least one color token must be provided:
brand, gray, black, or white")
return self
- @model_serializer(mode="wrap")
- def serialize_model(self, handler: Any) -> dict:
- return {k: v for k, v in handler(self).items() if v is not None}
-
class Theme(BaseModel):
"""JSON to modify Chakra's theme."""
- tokens: dict[Literal["colors"], ThemeColors]
+ tokens: dict[Literal["colors"], ThemeColors] | None = None
globalCss: dict[str, dict] | None = None
icon: ThemeIconType = None
icon_dark_mode: ThemeIconType = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
index 96cd4aaad26..a511b31142b 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/config.py
@@ -16,6 +16,8 @@
# under the License.
from __future__ import annotations
+from pydantic import ConfigDict, field_serializer
+
from airflow.api_fastapi.common.types import Theme, UIAlert
from airflow.api_fastapi.core_api.base import BaseModel
@@ -23,6 +25,8 @@ from airflow.api_fastapi.core_api.base import BaseModel
class ConfigResponse(BaseModel):
"""configuration serializer."""
+ model_config = ConfigDict(json_schema_mode_override="validation")
+
fallback_page_limit: int
auto_refresh_interval: int
hide_paused_dags_by_default: bool
@@ -36,3 +40,9 @@ class ConfigResponse(BaseModel):
external_log_name: str | None = None
theme: Theme | None
multi_team: bool
+
+ @field_serializer("theme")
+ def serialize_theme(self, theme: Theme | None) -> dict | None:
+ if theme is None:
+ return None
+ return theme.model_dump(exclude_none=True)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 915e4d54300..706cf64b492 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -3272,11 +3272,13 @@ components:
Theme:
properties:
tokens:
- additionalProperties:
- $ref: '#/components/schemas/ThemeColors'
- propertyNames:
- const: colors
- type: object
+ anyOf:
+ - additionalProperties:
+ $ref: '#/components/schemas/ThemeColors'
+ propertyNames:
+ const: colors
+ type: object
+ - type: 'null'
title: Tokens
globalCss:
anyOf:
@@ -3297,13 +3299,80 @@ components:
- type: 'null'
title: Icon Dark Mode
type: object
- required:
- - tokens
title: Theme
description: JSON to modify Chakra's theme.
ThemeColors:
- additionalProperties: true
+ properties:
+ brand:
+ anyOf:
+ - additionalProperties:
+ additionalProperties:
+ $ref: '#/components/schemas/OklchColor'
+ propertyNames:
+ const: value
+ type: object
+ propertyNames:
+ enum:
+ - '50'
+ - '100'
+ - '200'
+ - '300'
+ - '400'
+ - '500'
+ - '600'
+ - '700'
+ - '800'
+ - '900'
+ - '950'
+ type: object
+ - type: 'null'
+ title: Brand
+ gray:
+ anyOf:
+ - additionalProperties:
+ additionalProperties:
+ $ref: '#/components/schemas/OklchColor'
+ propertyNames:
+ const: value
+ type: object
+ propertyNames:
+ enum:
+ - '50'
+ - '100'
+ - '200'
+ - '300'
+ - '400'
+ - '500'
+ - '600'
+ - '700'
+ - '800'
+ - '900'
+ - '950'
+ type: object
+ - type: 'null'
+ title: Gray
+ black:
+ anyOf:
+ - additionalProperties:
+ $ref: '#/components/schemas/OklchColor'
+ propertyNames:
+ const: value
+ type: object
+ - type: 'null'
+ title: Black
+ white:
+ anyOf:
+ - additionalProperties:
+ $ref: '#/components/schemas/OklchColor'
+ propertyNames:
+ const: value
+ type: object
+ - type: 'null'
+ title: White
type: object
+ title: ThemeColors
+ description: Color tokens for the UI theme. All fields are optional; at
least
+ one must be provided.
TokenType:
type: string
enum:
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index a6cabf8c9d3..356802aab9f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8895,13 +8895,20 @@ export const $TeamResponse = {
export const $Theme = {
properties: {
tokens: {
- additionalProperties: {
- '$ref': '#/components/schemas/ThemeColors'
- },
- propertyNames: {
- const: 'colors'
- },
- type: 'object',
+ anyOf: [
+ {
+ additionalProperties: {
+ '$ref': '#/components/schemas/ThemeColors'
+ },
+ propertyNames: {
+ const: 'colors'
+ },
+ type: 'object'
+ },
+ {
+ type: 'null'
+ }
+ ],
title: 'Tokens'
},
globalCss: {
@@ -8943,14 +8950,96 @@ export const $Theme = {
}
},
type: 'object',
- required: ['tokens'],
title: 'Theme',
description: "JSON to modify Chakra's theme."
} as const;
export const $ThemeColors = {
- additionalProperties: true,
- type: 'object'
+ properties: {
+ brand: {
+ anyOf: [
+ {
+ additionalProperties: {
+ additionalProperties: {
+ '$ref': '#/components/schemas/OklchColor'
+ },
+ propertyNames: {
+ const: 'value'
+ },
+ type: 'object'
+ },
+ propertyNames: {
+ enum: ['50', '100', '200', '300', '400', '500', '600',
'700', '800', '900', '950']
+ },
+ type: 'object'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Brand'
+ },
+ gray: {
+ anyOf: [
+ {
+ additionalProperties: {
+ additionalProperties: {
+ '$ref': '#/components/schemas/OklchColor'
+ },
+ propertyNames: {
+ const: 'value'
+ },
+ type: 'object'
+ },
+ propertyNames: {
+ enum: ['50', '100', '200', '300', '400', '500', '600',
'700', '800', '900', '950']
+ },
+ type: 'object'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Gray'
+ },
+ black: {
+ anyOf: [
+ {
+ additionalProperties: {
+ '$ref': '#/components/schemas/OklchColor'
+ },
+ propertyNames: {
+ const: 'value'
+ },
+ type: 'object'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Black'
+ },
+ white: {
+ anyOf: [
+ {
+ additionalProperties: {
+ '$ref': '#/components/schemas/OklchColor'
+ },
+ propertyNames: {
+ const: 'value'
+ },
+ type: 'object'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'White'
+ }
+ },
+ type: 'object',
+ title: 'ThemeColors',
+ description: 'Color tokens for the UI theme. All fields are optional; at
least one must be provided.'
} as const;
export const $TokenType = {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 14a88fd0ffa..1601e54b01a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2199,9 +2199,9 @@ export type TeamResponse = {
* JSON to modify Chakra's theme.
*/
export type Theme = {
- tokens: {
- [key: string]: ThemeColors;
- };
+ tokens?: {
+ [key: string]: ThemeColors;
+} | null;
globalCss?: {
[key: string]: {
[key: string]: unknown;
@@ -2211,8 +2211,26 @@ export type Theme = {
icon_dark_mode?: string | null;
};
+/**
+ * Color tokens for the UI theme. All fields are optional; at least one must
be provided.
+ */
export type ThemeColors = {
- [key: string]: unknown;
+ brand?: {
+ [key: string]: {
+ [key: string]: OklchColor;
+ };
+} | null;
+ gray?: {
+ [key: string]: {
+ [key: string]: OklchColor;
+ };
+} | null;
+ black?: {
+ [key: string]: OklchColor;
+} | null;
+ white?: {
+ [key: string]: OklchColor;
+} | null;
};
/**
diff --git a/airflow-core/src/airflow/ui/src/theme.ts
b/airflow-core/src/airflow/ui/src/theme.ts
index fc9c07a99b8..15b34dda2c9 100644
--- a/airflow-core/src/airflow/ui/src/theme.ts
+++ b/airflow-core/src/airflow/ui/src/theme.ts
@@ -406,16 +406,20 @@ const defaultAirflowTheme = {
export const createTheme = (userTheme?: Theme) => {
const defaultAirflowConfig = defineConfig({ theme: defaultAirflowTheme });
- const userConfig = defineConfig(
- userTheme
- ? {
- theme: { tokens: userTheme.tokens },
+ const userConfig = userTheme
+ ? defineConfig({
+ ...(userTheme.tokens !== undefined && {
+ theme: { tokens: userTheme.tokens as Record<string, unknown> },
+ }),
+ ...(userTheme.globalCss !== undefined && {
globalCss: userTheme.globalCss as Record<string, SystemStyleObject>,
- }
- : {},
- );
+ }),
+ })
+ : undefined;
- const mergedConfig = mergeConfigs(defaultConfig, defaultAirflowConfig,
userConfig);
+ const mergedConfig = userConfig
+ ? mergeConfigs(defaultConfig, defaultAirflowConfig, userConfig)
+ : mergeConfigs(defaultConfig, defaultAirflowConfig);
return createSystem(mergedConfig);
};
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_types.py
b/airflow-core/tests/unit/api_fastapi/common/test_types.py
index 3476f6a5293..da17ca505cc 100644
--- a/airflow-core/tests/unit/api_fastapi/common/test_types.py
+++ b/airflow-core/tests/unit/api_fastapi/common/test_types.py
@@ -147,7 +147,7 @@ class TestThemeColors:
def test_serialization_excludes_none_fields(self):
colors = ThemeColors.model_validate({"brand": _BRAND_SCALE})
- dumped = colors.model_dump()
+ dumped = colors.model_dump(exclude_none=True)
assert "brand" in dumped
assert "gray" not in dumped
assert "black" not in dumped
@@ -200,10 +200,37 @@ class TestTheme:
def test_serialization_round_trip(self):
"""Verify None color fields are excluded and OklchColor values are
serialized as strings."""
theme = Theme.model_validate({"tokens": {"colors": {"brand":
_BRAND_SCALE}}})
- dumped = theme.model_dump()
+ dumped = theme.model_dump(exclude_none=True)
colors = dumped["tokens"]["colors"]
assert "brand" in colors
assert "gray" not in colors
assert "black" not in colors
assert "white" not in colors
assert colors["brand"]["50"]["value"] == "oklch(0.975 0.007 298.0)"
+
+ def test_globalcss_only_theme(self):
+ """tokens is optional; globalCss alone is sufficient."""
+ theme = Theme.model_validate({"globalCss": {"button":
{"text-transform": "uppercase"}}})
+ assert theme.tokens is None
+ assert theme.globalCss == {"button": {"text-transform": "uppercase"}}
+
+ def test_icon_only_theme(self):
+ """tokens is optional; an icon URL alone is sufficient."""
+ theme = Theme.model_validate({"icon": "https://example.com/logo.svg"})
+ assert theme.tokens is None
+ assert theme.icon == "https://example.com/logo.svg"
+
+ def test_empty_theme(self):
+ """An empty theme object is valid — it means 'use OSS defaults'."""
+ theme = Theme.model_validate({})
+ assert theme.tokens is None
+ assert theme.globalCss is None
+ assert theme.icon is None
+ assert theme.icon_dark_mode is None
+
+ def test_theme_serialization_excludes_none_tokens(self):
+ """When tokens is None it must not appear in the serialized output."""
+ theme = Theme.model_validate({"globalCss": {"a": {"color": "red"}}})
+ dumped = theme.model_dump(exclude_none=True)
+ assert "tokens" not in dumped
+ assert dumped == {"globalCss": {"a": {"color": "red"}}}
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
index dbc3c0eb649..8b9982fc47b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_config.py
@@ -136,6 +136,19 @@ def mock_config_data_all_colors():
yield
+THEME_CSS_ONLY = {
+ "globalCss": {
+ "button": {"text-transform": "uppercase"},
+ }
+}
+
+
[email protected]
+def mock_config_data_css_only():
+ with conf_vars(_theme_conf_vars(THEME_CSS_ONLY)):
+ yield
+
+
class TestGetConfig:
def test_should_response_200(self, mock_config_data, test_client):
"""
@@ -170,3 +183,14 @@ class TestGetConfig:
assert "white" in colors
assert colors["black"] == {"value": "oklch(0.22 0.025 288.6)"}
assert colors["white"] == {"value": "oklch(0.985 0.002 264.0)"}
+
+ def test_should_response_200_with_css_only_theme(self,
mock_config_data_css_only, test_client):
+ """Theme with only globalCss (no tokens) is valid and round-trips
correctly."""
+ response = test_client.get("/config")
+
+ assert response.status_code == 200
+ theme = response.json()["theme"]
+ assert "tokens" not in theme
+ assert theme["globalCss"] == {"button": {"text-transform":
"uppercase"}}
+ assert "icon" not in theme
+ assert "icon_dark_mode" not in theme