All feature plans live in docs/plans/ using the template at docs/plans/_template.md. Never create plans elsewhere.
When proposing significant architectural changes:
- Check existing plans in
docs/plans/for conflicts - Create or update a plan using the template before implementing
- Pay special attention to "Constraints on Current Work" sections — these apply even when you're not implementing that plan directly
Use /project:create-plan and /project:update-plan for plan management.
Active plans (6.0 target, work pending):
6.0-cart-order-split.md— Cart/Order model separation, polymorphic LineItem6.0-admin-api.md— Admin REST API conventions, auth, endpoint list (~300 endpoints)6.0-admin-spa.md— React admin architecture, extension points, table registry, i18n + server-error mapping6.0-product-types.md— Prototype → ProductType rename, MetafieldDefinition schema enforcement6.0-remove-master-variant.md— Eliminate is_master, add default_variant_id FK on Product6.0-split-adjustments.md— Replace polymorphic Adjustment with TaxLine, Discount, Fee6.0-typed-stock-movements.md— Replace generic StockMovement with typed kinds + concrete FKs6.0-normalize-state-to-status.md— Rename state → status on Payment, Shipment, InventoryUnit, ReturnAuthorization, GiftCard6.0-fulfillment-and-delivery.md— Shipment→Fulfillment, ShippingMethod→DeliveryMethod, drop ShippingCategory, FulfillmentProvider strategy, pickup (merchant StockLocation) + pickup_point (third-party PickupPointProvider)6.0-returns-exchanges-claims.md— First-class Return, Exchange, Claim models replacing ReturnAuthorization/Reimbursement chain6.0-channels-catalogs-b2b.md— Channel, Catalog, ProductListing (replaces StoreProduct), Company/CompanyLocation/CompanyContact for B2B (Channel shipped in 5.5)6.0-platform-auth.md— Drop Devise, own auth stack, User→Customer/Staff rename (RefreshToken shipped in 5.4)6.0-tax-provider.md— Per-Market TaxProvider, replaces TaxRate.adjust + Calculator, drop Zone model (TaxRate gets direct country/state FKs)6.0-delivery-rate-provider.md— Per-DeliveryMethod DeliveryRateProvider, replaces Estimator + Calculator, DeliveryZone with postal code support6.0-rich-text-descriptions.md— Drop ActionText storage, store HTML in text columns, sanitize on write, servedescription+description_htmlin API (description_html serializer field shipped in 5.4)6.0-inventory-operations.md— StockTransfer lifecycle (draft → ready_to_ship → in_transit → received with partial receive), newSpree::PurchaseOrder+Spree::Vendorreplacing today's "external receive" hack, variant + stock-location stock history panels. Consumes the typed-movement primitives from6.0-typed-stock-movements.md.6.0-replace-taxons-with-categories.md— Split Taxon into Category (hierarchy) + Collection (flat/rule-based).Spree::Category < Spree::Taxonalias + Category API surface shipped in 5.5; table rename + Collection pending.
Multi-version plans (some phases shipped, some pending):
5.4-store-api-naming-standardization.md— Standardize API naming against industry (address fields, discounts, customer_note, label, brand/last4, etc.). 5.4 model/API aliases shipped; 6.0 column/table renames pending.5.4-6.0-eu-legal-compliance.md— GDPR (data export/anonymization, consent timestamps), Omnibus (PriceHistory, lowest-in-30-days), Consumer Rights (withdrawal period). 5.4 PriceHistory +prior_priceshipped; GDPR endpoints + withdrawal period still pending.5.4-6.0-custom-fields-rename.md— Rename Metafields → Custom Fields. 5.4 API bridge + 5.5Spree::CustomField/CustomFieldDefinitionconstant aliases shipped; 6.0 model/table rename pending.5.4-6.0-product-media-system.md— Product-level media gallery. 5.5 data model (spree_variant_media, media_type, focal_point, external_video_url) shipped; admin UIs in progress; 6.0 cleanup pending.5.5-6.0-order-cancellation-and-approval.md— First-classOrderCancellation+OrderApprovalmodels. 5.5 models + migrations shipped; 6.0 drops denormalized columns.5.5-6.0-display-on-to-boolean.md— Collapsedisplay_ontri-state to a singlestorefront_visibleboolean. 5.5 bridge (storefront_visibleaccessor + Ransacker onSpree::DisplayOn) shipped; 6.0 schema rename pending.6.0-order-routing.md— Two-tier extension: pluggableSpree::OrderRouting::Strategy::Base+ STI subclasses ofSpree::OrderRoutingRule. Phase 1 (5.5) shipped:Channel,OrderRoutingRule, strategy base + Rules + Reducer + Legacy,preferred_stock_location_id+channel_idon Order. Phase 2+ (6.0) layers ProductListing/Catalog/Company on top via6.0-channels-catalogs-b2b.md.
Pending design work (drafts, no implementation yet):
5.4-centralized-translations-admin.md— Centralized Translations admin page under Products, overview grid + bulk CSV import/export5.4-metafield-translations.md— Translate MetafieldDefinition names + Metafield text values (ShortText, LongText, RichText) via Mobility translation tables
Shipped plans:
5.4-store-api-bridges.md— Bridge 6.0 naming into 5.4 Store API (PR #13782)5.4-spree-starter-and-create-spree-app.md— Replace monorepo server/ with spree-starter template repo5.4-option-type-enhancements.md—kind(dropdown/color_swatch/buttons) on OptionType +color_codeon OptionValue5.4-search-provider.md— Pluggable SearchProvider interface (Database + Meilisearch); PgSearch +add_search_scoperemoved (6.0 MetafieldDefinition faceting still pending)5.4-disjunctive-option-faceting.md— Per-option-type filter params with disjunctive facet counts (FiltersAggregatorfor DB,merge_disjunctive_facetsfor Meilisearch)6.0-stock-reservations.md— Time-limited stock reservations during checkout (PR #13978; Cart/Order split integration +allocated_countterm still pending for 6.0)5.5-admin-api-key-scopes.md— Shopify-styleread_*/write_*scopes onSpree::ApiKeyfor app authorization5.5-admin-auth-cookie-refresh.md— Admin SPA refresh token in httpOnly cookie, access token in memory, server-side logout5.5-admin-customers-api.md— Admin Customers + nested addresses/credit_cards/store_credits + CustomerGroups5.5-admin-spa-csv-export.md— Admin API ExportsController + admin-sdk +useExport+ toolbar export button
| Directory | Description |
|---|---|
spree/core |
Ruby gem — models, services, business logic (spree_core) |
spree/api |
Ruby gem — Store & Admin REST APIs (spree_api) |
spree/emails |
Ruby gem — transactional emails (optional). Deprecated in 6.0 — Next.js storefront handles consumer emails via webhooks. |
packages/dashboard |
@spree/dashboard — React SPA admin dashboard (Spree 6.0, replaces spree/admin). The deployable app shell, routes, schemas, resource hooks, locales. |
packages/dashboard-ui |
@spree/dashboard-ui — design system. Shadcn primitives + headless composed components + tokens. Source-only; consumer compiles via Vite/Tailwind. Components are headless: data comes via props, no provider/hook imports. |
packages/dashboard-core |
@spree/dashboard-core — framework. Registries (table, nav, slot, settings-nav), providers (auth, permission, store, theme), generic infra hooks, admin SDK client singleton, defineDashboardPlugin facade. The extension API for plugin authors. |
packages/sdk |
@spree/sdk — TypeScript Store API client |
packages/admin-sdk |
@spree/admin-sdk — TypeScript Admin API client (Developer Preview) |
packages/sdk-core |
@spree/sdk-core — shared HTTP/retry/error layer (private internal) |
packages/cli |
@spree/cli — Docker-based project management CLI |
packages/create-spree-app |
create-spree-app — project scaffolding |
server/ |
Rails app cloned from spree/spree-starter (.gitignored, run pnpm server:setup) |
- All code namespaced under
Spree::module - Follow Rails conventions and the Rails Security Guide
- RESTful routes and action names
- CanCanCan for authorization: listings use
accessible_by(current_ability, :show), other actions useauthorize! - Always use scope fetching for security (e.g.
current_store.ordersnotSpree::Order) - Ransack for filtering/searching, Pagy for pagination
- Use services only when necessary — prefer standard Rails models and concerns
- DO NOT call
Spree::Userdirectly, useSpree.user_class; same forSpree.admin_user_class - DO NOT put logic into controllers or serializers - this should live in models and services
- ALWAYS use Yard comments for classes and public methods, with
@paramand@returntypes - DO NOT generate too much comment noise, be very strict and selective about what gets a comment — only non-obvious public methods, never private methods or internal helpers
All backend code lives inside spree/ engine directories following Rails conventions:
app/models/spree/,app/controllers/spree/,app/services/spree/,app/serializers/spree/,app/subscribers/spree/,app/mailers/spree/,app/jobs/spree/,app/helpers/spree/,app/presenters/spree/- File naming matches class:
spree/product.rb→Spree::Product - Split large models into concerns, organized by topic
Per-request context available in models, controllers, jobs, and services:
Spree::Current.store— current storeSpree::Current.currency— current currencySpree::Current.locale— current locale
- Inherit from
Spree.base_class - Always pass
class_nameanddependenton associations; usedependent: :destroy_asyncfor high-fanout associations to offload deletion to a background job - Include
Spree::Metafieldsfor custom fields support (see docs/plans/5.4-6.0-custom-fields-rename.md) - Include
Spree::Metadatafor JSON metadata support - Use string columns instead of enums
- State machines: use
state_machines-activerecordgem, default columnstatus(legacy usesstate, see docs/plans/6.0-normalize-state-to-status.md) - Never cast IDs to integer — always treat as strings (UUID support)
- Uniqueness validations: always use
scope: spree_base_uniqueness_scope, should be also enforced by database index - If needed use paranoia gem for soft delete support (via
acts_as_paranoid) - For configuration / options always use Model Preferences
class Spree::Product < Spree.base_class
include Spree::Metafields
include Spree::Metadata
acts_as_paranoid
has_many :variants, class_name: 'Spree::Variant', dependent: :destroy
scope :available, -> { where(available_on: ..Time.current) }
validates :name, presence: true
validates :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }
end- Target version:
ActiveRecord::Migration[7.2](Rails 7.2 support) - No foreign key constraints
- No default values
- Always add
null: falseon required columns - One migration per feature when possible
- Data transformations go in rake tasks, never in migrations
- Soft delete: use
paranoiagem, adddeleted_atcolumn yourself - JSON columns must work across PostgreSQL, MySQL, and SQLite. PostgreSQL supports
t.jsonb(binary, indexable); MySQL and SQLite do not — onlyt.json. Guard withrespond_to?:
# JSON column — works on PostgreSQL, MySQL, SQLite
if t.respond_to?(:jsonb)
t.jsonb :metadata
else
t.json :metadata
endclass CreateSpreeMetafields < ActiveRecord::Migration[7.2]
def change
create_table :spree_metafields do |t|
t.string :key, null: false
t.text :value, null: false
t.string :kind, null: false
t.string :visibility, null: false
t.references :resource, polymorphic: true, null: false
t.timestamps
end
add_index :spree_metafields, [:resource_type, :resource_id, :key, :visibility],
name: 'index_spree_metafields_on_resource_and_key_and_visibility'
end
endThe Store API (customer-facing) and Admin API (back-office) are two halves of the same v3 API and should follow the same conventions. The differences are in what data is exposed, who can call it, and which actions are enabled by default — not in routing style, parameter shape, or response format.
- Base:
Spree::Api::V3::ResourceController— pagination (Pagy), Ransack, CanCanCan, prefixed ID lookups, HTTP caching - Store API:
Spree::Api::V3::Store::ResourceController— publishable API key auth, read-only by default; opt intocreate/update/destroyper resource where it makes sense (carts, customers, addresses) - Admin API:
Spree::Api::V3::Admin::ResourceController— secret API key auth (with scopes) or JWT auth (with CanCanCan), full CRUD by default (index,show,create,update,destroy); subclasses don't need to redeclare actions unless restricting
model_class, serializer_class (use Spree.api.serializer_name), scope (call super and chain), find_resource, permitted_params, collection_includes
API v3 uses flat params — no nested Rails-style wrapping. For new controllers, prefer enumerating attributes directly with params.permit(...) rather than reaching into Spree::PermittedAttributes. Existing controllers that use the global allowlist remain valid until migrated as part of the 6.0 transition.
# ✅ Flat params
def permitted_params
params.permit(:name, :description, :slug)
end
# ❌ Nested params — not used in API v3
def permitted_params
params.require(:product).permit(:name, :description, :slug)
endRead and write attribute names must match. Whatever a serializer exposes (label, status, customer_note) is what the controller's permitted_params must accept on write — no "we expose label but accept presentation" mismatches. This is non-negotiable for v3: clients should not have to translate field names between read and write. When the underlying column has a legacy name, define a writer alias on the model (def label=(value); self.presentation = value; end — pair it with the matching reader) and permit the public name in the controller. The model owns the bridge, never the client. Example: Spree::OptionType#label / label= aliases — the serializer returns label, the controller permits :label, and the model translates to the underlying presentation column.
module Spree::Api::V3::Store
class ProductsController < ResourceController
protected
def model_class
Spree::Product
end
def serializer_class
Spree.api.product_serializer
end
def scope
super.active(Spree::Current.currency)
end
end
end# Admin counterpart — gets full CRUD for free from the base class
module Spree::Api::V3::Admin
class ProductsController < ResourceController
protected
def model_class
Spree::Product
end
def serializer_class
Spree.api.admin_product_serializer
end
# No need to declare index/show/create/update/destroy — inherited.
# Only override scope/find_resource/permitted_params when behavior differs.
end
endAll API v3 uses Stripe-style prefixed IDs (e.g. prod_86Rf07xd4z, variant_k5nR8xLq):
- Always return prefixed IDs in responses — never expose raw IDs
- Always accept prefixed IDs in request params
BaseSerializerauto-converts the primaryid; for associations useobject.association&.prefixed_id- Controllers use
find_by_prefix_id!(automatic in baseResourceController) - Event payloads also use prefixed IDs
# ✅ Serializer
attribute :variant_id do |line_item|
line_item.variant&.prefixed_id
end
# ❌ Exposes raw ID
attribute :variant_idLocated in api/app/serializers/spree/api/v3/. Store and Admin APIs have separate serializers; Admin always extends Store so changes to public fields propagate automatically.
The Store API is a customer-facing surface. The Admin API is a back-office surface. Two rules govern which serializer an attribute belongs to:
Store serializer (customer-visible):
- Public product/category/cart/order data the customer sees in the storefront
- Computed display values (
display_total,purchasable,in_stock) - Customer-facing pricing (
price,compare_at_price,prior_pricefor EU Omnibus) - No timestamps (
created_at,updated_at,deleted_at) — these leak operational info and aren't useful to customers - No internal state — never expose
cost_price, internal status flags, soft-delete columns, audit logs, internal notes, private metadata, or admin-only relations (vendors, fulfillment providers)
Admin serializer (back-office):
- Always include
created_at,updated_at, anddeleted_at(when paranoid) - Cost price, margins, internal notes, private metadata
- Internal status, audit fields (
approved_by_id,cancelled_by_id) - Operational relations (stock movements, fulfillment providers, internal customer tags)
- Anything an admin needs to see but a customer must not
# Store serializer — customer-facing, no timestamps, no back-office data
module Spree::Api::V3
class ProductSerializer < BaseSerializer
typelize purchasable: :boolean, in_stock: :boolean, price: 'number | null'
attributes :id, :name, :description, :slug, :price
end
end
# Admin serializer — extends store, adds back-office attributes + timestamps
module Spree::Api::V3::Admin
class ProductSerializer < V3::ProductSerializer
typelize cost_price: 'number | null', private_metadata: 'Record<string, unknown> | null'
attributes :status, :cost_price, :private_metadata, :created_at, :updated_at, :deleted_at
end
endtypelize attr: :typefor computed/delegated attribute types- Never use
typelize_from— it connects to the database - Customize via inheritance +
Spree.api.product_serializer = 'MyApp::ProductSerializer'
order.publish_event('order.completed')Subscribers go in app/subscribers/spree/:
module Spree
class OrderCompletedSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
def handle(event)
order = Spree::Order.find_by_prefix_id(event.payload['id'])
return unless order
ExternalService.notify_order_placed(order)
end
end
endFor new models, add publishes_lifecycle_events concern and create an event serializer.
Four credential types, each with its own header and authorization model:
- Publishable keys (
pk_xxx) — Store API,X-Spree-API-Keyheader. Identifies the store; permits public/guest endpoints. Safe to expose in client-side code. - Secret keys (
sk_xxx) — Admin API,X-Spree-API-Keyheader. Server-to-server only. Each key carries a list of Shopify-style scopes (read_products,write_orders, etc.) that gate which endpoints it can hit. Authorization is scope-based, not CanCanCan-based. - JWT tokens — user auth,
Authorization: Bearer <token>header. Used by both Store API (logged-in customer) and Admin API (logged-in admin user). Admin JWT auth uses CanCanCan abilities for authorization, not scopes — this is what the admin SPA uses. - Guest cart tokens —
X-Spree-Tokenheader. Authorizes operations on a specific guest cart.
Admin API authorization summary:
- Secret API key + scopes → for apps and integrations (audit-friendly, fine-grained)
- JWT + CanCanCan → for human admin users (role-based)
Both code paths converge at the same controllers; the controller checks permissions appropriately based on which credential authenticated the request.
Register swappable services in Spree::Dependencies:
Spree::Dependencies.cart_add_item_service = 'Spree::Cart::AddItem'- CanCanCan permission checks on all actions
- Use Rails
params.permitto whitelist parameters in controllers - Use
Spree.user_class/Spree.admin_user_class— never reference user models directly - Declare Ransack allowlists on models via
whitelisted_ransackable_attributes,whitelisted_ransackable_associations, andwhitelisted_ransackable_scopesto control which attributes, associations, and scopes are queryable from API requests
- Use
includes/preloadto avoid N+1 queries (ar_lazy_preloadgem also active) - Use
Rails.cachefor expensive operations; usecache_key_with_versionfor custom keys - Proper database indexing
- Use
Spree.tfor translations - Keep translations in
config/locales/en.yml— no duplication across files
- Re-generate OpenAPI spec after API changes:
bundle exec rake rswag:specs:swaggerize - OpenAPI spec:
docs/api-reference/store.yaml(generated fromspree/api/spec/integration) - Update developer docs in
docs/developer/when relevant
Managed with pnpm workspace + Turbo for task orchestration. All packages use Tsup for building and Vitest for testing.
pnpm install # install all workspace deps
pnpm build # build all packages (Turbo-cached)
pnpm test # run all package tests
pnpm typecheck # TypeScript validation across all packages
pnpm lint # Biome lint across all packages
pnpm lint:fix # Biome lint + auto-fix
pnpm format # Biome format-writeLinting: All TypeScript packages use Biome (replaces ESLint + Prettier). Root config at biome.json; per-package configs extend it via "extends": ["../../biome.json"] and set "root": false. CI runs pnpm turbo lint on every PR touching packages/**.
TypeScript SDK for the customer-facing Store API v3.
Structure:
src/client.ts—createClient()factory,ClientConfiginterfacesrc/store-client.ts— all REST endpoints as resource classes (client.products.list(),client.carts.create(), etc.)src/types/generated/— auto-generated TypeScript types from Alba serializerssrc/zod/generated/— auto-generated Zod schemas for runtime validation
Patterns:
- Flat resource pattern:
client.products.list(),client.carts.items.create() - Auth modes: publishable key (guest), JWT (customer)
- Automatic retry with exponential backoff
SpreeErrorclass with code, status, details- Ransack query params transformed via
transformListParams()in sdk-core
Testing: Vitest + MSW (Mock Service Worker) for HTTP mocking. Tests in tests/.
cd packages/sdk
pnpm build # tsup build (CJS + ESM)
pnpm test # vitest
pnpm generate:zod # regenerate Zod schemas from TS types
pnpm typecheckSame patterns as @spree/sdk but for the Admin API. Supports both secret key (server-to-server) and JWT (admin SPA) authentication. Published under the next dist-tag during the Spree 6.0 Developer Preview.
The Spree 6.0 admin dashboard — a Vite-built React SPA that replaces the legacy Rails spree/admin engine entirely. Tech stack: Vite, TanStack Router (file-based, type-safe), TanStack Query, React Hook Form + Zod, shadcn/ui + Base UI + Tailwind, Biome, Vitest. All API calls go through @spree/admin-sdk. See packages/dashboard/README.md and docs/plans/6.0-admin-spa.md for the full architecture (auth, permissions, multi-store, extension points, the three-package split).
Package boundary rules (see docs/plans/6.0-admin-spa.md → "Package Split"):
@spree/dashboard-ui— primitives + headless compounds. Components accept data via props, never import providers or hooks.@spree/dashboard-core— registries, providers, generic infra hooks, admin SDK client singleton,defineDashboardPlugin.@spree/dashboard— routes, resource hooks (use-orders,use-products, …), Zod schemas, locales, app shell.
The split lets plugin authors register UI via defineDashboardPlugin from @spree/dashboard-core/plugin, build new pages with @spree/dashboard-ui primitives, and reuse the same providers/hooks. It also lets app developers compose custom dashboards (e.g. vendor panels) from the same packages.
Running the admin UI locally:
# 1. Boot a Spree backend (one terminal, from monorepo root)
pnpm server:setup # one-time: clones spree-starter into ./server
pnpm server:dev # Rails on http://localhost:3000
# 2. Boot the admin (separate terminal)
cd packages/dashboard
pnpm dev # http://localhost:5173 (proxies /api/* to :3000)VITE_SPREE_API_URL overrides the backend URL (default http://localhost:3000). Sign in with the seed admin user (admin@example.com / spree123 — check db/seeds.rb).
When implementing a new admin feature:
- The Admin API is the only data source. Never reach into Rails models or import server-rendered HTML. If a needed endpoint or attribute is missing, add it to
spree/apifirst (see backend conventions above), regenerate types via the Type Generation Pipeline, then consume it from the SPA. - Look at the legacy Rails admin in
spree/admin/for what the feature does today (data shape, business rules, edge cases) — but don't port the UX 1:1. The SPA can do better than Turbo-era full-page reloads where it meaningfully improves the experience. - Follow
docs/plans/6.0-admin-spa.mdfor the three extension points (table registry, navigation registry, component injection) and the shadcn copy-paste ownership model. - Wrap SDK calls in custom hooks under
src/hooks/(e.g.useOrders,useProduct) — never calladminClientdirectly from components.
Forms. Raw React Hook Form with <Field> / <Input> / <FieldError> blocks. Drive each input explicitly with form.register(...) or a <Controller> for custom widgets so the form reads top-to-bottom. Wrap RHF's handleSubmit with a try/catch that calls mapSpreeErrorsToForm (@/lib/form-errors) to route 422 responses onto form.formState.errors: flat attribute keys become field errors with aria-invalid + <FieldError>; :base and nested keys land on errors.root.message so render a destructive banner at the top of the form.
async function handleSubmit(values: FormValues) {
try {
await onSubmit(values)
} catch (err) {
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
}
}- Labels/placeholders/help come from
packages/dashboard/src/locales/en.jsonunderadmin.fields.<resource>.<attribute>.{label,placeholder,help}with cross-resource fallbackadmin.fields.<attribute>.<facet>. Dev mode logs missing keys to the console. - Client validation lives in the Zod schema (
zodResolver). - Mutation hooks built on
useResourceMutationsuppress their own toast for 422 responses — the form already shows the inline message. Non-validation errors (network, 5xx, gateway) still toast. For a plainuseMutationyou want a fallback toast on, layer the catch: trymapSpreeErrorsToFormfirst, re-throwSpreeError, otherwisetoast.error(...).
Form schemas live in packages/dashboard/src/schemas/<resource>.ts when shared across 2+ files or non-trivial (~30+ lines, nested sub-schemas, companion constants); inline is fine for short single-file forms. The schema file owns the Zod schema, its inferred FormValues type, defaults, dropdown option arrays, and regex constants. Don't add form↔API mappers to paper over field renames — if you find yourself translating ot.label → form.presentation, fix the API instead (read/write symmetry, see "API Controllers" above). Mappers are only for pure frontend state (upload progress, transient UI bookkeeping).
Base UI <Select> does not auto-render labels. Unlike Radix, Base UI's <Select.Value /> renders the raw selected value (the slug, the ISO code, the prefixed ID) instead of the matching <SelectItem>'s children. Two fixes:
- Static option labels — pass an
itemsarray; Base UI resolves the trigger label automatically:<Select items={KIND_OPTIONS} value={...} onValueChange={...}> <SelectTrigger><SelectValue /></SelectTrigger> <SelectContent> {KIND_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)} </SelectContent> </Select>
- Dynamic option labels — use the children render-prop:
<SelectValue>{(value) => roles.find((r) => r.id === value)?.name ?? (value as string)}</SelectValue>
For free-text searchable pickers, use <Combobox> instead — see components/spree/country-state-fields.tsx.
acts_as_list ⇒ drag-and-drop reorder, never a numeric position input. When a model uses acts_as_list, both top-level list tables and nested collection editors must reorder via dnd-kit:
- Top-level resource tables: pass
reorder={{ onReorder: (id, position) => adminClient.X.update(id, { position }) }}to<ResourceTable>— it owns theDndContext+SortableContextinternally, optimistic with rollback. Reference:routes/_authenticated/$storeId/settings/payment-methods.tsx. - Nested collection editors (e.g.
option_values[]on an option-type sheet): wrapuseFieldArrayrows inDndContext+SortableContext, give each row a<GripVerticalIcon>grip with{...attributes} {...listeners}fromuseSortable, and on drag end callvaluesArray.move(from, to)and rewrite each row'spositionto its new index. The position field is not rendered; it's a computed output. Reference:routes/_authenticated/$storeId/products/options.tsx(vertical),routes/_authenticated/$storeId/products/$productId.tsx(product media grid).
Use verticalListSortingStrategy for rows/lists, rectSortingStrategy for grids. Always pair PointerSensor (with activationConstraint: { distance: 5 } so row clicks don't hijack as drags) with KeyboardSensor + sortableKeyboardCoordinates for accessibility.
<StoreDatePicker> is the only correct way to render a date/datetime field. Never use <Input type="date"> (native styling breaks the design system) or the bare <DatePicker> in components/ui/ (skips the store timezone). @/components/spree/store-date-picker reads the store's IANA timezone from <StoreProvider> so every datetime in the SPA means the same thing for every admin. Modes:
- Date-only (default): emits
yyyy-MM-ddstrings (timezone-agnostic). Persist as-is — backenddatecolumns accept these directly via Ransack. - Datetime (
includeTime): the user picks a wall-clock time in the store's timezone; the picker emits the corresponding UTC ISO string and reinterprets it on read.
Wire through <Controller> in forms; pass value/onChange directly in filter panels. Inside a <Sheet>, pass inline — the default Popover path hits the portal bug below.
Base UI <Popover> is unreliable inside a <Sheet>'s portal tree. Symptom: the trigger gets aria-expanded="true" and data-popup-open="" on click, but no [data-slot="popover-content"] ever appears in the DOM. Happens in deeply-nested portal trees (Sheet → SortableContext → TableRow → Popover). Fix: render the panel inline with absolute top-full left-0 z-50 + a document.pointerdown click-outside listener + Escape-to-close. A portal is only needed to escape an overflow: hidden ancestor; for table cells and form fields, inline is fine. Reference: components/spree/color-picker.tsx, plus <StoreDatePicker inline> above.
Private package providing createRequestFn(), SpreeError, retry logic, and Ransack param transformation. Used internally by both SDKs.
When changing Alba serializers, run the full pipeline:
cd spree/api && bundle exec rake typelizer:generate # 1. TS types from serializers
cd packages/sdk && pnpm generate:zod # 2. Zod schemas from TS types
cd spree/api && bundle exec rspec spec/integration/ # 3. Integration tests
bundle exec rake rswag:specs:swaggerize # 4. OpenAPI spec
cd packages/sdk && pnpm test # 5. SDK tests- TypeScript types →
packages/sdk/src/types/generated/(Store) andpackages/admin-sdk/src/types/generated/(Admin) - Zod schemas →
packages/sdk/src/zod/generated/ - Store types:
StoreProduct,StoreOrder, etc. Admin types:AdminProduct,AdminOrder, etc.
A Lefthook pre-commit hook (lefthook.yml) regenerates types and Zod schemas automatically whenever spree/api/app/serializers/**/*.rb files are committed, then re-stages the generated output. You don't need to run steps 1 and 2 manually if you're committing serializer changes — the hook handles it. Steps 3–5 (integration tests, OpenAPI regen, SDK tests) still need to run locally before pushing.
Published packages use Changesets for versioning. Place changeset files in the package's .changeset/ directory.
Always run tests before committing changes.
Each engine has its own test suite:
cd spree && bundle install # shared deps
cd core && bundle install # engine deps
bundle exec rake test_app # create dummy Rails app (skip if already exists)
bundle exec rspec # run full suite
bundle exec rspec spec/models/spree/state_spec.rb # single file
bundle exec rspec spec/models/spree/state_spec.rb:7 # single testDefault DB is SQLite3. For PostgreSQL:
DB=postgres DB_USERNAME=postgres DB_PASSWORD=password DB_HOST=localhost bundle exec rake test_appParallel runs:
bundle exec rake parallel_setup # create worker DBs
bundle exec parallel_rspec spec # run in parallel
bundle exec parallel_rspec -n 4 spec # with worker countRe-run parallel_setup after schema changes.
Test guidelines:
- RSpec + Factory Bot
- Prefer
buildovercreatefor speed - Factories live in
lib/spree/testing_support/factories/ - ALWAYS use factories in tests, never call
Model#createdirectly - Pragmatic — no tests for standard Rails validations, only custom ones
- Controller specs: always add
render_views, usestub_authorization!for auth - Use controller specs for testing edge cases, API integration tests are only for happy path/simple 422 failures to generate OpenAPI examples; otherwise they get too brittle and high-maintenance
- Time-based tests: use
Timecop - Don't over-engineer or repeat tests
cd packages/sdk && pnpm test # SDK tests (uses MSW for HTTP mocking)End-to-end tests for packages/dashboard live in packages/dashboard/e2e/. The global setup boots a real Rails test server (port 3010) + Vite dev (port 5174) once and seeds the DB; specs then exercise the SPA through a browser against that stack.
cd packages/dashboard && pnpm test:e2e # full suite
cd packages/dashboard && pnpm test:e2e:ui # Playwright UI mode (debug)Write UI-only assertions, like Capybara. Drive the test through user-visible actions (fill labels, click buttons, find by role) and assert on visible UI. Do not reach for page.waitForResponse(/api/...) to wait for backend completion — it leaks API shape into tests and makes refactors painful. Playwright's await expect(...).toBeVisible() auto-polls until the condition is met (same as Capybara's default_max_wait_time), which covers virtually all cases.
// ✅ Capybara-style: drive the UI, assert on the UI.
await page.getByLabel(/^label$/i).fill('Color')
await page.getByRole('button', { name: /create option type/i }).click()
await expect(page.getByRole('button', { name: 'color' })).toBeVisible({ timeout: 15_000 })
// ❌ Avoid: couples the test to API shape, brittle on refactor.
await Promise.all([
page.waitForResponse((res) => /\/api\/v3\/admin\/option_types/.test(res.url()) && res.status() === 201),
page.getByRole('button', { name: /create option type/i }).click(),
])The narrow exceptions where API-level waits are justified:
- No UI feedback — a mutation kicks off background work (e.g., a webhook fire-and-forget) and there's nothing visible to assert against.
- Optimistic UI — success state appears in the DOM before the API confirms; a UI-only assertion can't distinguish "rendered and persisted" from "rendered but later failed."
Both are rare in the admin SPA, which renders success states only after mutations resolve.
Conventions:
- Use
Date.now()suffixes on names so leftover rows from earlier specs don't collide (the suite runs serially —fullyParallel: false, workers: 1). - Disambiguate duplicate button names (e.g., a "Delete" in the sheet footer + another in a confirm dialog) by scoping:
page.getByRole('dialog').getByRole('button', { name: /^delete$/i }). - Reference:
e2e/option-types.spec.ts,e2e/invitation-acceptance.spec.ts.