apache / apache/superset

[SIP-205] Content Localization for Dashboards, Charts, and Filters

Open
#37,789 3 comments 5 reactions 0 assignees View on GitHub
design:proposal
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.