Back to Spree

Option Type Enhancements

docs/plans/5.4-option-type-enhancements.md

5.4.211.1 KB
Original Source

Option Type Enhancements

Status: Implemented Target: Spree 5.4 Depends on: 5.4-store-api-naming-standardization.md (presentationlabel bridge) Author: Damian + Claude Last updated: 2026-04-02

Summary

Spree's OptionType and OptionValue models are structurally sound — global, shared, M2M with products, proper disjunctive faceting — but lack presentation metadata. A storefront receiving an option type has no API-level signal for how to render it (dropdown vs. color swatches vs. size buttons) or any visual data on option values (hex color, swatch image). Every Spree frontend must invent its own convention or rely on the hardcoded color? name check.

This plan adds a kind column to OptionType (rendering hint) and color_code + image to OptionValue (visual metadata), closing the last significant gap vs. Shopify and Saleor without overcomplicating the model.

Problem

  1. No rendering hint on OptionType. The filters endpoint returns type: "option" for every option type. The frontend cannot distinguish Color (should render as swatches) from Size (should render as buttons) from Material (should render as dropdown) without hardcoding option type names.

  2. No visual metadata on OptionValue. Shopify has ProductOptionValueSwatch (hex color + image). Saleor has AttributeValue.value (hex) + file_url (image). Spree has nothing — the only visual hint is a hardcoded color? method checking if the option type name is "color" or "colour".

  3. Metafields are not the answer. While metafields could theoretically store color codes, there's no API contract — frontends don't know which metafield key to look for, and the filters endpoint doesn't include metafield data on option values.

Key Decisions (do not deviate without discussion)

  • kind is a rendering hint, not a behavioral change. It tells the frontend how to display the option. The underlying data model (OptionType → OptionValue → Variant) is unchanged. All kinds use the same predefined value pool.
  • Only kinds that make sense for variant axes. No text_input, file, rich_text, etc. — those are not variant-defining (see MetafieldDefinition for typed attributes, line_item.metadata for customer input at purchase time).
  • Three kinds: dropdown, color_swatch, buttons. Covers all standard e-commerce option rendering patterns. dropdown is the default for backward compatibility.
  • color_code and image on OptionValue are optional. They are presentation-only fields. An option value without a color code or image renders normally (by label). Having a color_code without kind: color_swatch on the parent type is allowed (no coupling).
  • Exposed in both the filters endpoint and product serializers. Frontends get the rendering hint everywhere options appear.
  • color? method kept but deprecated. Replaced by kind == 'color_swatch' check. Remove color? in 6.0.
  • Image uses Active Storage attachment, consistent with how Spree handles images elsewhere (product images, category icons). Not a URL string column.

Design Details

Schema Changes

ruby
class AddKindToSpreeOptionTypes < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_option_types, :kind, :string, null: false, default: 'dropdown'
    add_index :spree_option_types, :kind

    # Backfill: option types named 'color'/'colour' get kind 'color_swatch'
    reversible do |dir|
      dir.up do
        execute "UPDATE spree_option_types SET kind = 'color_swatch' WHERE name IN ('color', 'colour')"
      end
    end
  end
end
ruby
class AddVisualMetadataToSpreeOptionValues < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_option_values, :color_code, :string   # e.g. "#FF0000", nullable
  end
end

Model Changes

ruby
class Spree::OptionType < Spree.base_class
  KINDS = %w[dropdown color_swatch buttons].freeze

  validates :kind, presence: true, inclusion: { in: KINDS }

  scope :color_swatches, -> { where(kind: 'color_swatch') }

  def color_swatch?
    kind == 'color_swatch'
  end

  # Deprecate old method
  def color?
    Spree::Deprecation.warn(
      'Spree::OptionType#color? is deprecated. Use #color_swatch? instead. Will be removed in Spree 6.0.'
    )
    color_swatch?
  end
