YuriyKrasilnikov opened a new issue, #37789:
URL: https://github.com/apache/superset/issues/37789

   ## 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`


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to