docs/plans/5.4-option-type-enhancements.md
Status: Implemented
Target: Spree 5.4
Depends on: 5.4-store-api-naming-standardization.md (presentation → label bridge)
Author: Damian + Claude
Last updated: 2026-04-02
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.
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.
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".
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.
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.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).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).color? method kept but deprecated. Replaced by kind == 'color_swatch' check. Remove color? in 6.0.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
class AddVisualMetadataToSpreeOptionValues < ActiveRecord::Migration[7.2]
def change
add_column :spree_option_values, :color_code, :string # e.g. "#FF0000", nullable
end
end
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
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
# 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
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
The FiltersAggregator option type filter response gains kind, and each option value gains color_code and image_url:
# 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
}
{
"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 serializers extend Store serializers as usual, gaining kind, color_code, and image_url automatically.
PermittedAttributes additions:
@@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
]
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.
After running the typelizer pipeline:
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
}
kind column to spree_option_types (default 'dropdown', backfill 'color_swatch' for color/colour)color_code column to spree_option_valuesimage Active Storage attachment to OptionValuecolor?OptionTypeSerializer — add kindOptionValueSerializer — add color_code, image_urlFiltersAggregator — include kind, color_code, image_url in filter responsePermittedAttributes — allow kind, color_code, image in params@spree/sdkkind selector (dropdown/color_swatch/buttons)color_code picker + image uploader (shown when parent type's kind is color_swatch, but not restricted to it)kind for renderingcolor?). Use kind once it ships.kind to behavioral logic. It is a rendering hint only — all kinds use the same OptionType → OptionValue → Variant data model.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.
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.
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).
ProductOptionValueSwatch: hex color + media image on option valuesAttributeValue: value field (hex for swatches) + file_url (swatch images)color? method: spree/core/app/models/spree/option_type.rb:76spree/api/app/controllers/spree/api/v3/store/products/filters_controller.rbspree/api/app/services/spree/api/v3/filters_aggregator.rb5.4-store-api-naming-standardization.md (presentation → label)6.0-product-types.md (option types as live template)5.4-disjunctive-option-faceting.md