Back to Spree

Resource Translations — Admin API & Dashboard

docs/plans/5.5-6.0-resource-translations-api.md

5.6.034.7 KB
Original Source

Resource Translations — Admin API & Dashboard

Status: Draft Target: Spree 5.5 (Phase 1 API) → 6.0 (staleness + bulk + metafields fold-in) Depends on: Mobility / Spree::TranslatableResource (shipped), Markets (locale config, shipped), Admin API v3 (shipped), 5.4-centralized-translations-admin.md, 5.4-metafield-translations.md Author: Damian + Claude Last updated: 2026-06-24

Summary

Spree stores translations via Mobility (table backend, one *_translations row per (record_id, locale)), and reads are already locale-aware — the Store/Admin serializers return the value for Mobility.locale, which Spree::Api::V3::LocaleAndCurrency resolves from the x-spree-locale header. What is missing is a write path in the API v3 and a way for the React dashboard to manage all locales at once. Today the only translation-editing UI is the legacy Rails admin TranslationsController (Turbo drawer, Mobility locale accessors like name_fr=).

This plan adds translation management to the Admin API v3 and the @spree/dashboard SPA, covering every model in the Spree.translatable_resources registry (Product, Taxon/category, Taxonomy, OptionType, OptionValue, Store, Policy — and later MetafieldDefinition/Metafield). After comparing Shopify, Vendure, Medusa, and Saleor, writes go through a single atomic batch endpoint (POST /admin/translations/batch, a flat list of per-record { locale → { field → value } } writes — so one request saves an option type and all its values together), while reads are a registry-driven, read-only per-resource matrix (GET …/:id/translations, with nested translatable children) plus an opt-in ?expand=translations. Backed by self-describing field discovery and server-side, advisory staleness (Shopify's idea minus its mandatory-digest footgun).

How competitors do it (and what we take)

PlatformWrite shapeRead shapeField discoveryStalenessLocale model
ShopifyDedicated translationsRegister (per field+locale, batched per resource)translatableResource (all locales + source + digest); storefront via @inContexttranslatableContent enumerates keys + typeHard digest (SHA-256 of sanitized source; required on every write)shopLocales (primary + published)
VendureEmbedded translations: [{ languageCode, … }] on entity create/update; upsert by languageCode, omit-to-leave-alone, never implicit-deleteDual: active-locale value at top level and sibling translations arraySchema-declared (LocaleString fields + XTranslationInput)NoneavailableLanguages (global) + per-channel defaultLanguageCode/availableLanguageCodes
Medusa v2Dedicated Translation Module; { reference, reference_id, locale_code, translations: {field→value} } (one locale per row, JSON bag of fields)Read-time overlay onto native fields; locale via ?locale= / x-medusa-locale; GET /store/locales discoveryModel .translatable() + admin Settings togglesNone (only translated_field_count)Locale rows + store-level active set
SaleorPer-resource productTranslate(id, languageCode, input) — one resource, one language, many fieldsInline translation(languageCode:) returns a *Translation object beside default fields*TranslatableContent types + top-level translations(kind:) enumerationNoneGlobal LanguageCodeEnum; no per-store allowlist

Decisions distilled from the matrix:

  • Use the { locale → { field → value } } object shape Vendure pioneered (but as an object, not an array — Mobility stores per-field, so a single field can be written without resending the row). It rides on the dedicated endpoint, not embedded in the resource update.
  • Take Shopify/Medusa's dedicated endpoint for standalone/per-resource and coverage workflows — but make it one generic controller resolved from the registry (Saleor's per-resource explosion is the cautionary tale; we already have a generic legacy TranslationsController proving the pattern). Cross-record bulk stays on CSV (async import/export), not a JSON endpoint — no competitor ships a generic JSON bulk route and Spree already has the CSV machinery.
  • Take Shopify's self-describing discovery — fold it into the dedicated read, plus a registry endpoint, so the SPA renders editors and grid columns with zero per-model code.
  • Take Shopify's staleness idea, drop its hard requirement — track a source digest server-side, expose a stale flag, but never require a digest on write (client-side digest computation is a footgun; sanitizer differences make it unreproducible).
  • Reuse markets for localessupported_locales_list (enabled) + default_locale (primary); no new Locale entity, no published/staged distinction in v1.

