diff --git a/airflow-core/docs/howto/customize-ui.rst b/airflow-core/docs/howto/customize-ui.rst index 3d696f52969b7..b9d03cdf94da5 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 0000000000000..ae70554cd22ee --- /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 bd4176a9fd927..7d2a944c82228 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 @@ 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 @@ def check_at_least_one_color(self) -> ThemeColors: 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 96cd4aaad266a..a511b31142b22 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 @@ 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 915e4d5430052..706cf64b492aa 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 a6cabf8c9d3f1..356802aab9fcc 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 14a88fd0ffaf9..1601e54b01a33 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 fc9c07a99b892..15b34dda2c9dc 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 }, + }), + ...(userTheme.globalCss !== undefined && { globalCss: userTheme.globalCss as Record, - } - : {}, - ); + }), + }) + : 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 3476f6a529394..da17ca505cc76 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 @@ def test_invalid_shade_key_rejected(self): 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 @@ def test_empty_colors_rejected(self): 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 dbc3c0eb64937..8b9982fc47b91 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"}, + } +} + + +@pytest.fixture +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 @@ def test_should_response_200_with_all_color_tokens(self, mock_config_data_all_co 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