Compare commits

..

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
0fdc637533 feat(themes): Add UUID reference system for dynamic theme configuration
Implements a UUID-based reference system that enables Superset themes to be
dynamically configured through external systems like Split.io, overcoming
payload size limitations.

## Problem
Split.io has a 1KB limit for JSON payloads, but Superset theme configurations
typically exceed 3KB. This prevented teams from managing themes dynamically
through feature flags without deployments.

## Solution
Instead of storing full theme objects, store lightweight UUID references:
- Before: `{"algorithm": "dark", "token": {...}, ...}` (3KB+)
- After: `{"uuid": "a7f3c8e2-4d1b-4c7a-9f8e-2b5d6c8a9e1f"}` (<100 bytes)

## Implementation Details

### Backend Changes
- **ResolveAndUpsertThemeCommand**: New command that resolves UUID references
  to full theme configurations and upserts them as system themes
- **Enhanced bootstrap data**: Modified `get_theme_bootstrap_data()` to
  dynamically resolve UUID references on every page load
- **Fallback support**: Graceful degradation to safe defaults if UUID
  resolution fails (empty object for default theme, dark algorithm for dark theme)

### Frontend Changes
- **UUID display in Theme Modal**: Added read-only UUID field with copy-to-clipboard
  functionality using existing CopyToClipboard component
- **Minimal styling**: Uses Label component with monospace font, following
  existing patterns

### How It Works
1. Store themes in Superset's Theme CRUD system (each gets a UUID)
2. Reference themes by UUID in Split.io or other configuration systems
3. On page load, the system:
   - Detects UUID references in theme configuration
   - Resolves them to full theme definitions from the database
   - Upserts as system themes for consistency
   - Falls back to safe defaults on errors

### Testing
Comprehensive test coverage including:
- UUID resolution scenarios (found, not found, invalid JSON)
- System theme upsert behavior (create new, update existing)
- Fallback configurations for different theme types
- UUID precedence over inline configuration

This architecture transforms Split.io's constraint into a feature, enabling
truly dynamic theme management with instant updates and no deployment required.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 13:51:09 -07:00
42 changed files with 523 additions and 494 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v2
uses: actions/first-interaction@v1
continue-on-error: true
with:
repo-token: ${{ github.token }}

View File

@@ -28,9 +28,6 @@ const globals = require('globals');
const { defineConfig, globalIgnores } = require('eslint/config');
module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
},
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
js.configs.recommended,
...ts.configs.recommended,
@@ -39,7 +36,7 @@ module.exports = defineConfig([
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
}
},
{
languageOptions: {
@@ -71,5 +68,5 @@ module.exports = defineConfig([
version: 'detect',
},
},
},
]);
}
])

View File

