Back to Spree

Centralized Translations Admin Page

docs/plans/5.4-centralized-translations-admin.md

5.4.28.9 KB
Original Source

Centralized Translations Admin Page

Status: Draft Target: Spree 5.4 Depends on: Markets (for locale configuration), Mobility (for translation storage) Author: Damian + Claude Last updated: 2026-04-04

Summary

Add a centralized Translations page under Products in the admin sidebar. It provides an overview of translation coverage across products and locales, with bulk CSV import/export. Individual per-field editing stays in the existing product edit drawer — this page is for visibility and bulk operations, inspired by Medusa's centralized translations approach.

Key Decisions (do not deviate without discussion)

  • Overview only, no inline editing — the page shows translation status (coverage grid), not editable fields. Per-product editing stays in the existing translation drawer on the product edit page.
  • Lives under Products in the sidebar — alongside Price Lists, Stock, Taxonomies, Options. Not a top-level nav item, not buried in Settings.
  • CSV is the bulk interface — Import and Export buttons on this page use the existing Spree::Imports::ProductTranslations and Spree::Exports::ProductTranslations types (already implemented in core).
  • Product translations first — the page focuses on products initially. Taxon/option type translations can be added later as additional tabs.
  • Locales come from markets — the page uses store.supported_locales_list (derived from market configuration) to determine which locales to show.

Design Details

Products (sidebar)
├─ Price Lists
├─ Stock
├─ Translations    ← NEW
├─ Taxonomies
└─ Options

Position: 25 (between Stock at 20 and Taxonomies at 30).

Page Layout

┌─────────────────────────────────────────────────┐
│  Translations                   [Import] [Export]│
├─────────────────────────────────────────────────┤
│                                                  │
│  Translation coverage                            │
│  ┌──────────────────────────────────────────┐   │
│  │ Locale    │ Translated │ Total │ Coverage │   │
│  ├───────────┼────────────┼───────┼──────────┤   │
│  │ 🇩🇪 de    │     36     │  36   │   100%   │   │
│  │ 🇫🇷 fr    │     36     │  36   │   100%   │   │
│  │ 🇪🇸 es    │      0     │  36   │     0%   │   │
│  │ 🇮🇹 it    │      0     │  36   │     0%   │   │
│  └──────────────────────────────────────────┘   │
│                                                  │
│  Products                              [Filter]  │
│  ┌──────────────────────────────────────────┐   │
│  │ Product            │  de  │  fr  │  es   │   │
│  ├────────────────────┼──────┼──────┼───────┤   │
│  │ Espresso Machine   │  ✓   │  ✓   │  —    │   │
│  │ Drip Coffee Maker  │  ✓   │  ✓   │  —    │   │
│  │ Air Fryer 4L       │  ✓   │  ✓   │  —    │   │
│  └──────────────────────────────────────────┘   │
│                                                  │
│  ✓ = has name translation   — = missing          │
│  Click product name → opens translation drawer   │
└─────────────────────────────────────────────────┘

Components

1. Summary cards / coverage table (top)

  • One row per non-default locale
  • Columns: locale code, translated count, total products, coverage percentage
  • Coverage bar (progress bar visual)
  • Locales derived from current_store.supported_locales_list - [default_locale]

2. Product × locale grid (main content)

  • Rows: products (paginated, Ransack-filterable)
  • Columns: product name (in default locale) + one column per non-default locale
  • Cell shows ✓ (checkmark) if the product has a name translation in that locale, — (dash) if missing
  • Product name links to the product edit page's translation drawer
  • Standard Pagy pagination

3. Action buttons (page header)

  • Import — opens the existing import drawer with type: Spree::Imports::ProductTranslations
  • Export — opens the existing export modal with type: Spree::Exports::ProductTranslations

Controller

ruby
module Spree
  module Admin
    class ProductTranslationsController < ResourceController
      # GET /admin/product_translations
      def index
        @locales = non_default_locales
        @coverage = translation_coverage
        @products = paginated_products
      end

      private

      def non_default_locales
        (current_store.supported_locales_list - [current_store.default_locale]).sort
      end

      def translation_coverage
        total = current_store.products.count
        @locales.map do |locale|
          translated = Spree::Product::Translation.where(locale: locale)
            .where(spree_product_id: current_store.products.select(:id))
            .where.not(name: [nil, ''])
            .count
          { locale: locale, translated: translated, total: total }
        end
      end

      def paginated_products
        products = current_store.products.ransack(params[:q]).result
        # Eager load translation status
        @pagy, products = pagy(products.order(:name))
        products
      end
    end
  end
end

Route

ruby
# In admin routes, under the products section
resources :product_translations, only: [:index]

Translation status query

To determine if a product has a translation for a given locale, query the spree_product_translations table:

ruby
# Returns a hash: { product_id => [locale1, locale2, ...] }
translated_locales = Spree::Product::Translation
  .where(spree_product_id: product_ids, locale: @locales)
  .where.not(name: [nil, ''])
  .group(:spree_product_id)
  .pluck(:spree_product_id, Arel.sql('array_agg(locale)'))
  .to_h

This avoids N+1 queries — one query loads all translation status for the current page of products.

Empty state

When no markets/locales are configured:

  • Show a message: "Set up markets with additional locales to start translating products"
  • Link to /admin/markets

Linking to per-product translation editing

Product name in the grid links to the existing translation drawer:

erb
<%= link_to product.name,
    spree.edit_admin_translation_path(resource_type: 'Spree::Product', id: product.id),
    data: { action: 'drawer#open', turbo_frame: :drawer } %>

Migration Path

No migrations needed. The translation table (spree_product_translations) and all core infrastructure already exist. This is purely an admin UI addition.

  1. Create the controller, view, and route
  2. Add the sidebar navigation item
  3. Add I18n strings

Constraints on Current Work

  • The CSV import/export types (Spree::Imports::ProductTranslations, Spree::Exports::ProductTranslations) are already implemented and registered in engine.rb — no changes needed there.
  • The existing per-product translation drawer remains the primary editing interface — do not duplicate editing UI on this page.
  • Import/Export buttons on this page should use the same drawer/modal components as the products index (not custom ones).

Open Questions

  • Should we add tabs for other resource types? (Taxons, Option Types, etc.) — deferred, can add tabs later without changing the architecture.
  • Should the grid show field-level granularity? (e.g., name ✓ but description missing) — start with name-only indicator, can expand to a detail view per product later.
  • Filter by translation status? (e.g., "show only products missing German translations") — nice to have, could use Ransack on the translation join.

References

  • Existing per-product translation drawer: spree/admin/app/controllers/spree/admin/translations_controller.rb
  • CSV import type: spree/core/app/models/spree/imports/product_translations.rb
  • CSV export type: spree/core/app/models/spree/exports/product_translations.rb
  • Row processor: spree/core/app/services/spree/imports/row_processors/product_translation.rb
  • Sample data: spree/core/db/sample_data/product_translations.csv
  • Medusa centralized translations: conceptual inspiration for the overview approach