Back to Spree

Metafield & MetafieldDefinition Translations

docs/plans/5.4-metafield-translations.md

5.4.212.1 KB
Original Source

Metafield & MetafieldDefinition Translations

Status: Draft Target: Spree 5.4 Depends on: Metafields system (shipped), TranslatableResource / Mobility infrastructure Author: Damian Last updated: 2026-04-08

Summary

Metafields and MetafieldDefinitions do not support translations. Products, Options, and Categories are translatable via Mobility, but custom field values and definition labels are locale-unaware. This plan adds translation support for MetafieldDefinition names (admin labels) and Metafield values (text types only), using Spree's existing Mobility + per-entity translation table pattern.

Key Decisions (do not deviate without discussion)

  • Only text-like metafield types are translatable — ShortText, LongText, RichText. Number, Boolean, and Json are NOT translatable. This matches industry consensus (Shopify, Saleor, Vendure all do the same).
  • No separate locale types — We do NOT introduce LocaleShortText / LocaleLongText (Vendure-style). Instead, all text types gain translation support automatically. Mobility's column_fallback handles "no translation available, use base value" gracefully.
  • MetafieldDefinition name is translatable — The admin-facing label for a custom field definition needs translation for multi-language admin UIs.
  • RichText translations stored as HTML in text column — Not ActionText. Aligns with 6.0-rich-text-descriptions.md plan to drop ActionText storage. Translated rich text goes in spree_metafield_translations.value as HTML.
  • Single translation table for all metafield values — One spree_metafield_translations table serves all STI types, consistent with how the base spree_metafields table works.
  • translatable? method on Metafield — Each STI subclass declares whether it supports translations. Controllers and serializers use this to decide behavior.

Design Details

Models

MetafieldDefinition — Translatable name

ruby
class Spree::MetafieldDefinition < Spree.base_class
  include Spree::TranslatableResource

  TRANSLATABLE_FIELDS = %i[name].freeze
  translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
end

Metafield — Translatable value (text types only)

ruby
class Spree::Metafield < Spree.base_class
  include Spree::TranslatableResource

  TRANSLATABLE_FIELDS = %i[value].freeze
  translates(*TRANSLATABLE_FIELDS, column_fallback: true)
  # column_fallback: true always — base value is the default-locale value

  # Override in subclasses
  def translatable?
    false
  end
end

STI Subclass Overrides

ruby
module Spree::Metafields
  class ShortText < Spree::Metafield
    def translatable?
      true
    end
  end

  class LongText < Spree::Metafield
    def translatable?
      true
    end
  end

  class RichText < Spree::Metafield
    def translatable?
      true
    end

    # For translations, store HTML directly in the translation table's
    # value column — do NOT use ActionText for translated values.
    # The base (default locale) value continues to use ActionText
    # until the 6.0 ActionText removal.
  end
end

Non-text types (Number, Boolean, Json) inherit the default translatable? # => false and never touch the translation table.

Translation Tables

spree_metafield_translations

ColumnTypeConstraints
idbigintPK
spree_metafield_idreferencesnot null
localestringnot null
valuetext
created_atdatetime
updated_atdatetime

Unique index: [spree_metafield_id, locale]

spree_metafield_definition_translations

ColumnTypeConstraints
idbigintPK
spree_metafield_definition_idreferencesnot null
localestringnot null
namestring
created_atdatetime
updated_atdatetime

Unique index: [spree_metafield_definition_id, locale]