@@ -15,7 +15,7 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"eslint": "eslint ."
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
@@ -26,33 +26,33 @@
"@emotion/styled": "^10.0.27",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@superset-ui/style": "^0.14.23",
"antd": "^5.26.7",
"antd": "^5.26.3",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.4.0",
"less": "^4.3.0",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-svg-pan-zoom": "^3.13.1",
"swagger-ui-react": "^5.27.1"
"swagger-ui-react": "^5.26.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/tsconfig": "^3.8.1",
"@eslint/js": "^9.32.0",
"@eslint/js": "^9.31.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.8",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"typescript-eslint": "^8.37.0",
"webpack": "^5.101.0"
"webpack": "^5.99.9"
},
"browserslist": {
"production": [

View File

@@ -2205,16 +2205,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.31.0":
"@eslint/js@9.31.0", "@eslint/js@^9.31.0":
version "9.31.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.31.0.tgz#adb1f39953d8c475c4384b67b67541b0d7206ed8"
integrity sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==
"@eslint/js@^9.32.0":
version "9.32.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.32.0.tgz#a02916f58bd587ea276876cb051b579a3d75d091"
integrity sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==
"@eslint/object-schema@^2.1.6":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
@@ -2517,10 +2512,10 @@
classnames "^2.3.2"
rc-util "^5.24.4"
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.3.0.tgz#9499ada078daca9dd99d01f0f0743ee1ab9e398b"
integrity sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.2.7":
version "2.2.7"
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.2.7.tgz#a2b97ecbb93280a3c424e51fa415b371b355d76a"
integrity sha512-Qggj4Z0AA2i5dJhzlfFSmg1Qrziu8dsdHOihROL5Kl18seO2Eh/ZaTYt2c8a/CyGaTChnFry7BEYew1+/fhSbA==
dependencies:
"@babel/runtime" "^7.23.2"
"@rc-component/portal" "^1.1.0"
@@ -3427,10 +3422,10 @@
dependencies:
"@types/estree" "*"
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
version "5.0.6"
@@ -3971,11 +3966,6 @@ accepts@~1.3.4, accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-import-phases@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
acorn-jsx@^5.0.0, acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -3988,7 +3978,12 @@ acorn-walk@^8.0.0:
dependencies:
acorn "^8.11.0"
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.8.2:
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.8.2:
version "8.14.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb"
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
acorn@^8.15.0:
version "8.15.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -4112,10 +4107,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.26.7:
version "5.26.7"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.7.tgz#e2f7e37330b27eec0de7a7789767975373f61602"
integrity sha512-iCyXN6+i2CUVEOSzzJKfbKeg115qoJhGvSkCh5uzAf9hANwHUOJQhsMn+KtN+Lx/2NQ6wfM7nGZ+7NPNO5Pn1w==
antd@^5.26.3:
version "5.26.3"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.3.tgz#cbbb7e1b48a972dc7b6ee8b6948f51cc91c263f8"
integrity sha512-M/s9Q39h/+G7AWnS6fbNxmAI9waTH4ti022GVEXBLq2j810V1wJ3UOQps13nEilzDNcyxnFN/EIbqIgS7wSYaA==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -4128,7 +4123,7 @@ antd@^5.26.7:
"@rc-component/mutate-observer" "^1.1.0"
"@rc-component/qrcode" "~1.0.0"
"@rc-component/tour" "~1.15.1"
"@rc-component/trigger" "^2.3.0"
"@rc-component/trigger" "^2.2.7"
classnames "^2.5.1"
copy-to-clipboard "^3.3.3"
dayjs "^1.11.11"
@@ -4158,7 +4153,7 @@ antd@^5.26.7:
rc-switch "~4.1.0"
rc-table "~7.51.1"
rc-tabs "~15.6.1"
rc-textarea "~1.10.1"
rc-textarea "~1.10.0"
rc-tooltip "~6.4.0"
rc-tree "~5.13.1"
rc-tree-select "~5.27.0"
@@ -4513,7 +4508,17 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.25.0:
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4:
version "4.24.4"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b"
integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==
dependencies:
caniuse-lite "^1.0.30001688"
electron-to-chromium "^1.5.73"
node-releases "^2.0.19"
update-browserslist-db "^1.1.1"
browserslist@^4.25.0:
version "4.25.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c"
integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==
@@ -4615,7 +4620,7 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702:
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001714"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz#cfd27ff07e6fa20a0f45c7a10d28a0ffeaba2122"
integrity sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==
@@ -5898,6 +5903,11 @@ electron-to-chromium@^1.5.160:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz#9f6697de4339e24da8b234e4492a9ecb91f5989c"
integrity sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==
electron-to-chromium@^1.5.73:
version "1.5.138"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.138.tgz#319e775179bd0889ed96a04d4390d355fb315a44"
integrity sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -5942,10 +5952,10 @@ encodeurl@~2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
enhanced-resolve@^5.17.2:
version "5.18.2"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464"
integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==
enhanced-resolve@^5.17.1:
version "5.18.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf"
integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -6151,10 +6161,10 @@ escape-string-regexp@^5.0.0:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
eslint-config-prettier@^10.1.8:
version "10.1.8"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-config-prettier@^10.1.5:
version "10.1.5"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
eslint-plugin-prettier@^5.5.1:
version "5.5.1"
@@ -8053,10 +8063,10 @@ less-loader@^12.3.0:
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340"
integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==
less@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/less/-/less-4.4.0.tgz#deaf881f4880ee80691beae925b8fac699d3a76d"
integrity sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==
less@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/less/-/less-4.3.0.tgz#ef0cfc260a9ca8079ed8d0e3512bda8a12c82f2a"
integrity sha512-X9RyH9fvemArzfdP8Pi3irr7lor2Ok4rOttDXBhlwDg+wKQsXOXgHWduAJE1EsF7JJx0w0bcO6BC6tCKKYnXKA==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"
@@ -10704,10 +10714,10 @@ rc-tabs@~15.6.1:
rc-resize-observer "^1.0.0"
rc-util "^5.34.1"
rc-textarea@~1.10.0, rc-textarea@~1.10.1:
version "1.10.2"
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.2.tgz#459e3574a95c32939c6793045a1e4db04cb514cc"
integrity sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==
rc-textarea@~1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.0.tgz#f8f962ef83be0b8e35db97cf03dbfb86ddd9c46c"
integrity sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "^2.2.1"
@@ -12090,10 +12100,10 @@ swagger-client@^3.35.5:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.27.1:
version "5.27.1"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.27.1.tgz#315b59970c33933a5f62ca0f702789741dcedc7c"
integrity sha512-wwDoavIeJI/Pwiavn32FMJ5dfptz0BAOKjSrj7EdU22QdP3gdk9+MZHdzzjxWURmVj0kc0XoQfsFgjln0toJaw==
swagger-ui-react@^5.26.0:
version "5.26.0"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.26.0.tgz#b15a903d556cc0ec2a56a969beb9d5bc9ea52910"
integrity sha512-4e6bP9bdJyh+SqQW0lxulPn/SDno4+oWrKXsuon5Z9kjtV0zeoWEJ1c70Qxp8kN/c3caFwec8OyxDNhvo14pkw==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -12515,7 +12525,7 @@ unraw@^3.0.0:
resolved "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz"
integrity sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==
update-browserslist-db@^1.1.3:
update-browserslist-db@^1.1.1, update-browserslist-db@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==
@@ -12784,27 +12794,26 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
webpack-sources@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723"
integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==
webpack-sources@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.101.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.0.tgz#4b81407ffad9857f81ff03f872e3369b9198cc9d"
integrity sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.9:
version "5.99.9"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247"
integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
"@types/estree" "^1.0.6"
"@types/json-schema" "^7.0.15"
"@webassemblyjs/ast" "^1.14.1"
"@webassemblyjs/wasm-edit" "^1.14.1"
"@webassemblyjs/wasm-parser" "^1.14.1"
acorn "^8.15.0"
acorn-import-phases "^1.0.3"
acorn "^8.14.0"
browserslist "^4.24.0"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.17.2"
enhanced-resolve "^5.17.1"
es-module-lexer "^1.2.1"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -12818,7 +12827,7 @@ webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
tapable "^2.1.1"
terser-webpack-plugin "^5.3.11"
watchpack "^2.4.1"
webpack-sources "^3.3.3"
webpack-sources "^3.2.3"
webpackbar@^6.0.1:
version "6.0.1"

View File

@@ -61,7 +61,7 @@
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.6.0",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
@@ -140,7 +140,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.55.6",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.28.0",
"@babel/compat-data": "^7.26.8",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/node": "^7.22.6",
@@ -175,7 +175,7 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/classnames": "^2.3.4",
"@types/classnames": "^2.2.10",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
@@ -263,7 +263,7 @@
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.20.3",
"tsx": "^4.19.2",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.99.9",
@@ -1086,9 +1086,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz",
"integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3989,18 +3989,18 @@
}
},
"node_modules/@deck.gl/core": {
"version": "9.1.14",
"resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.1.14.tgz",
"integrity": "sha512-tXakSSvi5g+EvxSsnnjoRO8z3XxHxISTRzzIqcs3AZuWHnDptK28y9iD0Da21ILop1IYLaWE1QTUe6IAdp/Wag==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.1.13.tgz",
"integrity": "sha512-c15DpwUEvDjmt3+/azSjcfhVQ5L5HiIj6LJob1KAwQOnB5zgVdKWukN/21ELQ7ekppEkfT0x4byRv5k4QVocqQ==",
"license": "MIT",
"dependencies": {
"@loaders.gl/core": "^4.2.0",
"@loaders.gl/images": "^4.2.0",
"@luma.gl/constants": "~9.1.9",
"@luma.gl/core": "~9.1.9",
"@luma.gl/engine": "~9.1.9",
"@luma.gl/shadertools": "~9.1.9",
"@luma.gl/webgl": "~9.1.9",
"@luma.gl/constants": "^9.1.5",
"@luma.gl/core": "^9.1.5",
"@luma.gl/engine": "^9.1.5",
"@luma.gl/shadertools": "^9.1.5",
"@luma.gl/webgl": "^9.1.5",
"@math.gl/core": "^4.1.0",
"@math.gl/sun": "^4.1.0",
"@math.gl/types": "^4.1.0",
@@ -15253,10 +15253,9 @@
"license": "MIT"
},
"node_modules/@types/classnames": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.4.tgz",
"integrity": "sha512-dwmfrMMQb9ujX1uYGvB5ERDlOzBNywnZAZBtOe107/hORWP05ESgU4QyaanZMWYYfd2BzrG78y13/Bju8IQcMQ==",
"deprecated": "This is a stub types definition. classnames provides its own type definitions, so you do not need this installed.",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.0.tgz",
"integrity": "sha512-3GsbOoDYteFShlrBTKzI2Eii4vPg/jAf7LXRIn0WQePKlmhpkV0KoTMuawA7gZJkrbPrZGwv9IEAfIWaOaQK8w==",
"license": "MIT",
"dependencies": {
"classnames": "*"
@@ -24129,9 +24128,9 @@
"license": "MIT"
},
"node_modules/dom-to-image-more": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/dom-to-image-more/-/dom-to-image-more-3.6.0.tgz",
"integrity": "sha512-0BB0M9gRRP7znKBNLRAvNyWnkDIzSgMSDcS7WdPDzPnWhW2YJqxUR/dCHiJ2HdCV3K2rVky5Vba8UF31mvrCuQ==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/dom-to-image-more/-/dom-to-image-more-3.5.0.tgz",
"integrity": "sha512-VF/vwfHsPNMHJb5W/5sAmco3UIlEWSEFLppInQwqwN4joUvBULDwE3CqVcUDkUWleke/nZ5KwIVSrrFlGw7WPA==",
"license": "MIT"
},
"node_modules/dom-to-pdf": {
@@ -54977,9 +54976,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"version": "4.19.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz",
"integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -61644,7 +61643,7 @@
"@storybook/types": "8.4.7",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.3.0",
"gh-pages": "^6.2.0",
"jquery": "^3.7.1",
"memoize-one": "^5.2.1",
"react": "^17.0.2",
@@ -62585,7 +62584,7 @@
"license": "Apache-2.0",
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/core": "^9.1.14",
"@deck.gl/core": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.13",

View File

@@ -129,7 +129,7 @@
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.6.0",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
@@ -208,7 +208,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.55.6",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.28.0",
"@babel/compat-data": "^7.26.8",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/node": "^7.22.6",
@@ -243,7 +243,7 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/classnames": "^2.3.4",
"@types/classnames": "^2.2.10",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
@@ -331,7 +331,7 @@
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.20.3",
"tsx": "^4.19.2",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.99.9",

View File

@@ -57,206 +57,5 @@ const exampleThemes: Record<string, SerializableThemeConfig> = {
},
algorithm: ThemeAlgorithm.DARK,
},
claudette: {
algorithm: 'dark',
token: {
colorPrimary: '#C15F3C',
colorPrimaryHover: '#d16b48',
colorPrimaryActive: '#a84f30',
colorBgBase: '#1a1a1a',
colorBgContainer: '#2a2a2a',
colorBgElevated: '#323232',
colorBgLayout: '#0f0f0f',
colorBgSpotlight: '#323232',
colorText: '#F4F3EE',
colorTextSecondary: '#B1ADA1',
colorTextTertiary: '#8a8680',
colorTextQuaternary: '#6b6862',
colorTextPlaceholder: '#B1ADA1',
colorTextLightSolid: '#F4F3EE',
colorBorder: '#404040',
colorBorderSecondary: '#303030',
colorFill: '#404040',
colorFillSecondary: '#353535',
colorFillTertiary: '#2a2a2a',
colorFillQuaternary: '#1f1f1f',
colorFillAlter: '#323232',
colorIcon: '#B1ADA1',
colorIconHover: '#F4F3EE',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu'",
fontFamilyCode:
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
fontSize: 14,
borderRadius: 6,
borderRadiusLG: 8,
wireframe: false,
},
},
figmate: {
algorithm: 'light',
token: {
colorPrimary: '#0d99ff',
colorPrimaryHover: '#3dadff',
colorPrimaryActive: '#0085e6',
colorInfo: '#4c74f4',
colorInfoHover: '#6b8af5',
colorInfoActive: '#2d5af2',
colorSuccess: '#00d2aa',
colorSuccessHover: '#1adbba',
colorSuccessActive: '#00c299',
colorWarning: '#ffad33',
colorWarningHover: '#ffbf5c',
colorWarningActive: '#ff9900',
colorError: '#ff5757',
colorErrorHover: '#ff7a7a',
colorErrorActive: '#ff3333',
colorBgBase: '#ffffff',
colorBgContainer: '#fafafa',
colorBgElevated: '#ffffff',
colorBgLayout: '#f5f5f5',
colorText: '#1a1a1a',
colorTextSecondary: '#666666',
colorTextTertiary: '#999999',
colorTextQuaternary: '#cccccc',
colorTextPlaceholder: '#999999',
colorBorder: '#e6e6e6',
colorBorderSecondary: '#f0f0f0',
colorFill: '#f0f0f0',
colorFillSecondary: '#f5f5f5',
colorFillTertiary: '#fafafa',
colorFillQuaternary: '#ffffff',
colorIcon: '#666666',
colorIconHover: '#333333',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 6,
lineHeight: 1.5,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
hubert: {
algorithm: 'dark',
token: {
colorPrimary: '#276EF1',
colorPrimaryHover: '#4285f4',
colorPrimaryActive: '#1557d6',
colorInfo: '#276EF1',
colorInfoHover: '#4285f4',
colorInfoActive: '#1557d6',
colorSuccess: '#3AA76D',
colorSuccessHover: '#4db87d',
colorSuccessActive: '#2d9657',
colorWarning: '#FFC043',
colorWarningHover: '#ffcd66',
colorWarningActive: '#e6ac26',
colorError: '#D44333',
colorErrorHover: '#dd5a4c',
colorErrorActive: '#bf3526',
colorBgBase: '#000000',
colorBgContainer: '#1a1a1a',
colorBgElevated: '#2a2a2a',
colorBgLayout: '#000000',
colorBgSpotlight: '#333333',
colorText: '#ffffff',
colorTextSecondary: '#cccccc',
colorTextTertiary: '#999999',
colorTextQuaternary: '#666666',
colorTextPlaceholder: '#999999',
colorTextLightSolid: '#ffffff',
colorBorder: '#333333',
colorBorderSecondary: '#1a1a1a',
colorFill: '#1a1a1a',
colorFillSecondary: '#2a2a2a',
colorFillTertiary: '#333333',
colorFillQuaternary: '#404040',
colorFillAlter: '#2a2a2a',
colorIcon: '#cccccc',
colorIconHover: '#ffffff',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
fontWeightStrong: 600,
borderRadius: 4,
borderRadiusLG: 6,
borderRadiusSM: 2,
lineHeight: 1.4,
lineWidthBold: 2,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
bnb: {
algorithm: 'light',
token: {
colorPrimary: '#FF5A5F',
colorPrimaryHover: '#FF7479',
colorPrimaryActive: '#E5464B',
colorInfo: '#29696B',
colorInfoHover: '#3D7D7F',
colorInfoActive: '#1F5557',
colorSuccess: '#5BCACE',
colorSuccessHover: '#7DD4D8',
colorSuccessActive: '#49B6BA',
colorWarning: '#F4B02A',
colorWarningHover: '#F6C054',
colorWarningActive: '#E2A01E',
colorError: '#C32F0E',
colorErrorHover: '#D54428',
colorErrorActive: '#A8280C',
colorBgBase: '#ffffff',
colorBgContainer: '#fafafa',
colorBgElevated: '#ffffff',
colorBgLayout: '#f7f7f7',
colorText: '#222222',
colorTextSecondary: '#484848',
colorTextTertiary: '#767676',
colorTextQuaternary: '#b0b0b0',
colorTextPlaceholder: '#767676',
colorBorder: '#e8e8e8',
colorBorderSecondary: '#f0f0f0',
colorFill: '#f7f7f7',
colorFillSecondary: '#fafafa',
colorFillTertiary: '#fcfcfc',
colorFillQuaternary: '#ffffff',
colorIcon: '#767676',
colorIconHover: '#484848',
fontFamily:
'Circular, Circular Air Pro, Airbnb Cereal, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
fontWeightStrong: 600,
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 4,
lineHeight: 1.5,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
};
export default exampleThemes;

View File

@@ -49,21 +49,6 @@ describe('isProbablyHTML', () => {
const trickyText = 'a <= 10 and b > 10';
expect(isProbablyHTML(trickyText)).toBe(false);
});
it('should return false for strings with angle brackets that are not HTML', () => {
// Test case from issue #25561
expect(isProbablyHTML('<abcdef:12345>')).toBe(false);
// Other similar cases
expect(isProbablyHTML('<foo:bar>')).toBe(false);
expect(isProbablyHTML('<123>')).toBe(false);
expect(isProbablyHTML('<test@example.com>')).toBe(false);
expect(isProbablyHTML('<custom-element>')).toBe(false);
// Mathematical expressions
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
expect(isProbablyHTML('price < $100')).toBe(false);
});
});
describe('sanitizeHtmlIfNeeded', () => {

View File

@@ -68,87 +68,9 @@ export function isProbablyHTML(text: string) {
return true;
}
// Check if the string contains common HTML patterns
if (!hasHtmlTagPattern(text)) {
return false;
}
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
// Check if parsing created actual HTML elements (not just text nodes)
const elements = Array.from(doc.body.childNodes).filter(
node => node.nodeType === 1,
) as Element[];
// If no elements were created, it's not HTML
if (elements.length === 0) {
return false;
}
// Check if the elements are known HTML tags (not custom/unknown tags)
// This prevents strings like "<abcdef:12345>" from being treated as HTML
return elements.some(element => {
const tagName = element.tagName.toLowerCase();
// List of common HTML tags we want to recognize
const knownHtmlTags = [
'div',
'span',
'p',
'a',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'tbody',
'thead',
'tfoot',
'ul',
'ol',
'li',
'img',
'br',
'hr',
'pre',
'code',
'blockquote',
'section',
'article',
'nav',
'header',
'footer',
'form',
'input',
'button',
'select',
'option',
'textarea',
'label',
'fieldset',
'legend',
'video',
'audio',
'canvas',
'iframe',
'script',
'style',
'link',
'meta',
'title',
];
return knownHtmlTags.includes(tagName);
});
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
}
export function sanitizeHtmlIfNeeded(htmlString: string) {

View File

@@ -43,7 +43,7 @@
"@storybook/types": "8.4.7",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.3.0",
"gh-pages": "^6.2.0",
"jquery": "^3.7.1",
"memoize-one": "^5.2.1",
"react": "^17.0.2",

View File

@@ -25,7 +25,7 @@
],
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/core": "^9.1.14",
"@deck.gl/core": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.13",

View File

@@ -174,8 +174,6 @@ function Echart(
if (!chartRef.current) {
chartRef.current = init(divRef.current, null, { locale });
}
// did mount
handleSizeChange({ width, height });
setDidMount(true);
});
}, [locale]);
@@ -237,6 +235,9 @@ function Echart(
echartOptions,
);
chartRef.current?.setOption(themedEchartOptions, true);
// did mount
handleSizeChange({ width, height });
}
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme]);

