[SIP-205] Content Localization for Dashboards, Charts, and Filters
- Dominant language
- Python
- Stars
- 74.8k
- Forks
- 18.3k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 685
Description
## Motivation
Apache Superset supports UI internationalization (gettext-based `.po` files), but **user-created content** — dashboard titles, chart names, filter labels, descriptions — remains single-language. Organizations serving multilingual audiences must either:
- Duplicate dashboards per language (maintenance nightmare)
- Accept that users see content in a foreign language
- Build custom solutions outside Superset
This is a solved problem in competing BI tools:
| Tool | Content Localization | Approach |
|------|---------------------|----------|
| **Power BI** | Native | Metadata translations in semantic model + `USERCULTURE()` DAX function |
| **Looker** | Native | `.strings.json` files with LookML field translations |
| **Tableau** | Workaround | Parameter + translation tables + calculated fields |
| **Metabase** | Embedded only (Pro) | CSV translation dictionary |
| **Superset** | **Not supported** | SIP-60 proposed but not implemented |
[SIP-60](https://github.com/apache/superset/issues/13442) proposed extending i18n to charts but was not implemented. This SIP takes a different architectural approach — JSON column storage instead of parallel i18n columns — and covers the full stack: data model, API, frontend editing, embedded SDK, SQL templating, and export/import.
### What gets translated
| Entity | Translatable Fields |
|--------|-------------------|
| Dashboard | `dashboard_title` |
| Chart | `slice_name`, `description` |
| Native Filter | `name` |
| Chart Name Override | `sliceNameOverride` (per-dashboard) |
## Proposed Change
### Feature Flag
All functionality is gated behind `ENABLE_CONTENT_LOCALIZATION` (default: `False`). When disabled, the system behaves identically to current Superset — no schema changes visible, no API changes, no frontend UI.
### Data Model
A `translations` JSON column is added to `dashboards` and `slices` tables:
```python
class Dashboard(Model, LocalizableMixin):
dashboard_title = Column(String(500)) # default/fallback text
translations = Column(JSON, nullable=True) # {"dashboard_title": {"de": "...", "fr": "..."}}
class Slice(Model, LocalizableMixin):
slice_name = Column(String(250))
description = Column(Text)
translations = Column(JSON, nullable=True) # {"slice_name": {"de": "..."}, "description": {"de": "..."}}
```
Native filter translations are stored in `json_metadata.native_filter_configuration[].translations`.
`LocalizableMixin` provides `get_localized()`, `set_translation()`, and `get_available_locales()` methods with a three-step fallback chain:
1. Exact locale match (`pt-BR`)
2. Base language (`pt`)
3. Original column value
### API
Two response modes controlled by `?include_translations=true`:
**Default mode** — returns localized values based on user's locale:
```
GET /api/v1/dashboard/123
Accept-Language: de
→ {"dashboard_title": "Verkaufs-Dashboard", "available_locales": ["de", "fr"]}
```
**Editor mode** — returns original values + full translations dict:
```
GET /api/v1/dashboard/123?include_translations=true
→ {"dashboard_title": "Sales Dashboard",
"translations": {"dashboard_title": {"de": "Verkaufs-Dashboard", "fr": "Tableau de bord"}},
"available_locales": ["de", "fr"]}
```
Locale detection priority: session locale → `Accept-Language` header → `BABEL_DEFAULT_LOCALE`.
**Saving:** standard PUT with `translations` field:
```
PUT /api/v1/dashboard/123
{"translations": {"dashboard_title": {"de": "Verkaufs-Dashboard"}}}
```
### Frontend: LocaleSwitcher
An inline locale dropdown rendered as an Input suffix (similar to password eye icon). The user switches between DEFAULT text and per-locale translations directly in the form field.
```
┌──────────────────────────────────────────────────────────┐
│ Sales Dashboard [🌐 ▾ 2] │
└──────────────────────────────────────────────────────────┘
│ click
▼
┌──────────────────┐
│ ✓ DEFAULT │
│───────────────────│
│ ✓ 🇩🇪 Deutsch │
│ 🇫🇷 Français │
│ ✓ 🇪🇸 Español │
└──────────────────┘
```
- **DEFAULT** = original column value (fallback for users without a matching translation)
- **✓** = translation text is filled for this locale
- **Badge (2)** = number of filled translations
- **Yellow icon** = viewer's locale has no translation (attention indicator)
Integrated into three editing surfaces:
1. **Dashboard Properties Modal** — Title field
2. **Chart Properties Modal** — Name and Description fields
3. **Filter Configuration** — Filter name field
### Jinja Macro
`{{ current_user_locale() }}` returns the viewer's resolved locale in SQL templates, enabling locale-aware queries:
```sql
SELECT product_name FROM products WHERE language = '{{ current_user_locale() }}'
```
The locale value is automatically included in the cache key.
### Embedded SDK
`setLocale()` method for host applications to switch embedded dashboard language dynamically:
```js
const dashboard = await embedDashboard({...});
dashboard.setLocale('de'); // triggers reload with new locale
```
### Export/Import
Translations are included in YAML exports. Missing `translations` field on import sets it to `null` (backward compatible).
```yaml
dashboard_title: Sales Dashboard
translations:
dashboard_title:
de: Verkaufs-Dashboard
fr: Tableau de bord des ventes
```
### Validation and Security
- **XSS prevention**: all translation values sanitized on save (HTML tags stripped)
- **Locale validation**: BCP 47 and POSIX formats accepted
- **Size limits**: configurable via `CONTENT_LOCALIZATION_MAX_*` settings (locales: 50, text: 10K chars, JSON: 1MB)
- **Feature flag guard**: PUT with `translations` rejected when flag is disabled
## New or Changed Public Interfaces
### REST API
| Endpoint | Change |
|----------|--------|
| `GET /api/v1/dashboard/{id}` | Returns localized field values; adds `available_locales` field |
| `GET /api/v1/dashboard/{id}?include_translations=true` | Returns original values + `translations` dict |
| `PUT /api/v1/dashboard/{id}` | Accepts `translations` field |
| `GET /api/v1/chart/{id}` | Same as dashboard |
| `PUT /api/v1/chart/{id}` | Same as dashboard |
| `GET /api/v1/localization/available_locales` | **New endpoint** — returns configured locales + default |
### Models
| Model | Change |
|-------|--------|
| `Dashboard` | Added `translations` JSON column, `LocalizableMixin` |
| `Slice` | Added `translations` JSON column, `LocalizableMixin` |
### Frontend Components
| Component | Description |
|-----------|-------------|
| `LocaleSwitcher` | Inline dropdown in Input suffix for locale switching |
| `TranslationTextAreaWrapper` | Wrapper for TextArea fields (no native suffix support) |
### Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `ENABLE_CONTENT_LOCALIZATION` | `False` | Feature flag |
| `CONTENT_LOCALIZATION_MAX_LOCALES` | 50 | Max locales per entity |
| `CONTENT_LOCALIZATION_MAX_TEXT_LENGTH` | 10000 | Max chars per translation |
| `CONTENT_LOCALIZATION_MAX_JSON_SIZE` | 1048576 | Max JSON payload (bytes) |
### Embedded SDK
New method on `EmbeddedDashboard` type:
```ts
setLocale(locale: string): void
```
### Jinja
New macro: `current_user_locale(add_to_cache_keys=True) → str`
## New Dependencies
**None.** The implementation uses only existing dependencies:
- SQLAlchemy JSON column type
- Flask-Babel (already used for UI i18n)
- antd Dropdown, Badge (already in superset-frontend)
## Migration Plan and Compatibility
### Database Migration
Migration `1af0da0adfec` adds a nullable `translations` JSON column to `dashboards` and `slices` tables:
```python
op.add_column("dashboards", sa.Column("translations", sa.JSON(), nullable=True))
op.add_column("slices", sa.Column("translations", sa.JSON(), nullable=True))
```
- **Forward compatible**: `NULL` = no translations, existing behavior unchanged
- **Rollback**: drops the columns
- **No data migration needed**: existing entities simply have `NULL` translations
### Feature Flag Behavior
| Flag State | API Behavior | Frontend | PUT with translations |
|------------|-------------|----------|----------------------|
| `False` (default) | No localization, no `available_locales` | No LocaleSwitcher visible | **Rejected** |
| `True` | Full localization pipeline | LocaleSwitcher in edit modals | Accepted + validated |
### Export/Import Compatibility
- Exports with translations: backward compatible (unknown fields ignored by older Superset)
- Imports without translations: `NULL` (no translations)
- Imports with translations: accepted if feature flag enabled
## Rejected Alternatives
### 1. Separate Translation Table (Normalized)
```sql
CREATE TABLE content_translations (
content_type VARCHAR(50),
content_id INTEGER,
field_name VARCHAR(50),
locale VARCHAR(10),
value TEXT,
UNIQUE(content_type, content_id, field_name, locale)
);
```
**Rejected because:**
- Requires JOINs on every read (performance impact on dashboard load)
- Additional table maintenance, more complex migrations
- Cannot be self-contained in export/import YAML
- JSON column achieves the same with single-query reads
### 2. Parallel i18n Columns (SIP-60 approach)
```python
slice_name = Column(String(250))
slice_name_i18n = Column(JSON) # {"en": "Sales", "de": "Verkauf"}
```
**Rejected because:**
- Doubles the number of translatable columns in the schema
- Does not scale: adding a new translatable field requires a migration
- Breaks existing field references in code
- This proposal uses a single `translations` column for all field translations
### 3. Modal-based Translation Editor
Initial implementation used a separate `TranslationEditorModal` with per-language text fields. Replaced with inline `LocaleSwitcher` because:
- Users didn't know which language they were editing in the main input
- Opening a modal for each field was cumbersome
- Inline switching makes the active language always visible
## Reference Implementation
- **Branch:** [`feat/dashboard-content-localization`](https://github.com/apache/superset/compare/master...feat/dashboard-content-localization)
- **Phases:** 9 completed (Foundation → Backend → API → Export/Import → Security → Frontend → Integration → Embedded/Caching → Documentation → E2E → UX)
- **Test coverage:** ~150 unit tests + 4 Playwright E2E scenarios
- **Documentation:** `docs/docs/configuration/content-localization.mdx`
Contributor guide
Research direction
Start by reviewing the feat/dashboard-content-localization branch and docs/docs/configuration/content-localization.mdx, then inspect migration 1af0da0adfec and the issue's listed API, frontend, SDK, Jinja, and export/import entry points. Done means the feature-flagged localization flow is implemented across the full scope, with the stated unit tests and four Playwright E2E scenarios passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- flask, python, react, sql, typescript
- Domain
- api, backend, databases, documentation, frontend, full-stack, internationalization
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100