Mobility Behavior

  • column_fallback ensures that when no translation exists for the requested locale, the base column value is returned. This is critical — metafield values written before translations were enabled continue to work.
  • store_based_fallbacks (Spree's custom Mobility plugin) provides per-store fallback chains. A French store might fall back fr → en, a German store de → en.
  • For non-translatable types, reads bypass the translation table entirely via translatable? check. The Mobility translates declaration on the base class only activates the join/lookup when the type supports it.

API Changes

Store API (read-only, locale-aware)

Custom fields are already serialized via CustomFieldSerializer. The current locale (from x-spree-locale header / Spree::Current.locale) determines which translation is returned. No API shape change needed — the value field simply returns the translated value for text types.

json
{
  "custom_fields": [
    {
      "key": "specs.care_instructions",
      "label": "Entretien",
      "type": "short_text",
      "value": "Lavage a 30C"
    },
    {
      "key": "specs.weight_grams",
      "label": "Poids",
      "type": "number",
      "value": 250
    }
  ]
}

Definition label (alias for name) is also translated via Mobility (the definition's name method already delegates through Mobility once we add the concern, and label delegates to name).

Admin API — Translation management

Admin serializers expose a translatable flag and translations hash for managing translations across locales:

Read (GET):

json
{
  "key": "specs.care_instructions",
  "label": "Care Instructions",
  "type": "short_text",
  "value": "Machine wash at 30C",
  "translatable": true,
  "translations": {
    "fr": { "value": "Lavage a 30C" },
    "de": { "value": "Maschinenwaesche bei 30C" }
  }
}

Non-translatable types omit translations or return an empty object:

json
{
  "key": "specs.weight_grams",
  "label": "Weight (grams)",
  "type": "number",
  "value": 250,
  "translatable": false
}

Write (PATCH):

json
{
  "translations": {
    "fr": { "value": "Lavage a 30C" }
  }
}

MetafieldDefinition Admin API — Label translations

json
{
  "namespace": "specs",
  "key": "care_instructions",
  "label": "Care Instructions",
  "label_translations": {
    "fr": "Entretien",
    "de": "Pflegehinweise"
  }
}

Serializer Changes

Store CustomFieldSerializer

ruby
module Spree::Api::V3
  class CustomFieldSerializer < BaseSerializer
    # value already reads from Mobility (current locale with fallback)
    # label already reads from definition (which is now translatable)
    # No changes needed — Mobility handles locale resolution transparently
  end
end

Admin CustomFieldSerializer

ruby
module Spree::Api::V3::Admin
  class CustomFieldSerializer < V3::CustomFieldSerializer
    typelize translatable: :boolean

    attribute :translatable do |metafield|
      metafield.translatable?
    end

    attribute :translations do |metafield|
      next {} unless metafield.translatable?

      metafield.translations.each_with_object({}) do |translation, hash|
        hash[translation.locale] = { value: translation.value }
      end
    end
  end
end

RichText Translation Details

The base (default locale) RichText value continues to use ActionText via has_rich_text. Translated values for non-default locales are stored as HTML strings in spree_metafield_translations.value.

When reading a RichText metafield:

  • Default locale: ActionText body.to_s (existing behavior)
  • Other locales: HTML string from the translation table

When writing a RichText translation:

  • Accept HTML string, sanitize on write (same sanitization rules as the base value)
  • Store sanitized HTML in the translation table's value column

This asymmetry is temporary — the 6.0-rich-text-descriptions.md plan will move all rich text to plain HTML columns, at which point RichText metafields will be fully symmetric.

Translation Coverage Tracking

Extend the existing ProductTranslationsController pattern to include metafield translation coverage:

  • Track which metafields have translations per locale
  • Surface in admin UI alongside product/taxon translation coverage
  • Include in the centralized translations admin page (per 5.4-centralized-translations-admin.md plan)

Migration Path

Step 1: Migration

ruby
class CreateSpreeMetafieldTranslations < ActiveRecord::Migration[7.2]
  def change
    create_table :spree_metafield_translations do |t|
      t.references :spree_metafield, null: false
      t.string :locale, null: false
      t.text :value

      t.timestamps
    end

    add_index :spree_metafield_translations,
              [:spree_metafield_id, :locale],
              unique: true,
              name: 'index_spree_metafield_translations_uniqueness'

    create_table :spree_metafield_definition_translations do |t|
      t.references :spree_metafield_definition, null: false
      t.string :locale, null: false
      t.string :name

      t.timestamps
    end

    add_index :spree_metafield_definition_translations,
              [:spree_metafield_definition_id, :locale],
              unique: true,
              name: 'index_spree_mf_def_translations_uniqueness'
  end
end

Step 2: Model changes

  1. Add TranslatableResource to MetafieldDefinition with translates :name
  2. Add TranslatableResource to Metafield with translates :value, column_fallback: true
  3. Add translatable? method to base Metafield (returns false)
  4. Override translatable? in ShortText, LongText, RichText (returns true)

Step 3: Serializer changes

  1. Update Admin CustomFieldSerializer to include translatable and translations
  2. Update Admin MetafieldDefinitionSerializer to include label_translations
  3. Store API serializers need no changes (Mobility handles locale resolution)

Step 4: Controller changes

  1. Add translation params to Admin metafield update endpoints
  2. Support translations hash in permitted params for text-type metafields
  3. Support label_translations hash in permitted params for definitions

Step 5: Tests

  1. Model specs: translation read/write for each text type, verify non-text types ignore translations
  2. Serializer specs: verify translatable flag and translations hash
  3. API integration specs: round-trip translation CRUD
  4. Fallback specs: verify column_fallback and store_based_fallbacks work correctly

Constraints on Current Work

  • Do not add locale-specific metafield types (no LocaleShortText etc.). The translatable? method on existing types is the mechanism.
  • Do not store translated rich text in ActionText. Use plain HTML in the translation table.
  • Do not translate Number, Boolean, or Json metafield values. If a use case emerges for translatable JSON (e.g. structured locale content), it should be a separate proposal.
  • When adding new metafield types, always implement translatable? — default to false unless the type is text-based.

Open Questions

  1. Should description be added to MetafieldDefinition? Currently definitions only have name. A translatable description field could help merchants understand what each custom field is for. Low priority but worth considering.
  2. Bulk translation import/export for metafields — Should this integrate with the centralized translations admin CSV import/export (per 5.4-centralized-translations-admin.md), or be a separate flow?
  3. Metafield translation via Product translation UI — When translating a product, should its translatable metafield values appear inline, or require navigating to a separate custom fields translation view?

References

  • 6.0-rich-text-descriptions.md — ActionText removal plan (affects RichText metafield storage)
  • 5.4-centralized-translations-admin.md — Centralized translations admin page
  • 5.4-6.0-custom-fields-rename.md — Metafields → Custom Fields rename
  • Shopify Translations API — metafield as translatable resource, text types only
  • Saleor AttributeTranslation — per-entity translation tables, attribute name + value translation
  • Vendure localeString/localeText — type-level translation decision, column on translation table