View File

@@ -32,11 +32,13 @@ import {
Form,
Tooltip,
Alert,
Label,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import { Typography } from '@superset-ui/core/components/Typography';
import { OnlyKeyWithType } from 'src/utils/types';
import { CopyToClipboard } from 'src/components/CopyToClipboard';
import { ThemeObject } from './types';
interface ThemeModalProps {
@@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
/>
</Form.Item>
{currentTheme?.uuid && (
<Form.Item label={t('UUID')}>
<div
css={css`
display: flex;
align-items: center;
gap: ${supersetTheme.sizeUnit * 2}px;
`}
>
<Label monospace>{currentTheme.uuid}</Label>
<CopyToClipboard
text={currentTheme.uuid}
shouldShowText={false}
wrapped={false}
copyNode={<Icons.CopyOutlined iconSize="m" />}
tooltipText={t('Copy UUID to clipboard')}
/>
</div>
</Form.Item>
)}
<Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
<Alert
type="info"

View File

@@ -0,0 +1,126 @@
# 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 logging
from typing import Any
from superset.commands.base import BaseCommand
from superset.daos.theme import ThemeDAO
from superset.extensions import db
from superset.models.core import Theme
from superset.utils import json
from superset.utils.decorators import transaction
logger = logging.getLogger(__name__)
class ResolveAndUpsertThemeCommand(BaseCommand):
"""Command to resolve theme configuration and upsert to system theme."""
def __init__(self, theme_config: dict[str, Any], theme_name: str):
self._theme_config = theme_config
self._theme_name = theme_name
self._fallback_config = self._get_fallback_config()
def run(self) -> dict[str, Any]:
"""Resolve theme configuration and upsert to system theme."""
try:
self.validate()
# First resolve the theme configuration
resolved_config = self._resolve_theme_config()
# Then upsert to system theme
self._upsert_system_theme(resolved_config)
return resolved_config
except Exception as ex:
logger.error(
"Failed to resolve and upsert theme %s: %s. Using fallback.",
self._theme_name,
ex,
)
return self._fallback_config
def _get_fallback_config(self) -> dict[str, Any]:
"""Get fallback configuration based on theme name."""
if self._theme_name == "THEME_DARK":
return {"algorithm": "dark"}
return {}
def _resolve_theme_config(self) -> dict[str, Any]:
"""Resolve theme configuration, looking up UUID references if present."""
# Check if config contains a UUID reference
if isinstance(self._theme_config, dict) and "uuid" in self._theme_config:
uuid = self._theme_config["uuid"]
referenced_theme = ThemeDAO.find_by_uuid(uuid)
if referenced_theme and referenced_theme.json_data:
try:
resolved_config = json.loads(referenced_theme.json_data)
logger.debug(
"Resolved UUID reference %s for %s to theme definition",
uuid,
self._theme_name,
)
return resolved_config
except (ValueError, TypeError) as ex:
logger.error(
"Failed to parse theme JSON for UUID %s: %s",
uuid,
ex,
)
return self._fallback_config
else:
logger.error(
"Referenced theme with UUID %s not found for %s",
uuid,
self._theme_name,
)
return self._fallback_config
# Not a UUID reference, return as-is
return self._theme_config
@transaction()
def _upsert_system_theme(self, theme_config: dict[str, Any]) -> None:
"""Upsert the resolved theme configuration as a system theme."""
existing_theme = (
db.session.query(Theme)
.filter(Theme.theme_name == self._theme_name, Theme.is_system)
.first()
)
json_data = json.dumps(theme_config)
if existing_theme:
existing_theme.json_data = json_data
logger.info(f"Updated system theme: {self._theme_name}")
else:
new_theme = Theme(
theme_name=self._theme_name,
json_data=json_data,
is_system=True,
)
db.session.add(new_theme)
logger.info(f"Created system theme: {self._theme_name}")
def validate(self) -> None:
"""Validate that the theme config is a dictionary."""
if not isinstance(self._theme_config, dict):
self._theme_config = {}
if not self._theme_name:
raise ValueError("Theme name is required")

View File

@@ -37,7 +37,7 @@ from superset.migrations.shared.security_converge import (
)
from superset.models.core import Database
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
Base: Type[Any] = declarative_base()

View File

@@ -28,7 +28,7 @@ from superset.migrations.shared.utils import paginated_update, try_load_json
from superset.utils import json
from superset.utils.date_parser import get_since_until
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
Base = declarative_base()

View File

@@ -29,7 +29,7 @@ from sqlalchemy import (
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Load, relationship, Session
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
Base = declarative_base()

View File

@@ -49,7 +49,7 @@ YELLOW = "\033[33m"
RED = "\033[31m"
LRED = "\033[91m"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
DEFAULT_BATCH_SIZE = int(os.environ.get("BATCH_SIZE", 1000))

View File

@@ -33,8 +33,6 @@ from superset.utils.core import generic_find_constraint_name
revision = "1226819ee0e3"
down_revision = "956a063c52b3"
logger = logging.getLogger("alembic.env")
naming_convention = {
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s"
@@ -63,7 +61,7 @@ def upgrade():
["datasource_name"],
)
except: # noqa: E722
logger.warning("Could not find or drop constraint on `columns`")
logging.warning("Could not find or drop constraint on `columns`")
def downgrade():

View File

@@ -38,8 +38,6 @@ from superset.utils.core import generic_find_constraint_name
revision = "3b626e2a6783"
down_revision = "eca4694defa7"
logger = logging.getLogger("alembic.env")
def upgrade():
# cleanup after: https://github.com/airbnb/superset/pull/1078
@@ -62,7 +60,7 @@ def upgrade():
batch_op.drop_column("druid_datasource_id")
batch_op.drop_column("table_id")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
# fixed issue: https://github.com/airbnb/superset/issues/466
try:
@@ -71,18 +69,18 @@ def upgrade():
None, "datasources", ["datasource_name"], ["datasource_name"]
)
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.create_unique_constraint("client_id", ["client_id"])
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.drop_column("name")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
def downgrade():
@@ -90,7 +88,7 @@ def downgrade():
with op.batch_alter_table("tables") as batch_op:
batch_op.create_index("table_name", ["table_name"], unique=True)
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
with op.batch_alter_table("slices") as batch_op:
@@ -115,7 +113,7 @@ def downgrade():
)
batch_op.create_foreign_key("slices_ibfk_2", "tables", ["table_id"], ["id"])
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
try:
fk_columns = generic_find_constraint_name(
@@ -127,11 +125,11 @@ def downgrade():
with op.batch_alter_table("columns") as batch_op:
batch_op.drop_constraint(fk_columns, type_="foreignkey")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))
op.add_column("query", sa.Column("name", sa.String(length=256), nullable=True))
try:
with op.batch_alter_table("query") as batch_op:
batch_op.drop_constraint("client_id", type_="unique")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))