Key Decisions (do not deviate without discussion)

  1. Single write surface: the batch endpoint. All translation writes go through POST /api/v3/admin/translations/batch — a flat array of { resource_type, resource_id, values: { locale: { field } } }, upserted atomically in one transaction via TranslatableResource#upsert_translations (a model method beside the existing readers — no service/DI). One entry = the degenerate single-resource case (a product); N entries = a multi-record save (an option type plus all its option values, in one atomic request). Reads are a separate, read-only per-resource matrix (GET …/:id/translations, with nested translatable children) plus an opt-in ?expand=translations. No per-resource write endpoint — the dashboard's only write path is the batch endpoint, so a separate per-resource POST/DELETE would be pure redundancy (decided after the Medusa research: Medusa proves one atomic {create/update/delete}-style batch beats N per-resource requests; the multi-entity dual-save genuinely needs atomicity a per-resource endpoint can't give). No embedded write (3 of 4 competitors keep writes off the resource update). Reject per-model translate controllers (Saleor's verbosity). Reject a JSON cross-record bulk endpoint distinct from this — cross-record bulk is CSV import/export (§C); the batch endpoint is bounded (one editor's worth of entities), not a mass-import tool.
  2. One generic Spree::Api::V3::Admin::TranslationsController, mounted via a :translatable route concern (mirroring the existing :custom_fieldable concern), with the parent model resolved by set_parent and validated against Spree.translatable_resources. No per-model translation controllers.
  3. Canonical payload shape is { locale → { field → value } }. This matches the 5.4-metafield-translations.md plan's already-committed shape ("translations": { "fr": { "value": … } }), so metafields fold in as just-another-registered-resource later.
  4. Upsert semantics: omit-to-leave-alone, never implicit-delete (Vendure's confirmed contract). Locale absent → untouched. Field absent within a present locale → untouched (per-field partial PATCH). Field = "" → write empty string (explicit clear; read falls back to source via column_fallback). Field = null → delete that field's translation cell. All of this flows through the single batch endpoint; there is no separate per-locale DELETE route.
  5. Read symmetry is preserved and unchanged for the storefront. The Store API continues to overlay the active-locale value onto the native field (name, description) via Mobility + x-spree-localeno parallel name_translations field on the storefront. Only the admin gains an all-locales read and the write surfaces. Read and write use the same field keys everywhere (where a column is legacy-named, the model alias already bridges it — e.g. OptionType#label/label=).
  6. Self-describing fields. The dedicated read returns a fields[] array (key + type + source) so the SPA renders generically; plus a GET /api/v3/admin/translatable_resources registry endpoint for the no-record case (centralized page columns, tooling).
  7. Locales come from markets. Enabled = current_store.supported_locales_list; primary = current_store.default_locale; read-time selection = existing x-spree-locale resolution; missing-translation fallback = existing column_fallback + store_based_fallbacks. Writes to an unsupported locale return a typed 422 (à la Vendure's LanguageNotAvailableError), never a silent accept.
  8. Staleness is server-side and advisory (Phase 2). Store a source_digest per translation cell; compute stale at read time; never require a digest on write. Purely additive — does not break the Phase 1 contract.
  9. OptionValue is its own translatable resource, not a nested special-case of OptionType. It declares TRANSLATABLE_FIELDS and is in the registry, so it's writable via the batch endpoint and discoverable. For reads it's returned inline as a child of an option type's matrix (one editor fetches the type plus all its values in one request), so it needs no standalone read route. (The legacy admin special-cases OptionValue inside OptionType — we keep the one-request read but drop the special-casing in the write path.)
  10. Feature is registry-driven, not hardcoded. Adding a translatable model = add it to Spree.translatable_resources + declare TRANSLATABLE_FIELDS. No new controller, route, or serializer per model.
  11. translations on GET is opt-in via ?expand=translations, never an always-on serializer attribute (the existing CountrySerializer states/market precedent). The matrix is costly (non-default locales × fields × joins) and unwanted on list/most-detail reads. params[:expand] is already in the HTTP cache key, so this is free and conventional. Expand returns values only; discovery metadata (fields[]/source/type) stays on the dedicated …/:id/translations endpoint.
  12. Cross-record bulk is product-only and already covered by CSV. In practice bulk translation matters for products; Spree already ships async product translation CSV import/export. Per-resource editing + discovery remain registry-generic (taxons, options, etc. translate via the API), but no new bulk surface is built for them in Phase 1 — generalizing CSV across the registry is a Phase 2 nicety, not a blocker.

Design Details

Locale resolution recap (already built — no change)

Spree::Api::V3::LocaleAndCurrency resolves the request locale: x-spree-locale header → params[:locale]Spree::Current.locale (market → store fallback), validated against supported_locale?. It sets Mobility.locale / I18n.locale and configures store fallbacks. The base ResourceController#scope already calls .i18n for translatable models. Storefront reads need zero new work.

The write API lives on TranslatableResource (not a service)

The write mechanics belong on the model that already owns translation behavior — every translatable model includes Spree::TranslatableResource, which already declares translatable_fields and the get_field_with_locale reader. Two instance methods sit beside them, so no ServiceModule ceremony, no DI registration, and no record.store indirection (the model knows its own store). The earlier draft routed this through a Spree::Translations::Upsert/DestroyLocale service pair — collapsed into the model because each was a thin wrapper over a single operation called from one or two places.

ruby
module Spree
  module TranslatableResource
    # ...existing reader + class methods...

    # Omit-to-leave-alone; "" clears, nil deletes a cell. Field/locale filtering
    # and validation happen here.
    # @raise [ActiveRecord::RecordInvalid] on an unsupported locale or invalid save
    def upsert_translations(values)
      return self if values.blank?

      validate_translation_locales!(values.keys)
      allowed = self.class.translatable_fields.map(&:to_s)

      transaction do
        values.each do |locale, fields|
          Mobility.with_locale(locale) do
            fields.to_h.slice(*allowed).each { |field, value| public_send("#{field}=", value) }
          end
        end
        save!
      end
      self
    end

    # Deletes the locale's translation row directly (a presence-validated field
    # like Product#name would reject a nil-and-resave).
    def destroy_locale_translation(locale)
      rows = translations
      rows = rows.with_deleted if rows.respond_to?(:with_deleted)
      rows.where(locale: locale.to_s).delete_all
      reload
      self
    end

    private

    def validate_translation_locales!(locales)
      supported = translatable_store&.supported_locales_list || []
      unsupported = locales.map(&:to_s) - supported.map(&:to_s)
      return if unsupported.empty?

      errors.add(:base, :unsupported_locale, message: "Unsupported locale(s): #{unsupported.join(', ')}")
      raise ActiveRecord::RecordInvalid, self
    end
  end
end

Both write paths call record.upsert_translations(values) and rescue ActiveRecord::RecordInvalidrender_validation_error(e.record.errors) (422). Value normalization (HTML sanitization, slug parameterization) stays on the model's own field writers — upsert_translations only routes values to them. No Spree::Dependencies entry: the behavior is on the model, overridable like any model method via a decorator.

Note on rich text: Taxon#description and Policy#body use the ActionText Mobility backend today. Translated HTML must be sanitized on write per the 6.0-rich-text-descriptions.md plan. The fields[].type == "html" hint (below) tells both the serializer and the sanitizer how to treat the field.

A. Dedicated nested endpoint (contract of record)

Route concern mirrors :custom_fieldable:

ruby
# spree/api/config/routes.rb (Admin namespace)
# Read-only — writes go through POST /translations/batch (decision #1).
concern :translatable do
  resources :translations, only: [:index], controller: 'translations'
end

resources :products,    concerns: [:custom_fieldable, :translatable] doend
resources :categories,  only: [:index, :show], concerns: [:custom_fieldable, :translatable]
resources :option_types, concerns: [:custom_fieldable, :translatable]
# Phase 1 mounts the read route on products, categories, and option_types only.
# Other registered resources (option_value, taxonomy, store, policy) are still
# writable via the batch endpoint; the discovery registry flags each entry with
# `readable` so a client never GETs a matrix that isn't routed. Mounting their
# read routes is a follow-up.

One generic controller:

ruby
module Spree::Api::V3::Admin
  # Generic translations controller. The parent model is resolved from the
  # nested route and validated against Spree.translatable_resources. One
  # controller serves every translatable model — no per-model subclasses.
  class TranslationsController < ResourceController
    before_action :set_parent
    before_action :ensure_translatable!

    # GET .../:parent/:parent_id/translations  (read-only)
    def index
      render json: serialize_translations(parent_resource)
    end

    # Reads only. Per decision #1, the single write surface is
    # POST /api/v3/admin/translations/batch — there is no per-resource
    # create/update/destroy here.

    private

    def ensure_translatable!
      raise ActiveRecord::RecordNotFound unless Spree.translatable_resources.include?(@parent.class)
    end
  end
end

GET …/products/prod_86Rf07xd4z/translations — management read (all locales + source + discovery):

json
{
  "data": {
    "resource_type": "product",
    "resource_id": "prod_86Rf07xd4z",
    "default_locale": "en",
    "supported_locales": ["en", "de", "fr", "es"],
    "fields": [
      { "key": "name",             "type": "string", "source": "Espresso Machine" },
      { "key": "description",      "type": "html",   "source": "<p>Pulls a great shot.</p>" },
      { "key": "slug",             "type": "slug",   "source": "espresso-machine" },
      { "key": "meta_title",       "type": "string", "source": "Espresso Machine | Acme" },
      { "key": "meta_description", "type": "string", "source": "Buy the Acme espresso machine." }
    ],
    "translations": {
      "de": { "name": "Espressomaschine", "description": "<p>Zieht…</p>", "slug": "espressomaschine", "meta_title": "Espressomaschine | Acme", "meta_description": "Kaufen…", "translated_field_count": 5 },
      "fr": { "name": "Machine à espresso", "description": null, "slug": "machine-a-espresso", "meta_title": null, "meta_description": null, "translated_field_count": 2 },
      "es": { "name": null, "description": null, "slug": null, "meta_title": null, "meta_description": null, "translated_field_count": 0 }
    }
  }
}

fields[].type (string / html / slug) drives generic SPA rendering (plain input vs rich-text editor vs slug field). translated_field_count (Medusa's cheap completeness counter) drives progress bars on the centralized page.

POST …/products/prod_86Rf07xd4z/translations — upsert:

json
{
  "translations": {
    "fr": { "description": "<p>Tire un excellent shot.</p>", "meta_title": "Machine à espresso | Acme" },
    "es": { "name": "Máquina de espresso", "slug": "maquina-de-espresso" }
  }
}

Response 200 echoes the GET shape. Validation errors (unsupported locale, invalid slug, sanitizer rejection) return standard v3 422 keyed translations.fr.slug so mapSpreeErrorsToForm routes them inline.

DELETE …/products/prod_86Rf07xd4z/translations/fr204.

B. ?expand=translations read on the resource (no embedded write)

There is no embedded write — translations are written only through the dedicated …/:id/translations endpoint (§A). The research is decisive: 3 of 4 competitors (Shopify, Medusa, Saleor) keep translation writes on a separate surface; only Vendure embeds them in the resource update. Adding an embedded write meant a controller concern overriding create/update plus transaction-wrapping that interfered with other inline-nested writes (custom fields), for a convenience nothing in the SPA uses (the translations UI saves through the dedicated endpoint). Cut.

The read side stays as a convenience: the admin ProductSerializer exposes a translations matrix attribute, opt-in via the existing ?expand= convention (CountrySerializer's states/market precedent), so a client can fetch the matrix alongside a product in one read without a second call.

ruby
module Spree::Api::V3::Admin
  class ProductSerializer < V3::ProductSerializer
    attribute :translations, if: proc { expand?('translations') } do |product|
      Spree::Translations.matrix_for(product) # { "de" => { "name" => …, "translated_field_count" => 5 }, … }
    end
  end
end

Why opt-in, not always-on: the matrix is non-default-locales × translatable fields × translation-table joins — wasteful on list views and detail views that don't edit translations. params[:expand] is already part of the HTTP cache key, so expanded and non-expanded responses cache separately for free. Store serializers never get translations — customers see only the resolved active-locale value.

Expand returns values only; the dedicated endpoint returns values + discovery. ?expand=translations returns just { locale → { field → value } }. The discovery metadata (fields[] with key/type/source, supported_locales) lives only on GET …/:id/translations (§A) — that's what the dashboard editor consumes, since it needs source + type to render fields generically.

C. Bulk = CSV import/export (generalized), not a JSON bulk endpoint

Decision: no dedicated JSON bulk-write endpoint. It would be redundant and the weaker tool. The bulk story is two things, neither of which is a new synchronous JSON route:

  1. True bulk = CSV import/export, generalized across the registry. A slug/identifier-keyed CSV importer + exporter, run async (background Spree::Import/Spree::Export job), is already the right shape for "translate 500 products across 6 locales" — and it's the channel the 5.4-centralized-translations-admin.md plan already commits to.
  2. Coverage read = a thin GET the centralized grid needs to render ✓/— cells — cheap, and not redundant with import.

Why no JSON bulk endpoint:

  • CSV already covers it, async. A synchronous JSON POST …/bulk over hundreds of records × locales is a timeout/partial-failure hazard; the existing Spree::Import runs in the background and reports per-row errors — strictly better for the actual bulk job.
  • No competitor validates a generic cross-resource JSON bulk endpoint. Shopify (translationsRegister), Vendure (embedded array), and Medusa all write one resource per call. Saleor reluctantly added productBulkTranslate only to amortize GraphQL round-trips — still product-only, one language per entry, never generic/cross-resource. REST + the registry-driven per-resource endpoint (§A) doesn't have the round-trip problem that forced Saleor's hand.
  • The per-resource endpoint (§A) already batches multi-locale + multi-field in one call. The genuinely different shape — many records at once — is the CSV job's job.

The real gap to close (Phase 2): Spree's CSV import/export exists for products only (Spree::Imports/Exports::ProductTranslations, slug-keyed, fields name/description/meta_title/meta_description). There is no taxon/option-type/policy/store translation importer. Generalize the existing pattern across Spree.translatable_resources:

  • A registry-driven Spree::Imports::ResourceTranslations / Spree::Exports::ResourceTranslations parameterized by resource_type, keyed by the resource's natural identifier (slug/permalink/name), columns derived from TRANSLATABLE_FIELDS (one column per field × non-default locale, or a long format with a locale column as products do today).
  • Reuses the existing async Spree::Import/Spree::Export machinery, the admin ExportsController, and the import/export drawer plumbing (5.5-admin-spa-csv-export.md).
  • Keep the existing ProductTranslations types working (back-compat) or fold them into the generic one.

Coverage read (keep — drives the grid):

GET /api/v3/admin/translations?resource_type=product&q[…]&page=1 — paginated coverage matrix (single array_agg(locale) query, no per-row N+1 — replaces the legacy ProductTranslationsController#build_translated_locales_map):

json
{
  "data": {
    "resource_type": "product",
    "default_locale": "en",
    "locales": ["de", "fr", "es"],
    "coverage": [
      { "locale": "de", "translated": 36, "total": 36, "coverage": 1.0 },
      { "locale": "fr", "translated": 34, "total": 36, "coverage": 0.94 },
      { "locale": "es", "translated": 0,  "total": 36, "coverage": 0.0 }
    ],
    "records": [
      { "id": "prod_86Rf07xd4z", "name": "Espresso Machine",  "locales": { "de": true, "fr": true,  "es": false } },
      { "id": "prod_9aKd21mn7p", "name": "Drip Coffee Maker", "locales": { "de": true, "fr": false, "es": false } }
    ]
  },
  "meta": { "total_count": 36, "total_pages": 2, "page": 1 }
}

Inline grid edits (e.g. "fill the missing es name on this row") write through the per-resource endpoint (§A), one record at a time — the user is already looking at that row. Mass fills go through CSV.

Discovery endpoints

GET /api/v3/admin/translatable_resources — the registry made public (drives centralized-page columns + tooling, no record needed):

json
{
  "data": [
    { "resource_type": "product",      "fields": [ {"key":"name","type":"string"}, {"key":"description","type":"html"}, {"key":"slug","type":"slug"}, {"key":"meta_title","type":"string"}, {"key":"meta_description","type":"string"} ] },
    { "resource_type": "category",     "fields": [ {"key":"name","type":"string"}, {"key":"description","type":"html"}, {"key":"permalink","type":"slug"} ] },
    { "resource_type": "option_type",  "fields": [ {"key":"presentation","type":"string"} ] },
    { "resource_type": "option_value", "fields": [ {"key":"presentation","type":"string"} ] },
    { "resource_type": "taxonomy",     "fields": [ {"key":"name","type":"string"} ] },
    { "resource_type": "store",        "fields": [ {"key":"name","type":"string"}, {"key":"seo_title","type":"string"} ] },
    { "resource_type": "policy",       "fields": [ {"key":"name","type":"string"}, {"key":"body","type":"html"} ] }
  ]
}

Derived from Spree.translatable_resources.map { |k| [k, k.translatable_fields] } plus a small type inference (html for description/body, slug for slug/permalink, else string).

GET /api/v3/locales (Store) and GET /api/v3/admin/locales (Admin) — so clients enumerate instead of hardcoding (Medusa's GET /store/locales):

json
{
  "data": [
    { "code": "en", "name": "English",  "default": true,  "rtl": false },
    { "code": "de", "name": "Deutsch",  "default": false, "rtl": false },
    { "code": "fr", "name": "Français", "default": false, "rtl": false },
    { "code": "ar", "name": "العربية",  "default": false, "rtl": true }
  ]
}

Built from supported_locales_list + default_locale; names via the existing Spree::LocaleHelper.

Staleness (Phase 2 — additive)

  • Add a source_digest column to each *_translations table (or a shared mechanism in TranslatableResource). On each cell write, store Digest::SHA256.hexdigest(current_source_value) for that field.
  • Compute stale = stored_source_digest != current_source_digest at serialization time. Surface it in a verbose mode of the management read (?include=staleness), which enriches each value to an object — keeping the default flat { field → value } shape simple for the common form path:
json
"fr": {
  "name": { "value": "Machine à espresso", "stale": true,  "updated_at": "2026-01-10T…" },
  "slug": { "value": "machine-a-espresso", "stale": false, "updated_at": "2026-06-01T…" }
}
  • Writes never send a digest. Optionally support an advisory If-Source-Digest per-field guard (opt-in 409 on mismatch) for concurrency-sensitive tooling; default is accept-and-record. This gives Spree Shopify's "outdated" UX without the mandatory-round-trip footgun, and beats Vendure/Saleor/Medusa's zero tracking.

React dashboard UX

One API contract powers both surfaces (the 5.4-centralized-translations-admin.md plan + the per-resource drawer):

Surface 1 — Per-resource translation editor: a full-page spreadsheet dialog (opened from a "Translations" launcher card on the product edit page; not a side sheet like the legacy Rails admin). Modeled on Medusa's translations editor and our bulk-price editor dialog (bulk-price-editor-dialog.tsx):

  • A locale dropdown in the header (like the currency Select in the bulk price editor) picks one target locale at a time, showing its localized name (from GET /admin/locales).
  • A compact 3-column spreadsheet: rows = translatable fields, columns = Field | Original (read-only source) | <selected locale>. Editable cells are borderless Textareas that fill the cell (true spreadsheet feel); type: html fields use the rich-text editor.
  • Edits accumulate across locales (switching the dropdown keeps pending edits) and persist in one POST …/translations with the full { locale → { field → value } } matrix. Dirty count + discard-confirm in the header. 422s toast via mapSpreeErrorsToForm.
  • Fetches the matrix from the dedicated GET …/:id/translations (its own dirty-state/submit, independent of the product form). SDK calls go through a useProductTranslations(id) hook; query key includes useStore().storeId (per repo convention).

Surface 2 — Centralized Translations page (under Products in the nav, per the existing plan):

  • Coverage grid powered by GET /api/v3/admin/translations?resource_type=product (one query). Phase 2 stale flags add an amber "outdated" cell state.
  • Bulk Import/Export reuse the existing CSV infra via the ExportsController + exports drawer (5.5-admin-spa-csv-export.md), generalized across the registry (§C). Inline single-row cell edits write through the per-resource endpoint (§A); mass fills go through CSV. No JSON bulk endpoint.
  • Clicking a row/cell deep-links to Surface 1 for that resource+locale.
  • Tabs per resource_type (Products → Categories → Options …) come free — the grid read is parameterized by resource_type and the column set comes from GET /admin/translatable_resources. Adding a tab is config, not code.

All labels go through i18next across every language file in packages/dashboard/src/locales/ and packages/dashboard-core/src/locales/ (en/de/fr/zh-CN/ar/pl), per repo convention. Locale-name display reuses GET /admin/locales.

Migration Path

Phase 1 (5.5) — read-all + write, no staleness:

  1. TranslatableResource#upsert_translations + #destroy_locale_translation (write API on the concern every translatable model already includes; no service/DI).
  2. :translatable route concern + generic Spree::Api::V3::Admin::TranslationsController (index/create/destroy).
  3. Promote OptionValue to its own nested translatable route (…/option_types/:id/option_values/:id/translations).
  4. (removed — no embedded write path.)
  5. translations ?expand attribute on the admin serializers for registry members (extends store serializer, admin-only — never on store serializers).
  6. GET /api/v3/admin/translatable_resources + GET /api/v3/{,admin/}locales discovery endpoints + serializers.
  7. Integration specs (happy path + 422 for unsupported locale / invalid slug) to generate OpenAPI examples; controller specs for upsert/destroy semantics, locale/field allowlisting, authorization (CanCanCan :update on parent). Run the full type-generation pipeline (typelizer → zod → rswag) per CLAUDE.md.

Phase 2 (6.0) — bulk + staleness + SPA centralized page: 8. GET /api/v3/admin/translations coverage matrix; generalize CSV import/export across the registry (Spree::Imports/Exports::ResourceTranslations, parameterized by resource_type) — no JSON bulk endpoint. 9. source_digest columns (data backfill via rake task, never in migration) + stale computation + ?include=staleness verbose read. 10. Dashboard: per-resource translation tab (Surface 1) + centralized Translations page (Surface 2) consuming the above.

Phase 3 (6.0) — fold in metafields: 11. Per 5.4-metafield-translations.md, register MetafieldDefinition (translatable name) and Metafield (translatable value, text types only) in Spree.translatable_resources. They become just-another-registered-resource — the {locale:{field}} write shape that plan already committed to is a subset of this contract, so no new endpoint is needed.

Constraints on Current Work

  • Never add a parallel *_translations field to a Store (customer-facing) serializer. Storefront reads resolve to the active locale via Mobility — translations matrices are admin-only.
  • Read/write field-name symmetry is mandatory. A field exposed in the translations matrix under key X must be writable under key X. Legacy-named columns get a model-level alias (the OptionType label/label= precedent), never a controller/serializer/client-side mapper.
  • When adding a translatable model, add it to Spree.translatable_resources and declare TRANSLATABLE_FIELDS. Do not write a per-model translation controller, route, or serializer — the generic controller + registry handle it.
  • Translate HTML fields safely: translated description/body (and future RichText metafields) must be sanitized on write per 6.0-rich-text-descriptions.md. Use the fields[].type == "html" hint.
  • Validate locales at the store boundary, not per-record. Writes to a locale outside supported_locales_list return 422, never a silent accept.
  • Do not require a digest on write if/when staleness lands — it stays advisory.

Open Questions

  1. Store translations route — Store is a singleton in the SPA's store-scoped context; does its translation editor live under store settings (…/settings/general/translations) rather than a nested resource route? Likely yes; confirm the route shape.
  2. Published vs enabled locales — Shopify distinguishes published (buyer-visible) from enabled (translatable but staged). Spree has no such distinction; supported_locales_list = enabled = visible. If staging is later wanted, add a published_locales subset on Market — out of scope for v1; flagged, not built.
  3. Per-market translation overrides — Shopify allows a translation scoped to a single market (marketId on the translation). Spree's Mobility model is per-locale, not per-market. Defer unless a concrete need emerges; would require a schema change (market_id on translation rows).
  4. Slug translation conflicts — translated slug/permalink must stay unique within the store per locale; confirm the existing TranslatableResourceSlug + uniqueness handling covers API writes, and that a 422 surfaces cleanly.
  5. Variant name / option_value generated presentations — do generated/derived fields need translation, or only authored ones? Restrict to authored TRANSLATABLE_FIELDS for v1.
  6. Generalized CSV schema — when extending CSV import/export across the registry, pick the row format: long (one locale column, as products do today) vs wide (one column per field×locale). Long is simpler and matches the existing product importer; wide is friendlier for a translator editing one spreadsheet. And the join key per resource type (slug/permalink/name) — confirm each registry member has a stable, unique-per-store natural identifier for the CSV to key on.

References

  • docs/plans/5.4-centralized-translations-admin.md — centralized overview grid + CSV bulk (the dashboard Surface 2 this API powers); keep consistent.
  • docs/plans/5.4-metafield-translations.md — already commits to the {locale:{field}} write shape; folds in as Phase 3.
  • docs/plans/6.0-rich-text-descriptions.md — HTML sanitization for translated rich-text fields.
  • docs/plans/6.0-replace-taxons-with-categories.md — Taxon→Category naming for the category translatable resource.
  • Existing infra: spree/core/app/models/concerns/spree/translatable_resource.rb, spree/admin/app/controllers/spree/admin/translations_controller.rb (generic-controller + OptionValue-nesting template), spree/core/lib/spree/core/engine.rb:245 (registry), spree/api/app/controllers/concerns/spree/api/v3/locale_and_currency.rb (locale resolution), spree/api/config/routes.rb (:custom_fieldable concern to mirror), spree/core/app/models/concerns/spree/stores/markets.rb (supported_locales_list/default_locale).
  • Competitor docs: Shopify translationsRegister/translatableResource; Vendure translatable guide + translation-differ.ts; Medusa v2 Translation Module (docs.medusajs.com/resources/commerce-modules/translation); Saleor translations API conventions.