end
ruby
class Spree::OptionValue < Spree.base_class
  has_one_attached :image

  # Validates hex color format when present
  validates :color_code, format: { with: /\A#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?\z/, message: 'must be a valid hex color (e.g. #FF0000)' },
                         allow_blank: true
end

Serializer Changes

ruby
# Store API
module Spree::Api::V3
  class OptionTypeSerializer < BaseSerializer
    typelize name: :string, label: :string, position: :number, kind: :string

    attributes :name, :label, :position, :kind
  end
end
ruby
module Spree::Api::V3
  class OptionValueSerializer < BaseSerializer
    typelize name: :string, label: :string, position: :number, option_type_id: :string,
             option_type_name: :string, option_type_label: :string,
             color_code: 'string | null', image_url: 'string | null'

    attribute :option_type_id do |option_value|
      option_value.option_type&.prefixed_id
    end

    attributes :name, :label, :position, :color_code

    attribute :option_type_name do |option_value|
      option_value.option_type.name
    end

    attribute :option_type_label do |option_value|
      option_value.option_type.label
    end

    attribute :image_url do |option_value|
      option_value.image.attached? ? Rails.application.routes.url_helpers.url_for(option_value.image) : nil
    end
  end
end

Filters Endpoint Changes

The FiltersAggregator option type filter response gains kind, and each option value gains color_code and image_url:

ruby
# In FiltersAggregator#option_type_filters
{
  id: option_type.prefixed_id,
  type: 'option',
  name: option_type.name,
  label: option_type.label,
  kind: option_type.kind,              # NEW
  options: values.map { |ov| option_value_data(count_ids, ov) }
}

# In FiltersAggregator#option_value_data
{
  id: option_value.prefixed_id,
  name: option_value.name,
  label: option_value.label,
  position: option_value.position,
  color_code: option_value.color_code,  # NEW
  image_url: option_value.image.attached? ? url_for(option_value.image) : nil,  # NEW
  count: count
}

Filter Response Example

json
{
  "id": "opt_k5nR8x",
  "type": "option",
  "name": "color",
  "label": "Color",
  "kind": "color_swatch",
  "options": [
    { "id": "optval_red", "name": "red", "label": "Red", "position": 1, "color_code": "#FF0000", "image_url": null, "count": 42 },
    { "id": "optval_blue", "name": "blue", "label": "Blue", "position": 2, "color_code": "#0000FF", "image_url": null, "count": 38 },
    { "id": "optval_floral", "name": "floral", "label": "Floral", "position": 3, "color_code": null, "image_url": "https://cdn.example.com/swatches/floral.jpg", "count": 15 }
  ]
}

Admin API

Admin serializers extend Store serializers as usual, gaining kind, color_code, and image_url automatically.

PermittedAttributes additions:

ruby
@@option_type_attributes = [
  :name, :presentation, :position, :filterable, :kind,  # kind added
  option_values_attributes: [:id, :name, :presentation, :position, :color_code, :image, :_destroy]  # color_code, image added
]

Search Provider Integration

Meilisearch: Index kind on option types for potential facet-aware rendering. No change to how option values are indexed — color_code and image_url are presentation-only, not filterable.

Database: No search index changes. kind is returned via FiltersAggregator only.

SDK Type Changes

After running the typelizer pipeline:

typescript
interface OptionType {
  id: string
  name: string
  label: string
  position: number
  kind: string  // 'dropdown' | 'color_swatch' | 'buttons'
}

interface OptionValue {
  id: string
  name: string
  label: string
  position: number
  option_type_id: string
  option_type_name: string
  option_type_label: string
  color_code: string | null
  image_url: string | null
}

Migration Path

Phase 1: Schema + models

  1. Add kind column to spree_option_types (default 'dropdown', backfill 'color_swatch' for color/colour)
  2. Add color_code column to spree_option_values
  3. Add image Active Storage attachment to OptionValue
  4. Model validations + deprecate color?

Phase 2: Serializers + API

  1. Update Store and Admin OptionTypeSerializer — add kind
  2. Update Store and Admin OptionValueSerializer — add color_code, image_url
  3. Update FiltersAggregator — include kind, color_code, image_url in filter response
  4. Update PermittedAttributes — allow kind, color_code, image in params

Phase 3: SDK + types

  1. Run typelizer pipeline
  2. Regenerate Zod schemas
  3. Update SDK tests
  4. Changeset for @spree/sdk

Phase 4: Admin UI

  1. OptionType form: kind selector (dropdown/color_swatch/buttons)
  2. OptionValue form: color_code picker + image uploader (shown when parent type's kind is color_swatch, but not restricted to it)
  3. Product variant option display respects kind for rendering

Constraints on Current Work

  • Do not add more hardcoded option type name checks (like color?). Use kind once it ships.
  • Do not store presentation metadata in metafields on option values. It will have dedicated columns.
  • Do not couple kind to behavioral logic. It is a rendering hint only — all kinds use the same OptionType → OptionValue → Variant data model.

Open Questions

  1. Should kind be extensible? The initial set is dropdown, color_swatch, buttons. Should merchants/extensions be able to register custom kinds (e.g., image_swatch, radio, slider)? For now, a fixed set is simpler — custom rendering can always be done frontend-side by checking kind and falling back. Revisit if demand arises.

  2. Should image on OptionValue support variants (thumbnails)? Active Storage supports variants for resizing. Swatches are typically small (32×32 to 64×64). We could auto-generate a swatch variant. Defer until we see real usage patterns.

  3. Dual swatch mode: color_code vs. image. When both are present on an option value, which takes precedence? Suggestion: frontend decides — the API returns both, the storefront picks whichever it prefers (image > color_code is the Shopify convention).

References

  • Shopify ProductOptionValueSwatch: hex color + media image on option values
  • Saleor AttributeValue: value field (hex for swatches) + file_url (swatch images)
  • Current color? method: spree/core/app/models/spree/option_type.rb:76
  • Filters endpoint: spree/api/app/controllers/spree/api/v3/store/products/filters_controller.rb
  • FiltersAggregator: spree/api/app/services/spree/api/v3/filters_aggregator.rb
  • Naming standardization plan: 5.4-store-api-naming-standardization.md (presentation → label)
  • ProductType plan: 6.0-product-types.md (option types as live template)
  • Disjunctive faceting plan: 5.4-disjunctive-option-faceting.md
  • Competitive analysis: Spree vs. Shopify/Saleor/Medusa/Vendure option systems (2026-03-26)