View File

@@ -31,8 +31,6 @@ import logging # noqa: E402
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
op.add_column("tables", sa.Column("params", sa.Text(), nullable=True))
@@ -42,4 +40,4 @@ def downgrade():
try:
op.drop_column("tables", "params")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex))

View File

@@ -31,8 +31,6 @@ import logging # noqa: E402
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
op.add_column(
@@ -46,7 +44,7 @@ def upgrade():
op.create_unique_constraint(None, "dbs", ["verbose_name"])
op.create_unique_constraint(None, "clusters", ["verbose_name"])
except Exception:
logger.info("Constraint not created, expected when using sqlite")
logging.info("Constraint not created, expected when using sqlite")
def downgrade():
@@ -54,4 +52,4 @@ def downgrade():
op.drop_column("dbs", "verbose_name")
op.drop_column("clusters", "verbose_name")
except Exception as ex:
logger.exception(ex)
logging.exception(ex)

View File

@@ -37,8 +37,6 @@ from superset.utils.core import (
revision = "4736ec66ce19"
down_revision = "f959a6652acd"
logger = logging.getLogger("alembic.env")
conv = {
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
@@ -121,13 +119,13 @@ def upgrade():
type_="unique",
)
except Exception as ex:
logger.warning(
logging.warning(
"Constraint drop failed, you may want to do this "
"manually on your database. For context, this is a known "
"issue around nondeterministic constraint names on Postgres "
"and perhaps more databases through SQLAlchemy."
)
logger.exception(ex)
logging.exception(ex)
def downgrade():

View File

@@ -22,16 +22,12 @@ Create Date: 2018-05-09 23:45:14.296283
"""
import logging
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
# revision identifiers, used by Alembic.
revision = "e502db2af7be"
down_revision = "5ccf602336a0"
logger = logging.getLogger("alembic.env")
import sqlalchemy as sa # noqa: E402
from alembic import op # noqa: E402
def upgrade():
@@ -42,4 +38,4 @@ def downgrade():
try:
op.drop_column("tables", "template_params")
except Exception as ex:
logger.warning(str(ex))
logging.warning(str(ex)) # noqa: F821

View File

@@ -35,8 +35,6 @@ from superset.utils import json
revision = "fb13d49b72f9"
down_revision = "de021a1ca60d"
logger = logging.getLogger("alembic.env")
Base = declarative_base()
@@ -51,7 +49,7 @@ class Slice(Base):
def upgrade_slice(slc):
params = json.loads(slc.params)
logger.info(f"Upgrading {slc.slice_name}")
logging.info(f"Upgrading {slc.slice_name}")
cols = params.get("groupby")
metric = params.get("metric")
if cols:
@@ -81,8 +79,8 @@ def upgrade():
for slc in filter_box_slices.all():
try:
upgrade_slice(slc)
except Exception as e:
logger.exception(e)
except Exception:
logging.exception(e) # noqa: F821
session.commit()
session.close()
@@ -96,7 +94,7 @@ def downgrade():
for slc in filter_box_slices.all():
try:
params = json.loads(slc.params)
logger.info(f"Downgrading {slc.slice_name}")
logging.info(f"Downgrading {slc.slice_name}")
flts = params.get("filter_configs")
if not flts:
continue
@@ -104,7 +102,7 @@ def downgrade():
params["groupby"] = [o.get("column") for o in flts]
slc.params = json.dumps(params, sort_keys=True)
except Exception as ex:
logger.exception(ex)
logging.exception(ex)
session.commit()
session.close()

View File

@@ -41,8 +41,6 @@ from superset.utils.core import ( # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Slice(Base):
__tablename__ = "slices"
@@ -65,7 +63,7 @@ def upgrade():
if source != target:
slc.params = json.dumps(target, sort_keys=True)
except Exception as ex:
logger.warning(ex)
logging.warn(ex)
session.commit()
session.close()

View File

@@ -37,8 +37,6 @@ from superset.utils.dashboard_filter_scopes_converter import convert_filter_scop
revision = "3325d4caccc8"
down_revision = "e96dbf2cfef0"
logger = logging.getLogger("alembic.env")
Base = declarative_base()
@@ -88,7 +86,7 @@ def upgrade():
if filters:
filter_scopes = convert_filter_scopes(json_metadata, filters)
json_metadata["filter_scopes"] = filter_scopes
logger.info(
logging.info(
f"Adding filter_scopes for dashboard {dashboard.id}: {json.dumps(filter_scopes)}" # noqa: E501
)
@@ -102,7 +100,7 @@ def upgrade():
else:
dashboard.json_metadata = None
except Exception as ex:
logger.exception(f"dashboard {dashboard.id} has error: {ex}")
logging.exception(f"dashboard {dashboard.id} has error: {ex}")
session.commit()
session.close()

View File

@@ -38,8 +38,6 @@ from sqlalchemy_utils import UUIDType # noqa: E402
from superset import db # noqa: E402
logger = logging.getLogger("alembic.env")
add_uuid_column_to_import_mixin = import_module(
"superset.migrations.versions."
"2020-09-28_17-57_b56500de1855_add_uuid_column_to_import_mixin",
@@ -54,9 +52,9 @@ def has_uuid_column(table_name, bind):
columns = {column["name"] for column in inspector.get_columns(table_name)}
has_uuid_column = "uuid" in columns
if has_uuid_column:
logger.info("Table %s already has uuid column, skipping...", table_name)
logging.info("Table %s already has uuid column, skipping...", table_name)
else:
logger.info("Table %s doesn't have uuid column, adding...", table_name)
logging.info("Table %s doesn't have uuid column, adding...", table_name)
return has_uuid_column

View File

@@ -38,8 +38,6 @@ down_revision = "31b2a1039d4a"
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Database(Base):
__tablename__ = "dbs"
@@ -58,7 +56,7 @@ def upgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
schemas_allowed_for_csv_upload = extra.get("schemas_allowed_for_csv_upload")

View File

@@ -35,8 +35,6 @@ from sqlalchemy import func # noqa: E402
from superset import db # noqa: E402
from superset.connectors.sqla.models import SqlaTable # noqa: E402
logger = logging.getLogger("alembic.env")
def upgrade():
with op.batch_alter_table("tables") as batch_op:
@@ -64,12 +62,12 @@ def remove_value_if_too_long():
for row in rows:
row.fetch_values_predicate = None
logger.info("%d values deleted", len(rows))
logging.info("%d values deleted", len(rows))
session.commit()
session.close()
except Exception as ex:
logger.warning(ex)
logging.warning(ex)
def downgrade():

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Dashboard(Base):

View File

@@ -37,8 +37,6 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
class Database(Base):
__tablename__ = "dbs"
@@ -54,7 +52,7 @@ def upgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
if "schemas_allowed_for_csv_upload" in extra:
@@ -76,7 +74,7 @@ def downgrade():
try:
extra = json.loads(database.extra)
except json.JSONDecodeError as ex:
logger.warning(str(ex))
logging.warning(str(ex))
continue
if "schemas_allowed_for_file_upload" in extra:

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Slice(Base):

View File

@@ -38,7 +38,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
class Slice(Base):

View File

@@ -37,7 +37,7 @@ from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
class Slice(Base): # type: ignore

View File

@@ -38,7 +38,7 @@ from superset.migrations.shared.utils import paginated_update # noqa: E402
from superset.utils import json # noqa: E402
Base = declarative_base()
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
class Dashboard(Base):

View File

@@ -41,7 +41,7 @@ from superset.utils.date_parser import get_since_until
revision = "f84fde59123a"
down_revision = "9621c6d56ffb"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
Base = declarative_base()

View File

@@ -34,7 +34,7 @@ from superset.utils.core import generic_find_uq_constraint_name
revision = "df3d7e2eb9a4"
down_revision = "48cbb571fa3a"
logger = logging.getLogger("alembic.env")
logger = logging.getLogger(__name__)
def upgrade():

View File

@@ -32,7 +32,8 @@ from sqlalchemy.ext.declarative import declarative_base
from superset import db
from superset.migrations.shared.utils import paginated_update
logger = logging.getLogger("alembic.env")
logger = logging.getLogger("alembic")
logger.setLevel(logging.INFO)
# revision identifiers, used by Alembic.
revision = "363a9b1e8992"

View File

@@ -62,7 +62,6 @@ from superset.extensions import cache_manager
from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse
from superset.themes.utils import (
is_valid_theme,
is_valid_theme_settings,
)
from superset.utils import core as utils, json
@@ -307,29 +306,25 @@ def menu_data(user: User) -> dict[str, Any]:
def get_theme_bootstrap_data() -> dict[str, Any]:
"""
Returns the theme data to be sent to the client.
Resolves UUID references and upserts system themes.
"""
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
# Get theme configs
default_theme_config = get_config_value("THEME_DEFAULT")
dark_theme_config = get_config_value("THEME_DARK")
theme_settings = get_config_value("THEME_SETTINGS")
# Validate theme configurations
default_theme = default_theme_config
if not is_valid_theme(default_theme):
logger.warning(
"Invalid THEME_DEFAULT configuration: %s, using empty theme",
default_theme_config,
)
default_theme = {}
# Resolve and upsert themes - command handles all error cases
default_theme = ResolveAndUpsertThemeCommand(
default_theme_config or {}, "THEME_DEFAULT"
).run()
dark_theme = dark_theme_config
if not is_valid_theme(dark_theme):
logger.warning(
"Invalid THEME_DARK configuration: %s, using empty theme",
dark_theme_config,
)
dark_theme = {}
dark_theme = ResolveAndUpsertThemeCommand(
dark_theme_config or {}, "THEME_DARK"
).run()
# Validate theme settings
if not is_valid_theme_settings(theme_settings):
logger.warning(
"Invalid THEME_SETTINGS configuration: %s, using defaults", theme_settings

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,184 @@
# 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.
from unittest.mock import MagicMock, patch
import pytest
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
from superset.models.core import Theme
from superset.utils import json
@pytest.fixture
def mock_theme_dao():
with patch("superset.commands.theme.resolve.ThemeDAO") as mock:
yield mock
@pytest.fixture
def mock_db():
with patch("superset.commands.theme.resolve.db") as mock:
yield mock
class TestResolveAndUpsertThemeCommand:
def test_resolve_uuid_reference_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is found."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_not_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is not found."""
# Setup
uuid = "missing-uuid-123"
theme_config = {"uuid": uuid}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_invalid_json(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference with invalid JSON data."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = "invalid json"
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
def test_resolve_non_uuid_config(self, mock_theme_dao, mock_db):
"""Test resolving a regular theme config (not UUID reference)."""
# Setup
theme_config = {"algorithm": "default", "token": {"colorPrimary": "#ff0000"}}
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return config as-is
assert result == theme_config
mock_theme_dao.find_by_uuid.assert_not_called()
def test_upsert_creates_new_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting creates a new system theme when none exists."""
# Setup
theme_config = {"algorithm": "default"}
mock_db.session.query.return_value.filter.return_value.first.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
command.run()
# Assert
mock_db.session.add.assert_called_once()
added_theme = mock_db.session.add.call_args[0][0]
assert added_theme.theme_name == "THEME_DEFAULT"
assert added_theme.is_system is True
assert added_theme.json_data == json.dumps(theme_config)
def test_upsert_updates_existing_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting updates an existing system theme."""
# Setup
theme_config = {"algorithm": "dark"}
existing_theme = MagicMock(spec=Theme)
mock_db.session.query.return_value.filter.return_value.first.return_value = (
existing_theme
)
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
command.run()
# Assert
assert existing_theme.json_data == json.dumps(theme_config)
mock_db.session.add.assert_not_called()
def test_fallback_for_theme_default(self, mock_theme_dao, mock_db):
"""Test fallback returns empty dict for THEME_DEFAULT."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == {}
def test_fallback_for_theme_dark(self, mock_theme_dao, mock_db):
"""Test fallback returns dark algorithm for THEME_DARK."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
result = command.run()
# Assert
assert result == {"algorithm": "dark"}
def test_uuid_with_additional_fields(self, mock_theme_dao, mock_db):
"""Test that UUID takes precedence even with additional fields."""
# Setup
uuid = "test-uuid-123"
theme_config = {
"uuid": uuid,
"algorithm": "ignored", # This should be ignored
"token": {"ignored": True}, # This should be ignored
}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return the resolved config, not the input
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)