docs/plans/5.4-centralized-translations-admin.md
Status: Draft Target: Spree 5.4 Depends on: Markets (for locale configuration), Mobility (for translation storage) Author: Damian + Claude Last updated: 2026-04-04
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.
Spree::Imports::ProductTranslations and Spree::Exports::ProductTranslations types (already implemented in core).store.supported_locales_list (derived from market configuration) to determine which locales to show.Products (sidebar)
├─ Price Lists
├─ Stock
├─ Translations ← NEW
├─ Taxonomies
└─ Options
Position: 25 (between Stock at 20 and Taxonomies at 30).
┌─────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────┘
1. Summary cards / coverage table (top)
current_store.supported_locales_list - [default_locale]2. Product × locale grid (main content)
name translation in that locale, — (dash) if missing3. Action buttons (page header)
type: Spree::Imports::ProductTranslationstype: Spree::Exports::ProductTranslationsmodule 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
# In admin routes, under the products section
resources :product_translations, only: [:index]
To determine if a product has a translation for a given locale, query the spree_product_translations table:
# 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.
When no markets/locales are configured:
/admin/marketsProduct name in the grid links to the existing translation drawer:
<%= link_to product.name,
spree.edit_admin_translation_path(resource_type: 'Spree::Product', id: product.id),
data: { action: 'drawer#open', turbo_frame: :drawer } %>
No migrations needed. The translation table (spree_product_translations) and all core infrastructure already exist. This is purely an admin UI addition.
Spree::Imports::ProductTranslations, Spree::Exports::ProductTranslations) are already implemented and registered in engine.rb — no changes needed there.spree/admin/app/controllers/spree/admin/translations_controller.rbspree/core/app/models/spree/imports/product_translations.rbspree/core/app/models/spree/exports/product_translations.rbspree/core/app/services/spree/imports/row_processors/product_translation.rbspree/core/db/sample_data/product_translations.csv