Back to Spree

Admin SPA CSV Import

docs/plans/5.6-admin-spa-csv-import.md

5.6.032.7 KB
Original Source

Admin SPA CSV Import

Status: In Progress (open questions resolved + implemented in a single PR, 2026-07-10) Target: Spree 5.6 Depends on: 6.0-admin-spa.md, 6.0-admin-api.md, 5.5-admin-spa-csv-export.md (pattern precedent), 5.5-admin-api-key-scopes.md Author: Damian Legawiec Last updated: 2026-07-10

Summary

The legacy Rails admin (spree/admin) ships a full CSV import wizard: upload a file, map its columns onto an import schema, then watch a background pipeline process every row — with live UI updates pushed over Turbo Streams (ActionCable). The entire backend pipeline lives in core and is production-hardened: Spree::Import (STI: Products, Customers, ProductTranslations), Spree::ImportMapping, Spree::ImportRow, Spree::ImportSchema, the CreateRowsJob → ProcessRowsJob → ProcessGroupJob chain, row processors, and lifecycle events. The new React admin SPA (packages/dashboard) has none of it.

This plan brings imports to the SPA as a universal, registry-driven flow — one Admin API v3 surface, one SDK resource, one set of dashboard components that work for every type in Spree.import_types — mirroring the shipped CSV export architecture (5.5-admin-spa-csv-export.md). Live status uses API polling, not ActionCable: the SPA polls GET /imports/:id for a progress bar and counters, and replaces the legacy per-row live feed with a paginated failed-rows table that — unlike the legacy view — also works for large imports.

Key Decisions (do not deviate without discussion)

  • Reuse the existing Spree::Import pipeline verbatim. No new models, jobs, state machine, or row processors. The legacy Spree::Admin::ImportsController and its Turbo Streams views stay in place untouched; the new endpoints live alongside under Spree::Api::V3::Admin::ImportsController.

  • Polling over ActionCable — this is a deliberate architecture decision, not a shortcut.

    • The legacy "real-time" layer is Turbo Streams broadcasting server-rendered HTML partials (imports/_row, _footer, _loader) from event subscribers (spree/admin/app/subscribers/spree/admin/import_row_subscriber.rb). None of that payload is usable by React; adopting ActionCable would mean building a new JSON channel protocol, not reusing anything.
    • Auth mismatch: ActionCable authenticates connections via cookies; the admin SPA holds its JWT in memory (5.5-admin-auth-cookie-refresh.md). Passing tokens in the WS query string leaks them into proxy/server logs.
    • Reliability: WebSocket pushes have no replay — a dropped connection or a slept laptop silently loses progress updates mid-import. A poll always returns the full current state from the DB; the flow is self-healing by construction.
    • Operational cost: no Redis/solid_cable requirement, no WS-aware proxy config for spree-starter deployments, nothing extra for secret-key API consumers (they poll the same endpoint).
    • Actual load: one indexed COUNT per watcher every 2s against counters that the pipeline already persists (rows_count, per-row status, processing_groups_count/completed_groups_count). Negligible.
    • Precedent: the shipped export flow already polls (packages/dashboard-core/src/hooks/use-export.ts, 2s interval).
    • Escape hatch: if a future release adds a shared realtime layer (e.g. notifications), the SPA swaps refetchInterval for push-triggered invalidation; the API contract does not change.
  • No live per-row feed; a polled failed-rows table instead. The legacy per-row Turbo appends only run for imports under Spree::Config[:large_import_threshold] (500 rows); large imports get counts only, with no row-level error visibility at all. The SPA shows the same UI for every size: progress bar + counters (polled at 2s), plus a paginated GET /imports/:id/rows?q[status_eq]=failed table (polled at ~5s while processing). Failures are the only actionable rows; a live scroll of successes is noise. large_import_threshold remains a backend processing concern (bulk mode, events disabled) and is invisible to the UI.

  • The mapping step stays. Column mapping is the genuinely valuable part of the legacy wizard (auto-assignment by header-name normalization, per-store metafield columns from Import#schema_fields). The SPA renders it from data the API returns on create; the server stays the source of truth for mappings. The API additionally exposes a sample_row (first data row) so the mapping UI can show an example value under each column — something the legacy UI never had.

  • Upload via the shipped direct-uploads flow, then create with a signed blob id. POST /api/v3/admin/direct_uploads + useDirectUpload (packages/dashboard-core/src/hooks/use-direct-upload.ts) already exist and already handle the Vite dev-proxy/CORS pitfalls. POST /imports accepts attachment: <signed_id> — no multipart on the JSON API. (This refines the multipart/form-data note next to the imports checklist in 6.0-admin-api.md.)

  • Secret-key gating: an import is a bulk write. Mirror Spree::Export.required_scope with Spree::Import.required_scope, but map every action to the write scope of the imported resource (Spree::Imports::Productswrite_products). The index is filtered to the types the key can write. No standalone write_imports scope, same reasoning as exports.

  • Overlay wizard, not a page (revised 2026-07-11 — originally a route under the settings shell). The upload step opens in a Sheet from the resource page toolbar; on create the wizard (mapping → progress → results, keyed off import.status) opens in a full-window dialog using the bulk-price-editor shell — the user never leaves the table they were on. The open dialog is driven by an ?import=<prefixed id> search param (same idiom as the webhooks delivery param), so it deep-links and survives refresh; closing mid-processing is safe — the import continues server-side. The /settings/imports history page (new audit settings-nav group, future home for exports history and audit logs) reopens the same dialog on row click; there is no detail route.

  • Retry failed rows ships in v1. The dispatcher already re-processes pending_and_failed rows on any re-dispatch, so retry is one new state-machine transition (completed → processing, guarded on failures existing) + one endpoint + one button on the results screen. No new processing logic.

  • Client-side gating is per target resource, not per Spree::Import. The import button renders inside <Can I="create" a={subject}> where subject is the imported resource (Spree::Product on the products page) — mirroring the server's write_<resource> scope model. No blanket Spree::Import subject in the dashboard Subject map. Backend enforcement is unchanged and remains authoritative.

  • Universal by registry. Everything is driven by Spree.import_types and Import#schema_fields. Adding a new import type (subclass + schema + row processor + registry entry) lights up the API, SDK types, and dashboard flow with no SPA changes beyond placing a button. This is the hook the translations plan (5.5-6.0-resource-translations-api.md, Phase 2 "generalize CSV across the registry") plugs into.

Design Details

What already exists (reused as-is)

PieceLocationNotes
Spree::Importspree/core/app/models/spree/import.rbSTI, polymorphic owner (Store), state machine on status: pending → mapping → completed_mapping → processing → completed | failed, rows_count counter cache, has_one_attached :attachment (private storage), preferred_delimiter preference
Spree::ImportMapping.../import_mapping.rbschema_field ↔ file_column, auto-assign on start_mapping
Spree::ImportRow.../import_row.rbraw row data (JSON), row_number, per-row status + validation_errors, polymorphic item link to the created record
Schemas.../import_schemas/*.rbfield name/label/required; Import#schema_fields appends per-store metafield definition columns
Jobsspree/core/app/jobs/spree/imports/CreateRowsJob (streams CSV → bulk upsert rows) → ProcessRowsJob (groups/batches) → ProcessGroupJob (processes, tracks completed_groups_count, completes import)
Row processorsspree/core/app/services/spree/imports/row_processors/ProductVariant, Customer, ProductTranslation
Eventspublishes_lifecycle_eventsimport.completed, import.progress, import_row.completed/failed — webhook-ready, untouched
Event serializersspree/api/app/serializers/spree/api/v3/import_serializer.rb, import_row_serializer.rbbecome the base classes for the new Admin serializers
Direct uploadsspree/api/.../admin/direct_uploads_controller.rb + useDirectUploadpresign + browser PUT + signed_id, dev-proxy safe
Export SPA patternpackages/dashboard-core/src/components/export-button.tsx, hooks/use-export.tstoolbar wiring via ResourceActionsContext, 2s polling, authed blob download

Core changes (small, additive)

ruby
# spree/core/app/models/spree/import.rb
scope :for_store, ->(store) { where(owner: store) }   # v3 ResourceController#scope calls model_class.for_store

# Deletable only when no jobs can be in flight. Base v3 destroy renders 422 otherwise.
def can_be_deleted?
  %w[pending mapping completed failed].include?(status)
end

# New event, added INSIDE the existing `state_machine initial: :pending,
# attribute: :status` block (a bare `state_machine do` reopen would target
# the default :state attribute). Retry is cheap because the dispatcher
# already targets pending_and_failed rows — a transition + re-dispatch.
event :retry_failed_rows do
  transition from: :completed, to: :processing, if: ->(import) { import.rows.failed.exists? }
end
after_transition on: :retry_failed_rows, do: :process_rows_async
# Note: the existing `after_transition to: :completed` hooks re-fire when the
# retry pass finishes — a second `import.completed` event is correct (it did
# complete again).

# Memoized per-status row counts for the poll payload — one grouped COUNT per request.
def rows_status_counts
  @rows_status_counts ||= rows.group(:status).count
end

class << self
  # Mirror of Spree::Export.required_scope, but imports WRITE the resource:
  # Spree::Imports::Products => :products => gated by `write_products`.
  def required_scope
    return nil if self == Spree::Import

    to_s.demodulize.underscore.to_sym
  end
end
ruby
# spree/core/app/models/spree/import_row.rb
self.whitelisted_ransackable_attributes = %w[status row_number]

ImportRow#process! and #bulk_process! additionally clear validation_errors when a previously-failed row succeeds — today a stale error message survives a successful retry (process! never resets it).

One migration:

ruby
class AddImportIdStatusIndexToSpreeImportRows < ActiveRecord::Migration[7.2]
  def change
    # Powers the per-poll grouped status counts and the failed-rows listing
    # without a full scan on 100k-row imports.
    add_index :spree_import_rows, [:import_id, :status]
  end
end

No changes to the state machine, jobs, events, or processors. In particular the import.progress event cadence (every 10 groups, large imports only) is irrelevant to the SPA — polling reads counters straight from the DB.

Admin API: routes

ruby
# spree/api/config/routes.rb — admin v3 namespace, next to exports
resources :imports, only: [:index, :show, :create, :destroy] do
  collection do
    get :template
  end
  member do
    patch :complete_mapping
    patch :retry_failed_rows
    get :download
  end
  resources :rows, only: [:index], controller: 'import_rows'
end

GET :id/download streams the originally uploaded CSV — the audit trail for what was actually imported (same inline-streaming rationale as the exports download). The serializer exposes original_filename / original_byte_size / original_file_url, surfaced as a row action on the history page and a download button in the wizard header.

Admin API: ImportsController

Follows the shipped ExportsController closely (type resolution against the trusted registry, scope-limited index filtering, dynamic scoped_resource_name).

ruby
module Spree::Api::V3::Admin
  class ImportsController < ResourceController
    include ActiveStorage::SetCurrent

    skip_scope_check! only: :index   # index spans types; `scope` filters to writable ones

    # POST /api/v3/admin/imports
    # { type: 'Spree::Imports::Products', attachment: '<signed blob id>', preferred_delimiter: ',' }
    # Saves, then advances into mapping (auto-assigns file columns), returns the
    # mapping payload so the SPA can render the mapping step from this response.
    def create
      @resource = build_resource
      authorize_resource!(@resource, :create)

      if @resource.save
        @resource.start_mapping!
        render json: serialize_resource(@resource), status: :created
      else
        render_errors(@resource.errors)
      end
    end

    # PATCH /api/v3/admin/imports/:id/complete_mapping
    # { mappings: [{ schema_field: 'sku', file_column: 'SKU Code' }, ...] }
    # Atomically applies the submitted mappings, then transitions to
    # completed_mapping (which enqueues CreateRowsJob). 422 when required
    # schema fields remain unmapped or a file column is double-assigned.
    def complete_mapping
      # update mappings in a transaction, keyed by schema_field;
      # then: @resource.mapping_done? ? @resource.complete_mapping! : render_errors(...)
    end

    # PATCH /api/v3/admin/imports/:id/retry_failed_rows
    # Re-dispatches processing over the rows still `failed` (the dispatcher's
    # pending_and_failed scope picks them up). 422 unless the import is
    # `completed` with failures. Named to avoid the `retry` Ruby keyword.
    def retry_failed_rows
      # @resource.retry_failed_rows! → serialize; invalid transition → 422
    end

    # GET /api/v3/admin/imports/template?type=Spree::Imports::Products
    # text/csv header row built from the type's schema_fields (includes the
    # store's metafield columns) — the "Download template" link in the dialog.
    def template
      # send_data headers_csv, filename: 'products_import_template.csv', type: 'text/csv'
    end

    protected

    def model_class = Spree::Import
    def serializer_class = Spree.api.admin_import_serializer
    def scope_includes = [:user, :mappings]

    # Every action on an import maps to the write scope of the imported
    # resource — creating is a bulk write, and status/rows expose the
    # uploaded data, so read-only keys get nothing.
    def action_kind = 'write'

    def scoped_resource_name
      import_class&.required_scope || :all
    end

    def permitted_params
      params.permit(:type, :attachment, :preferred_delimiter)
    end

    def build_resource
      klass = resolve_import_type(permitted_params[:type]) || Spree::Import
      klass.new(
        permitted_params.except(:type).merge(owner: current_store, user: try_spree_current_user)
      )
    end

    # Same trusted-registry constantize pattern as ExportsController#resolve_export_type.
  end
end

Notes:

  • attachment= accepts the signed blob id natively (has_one_attached). The existing model validations (content type text/csv, whitelisted type) return as regular 422s.
  • owner is polymorphic (multi-vendor extensions create Vendor-owned imports); for_store scopes the core Admin API to store-owned imports only — vendor dashboards override scope in their own extension controllers.
  • create runs start_mapping! synchronously — it reads the just-uploaded blob once to auto-assign columns, exactly as the legacy controller does.
  • destroy is inherited; the model's can_be_deleted? guard (above) makes the base controller render 422 while jobs may be running.
  • No Spree::Api::V3::HttpCaching module — the show endpoint is a poll target and must always return fresh state (caching is opt-in per controller, so this means simply not including it).

Admin API: ImportRowsController

Nested index only — the failure report. Uses the standard set_parent machinery (parent lookup via the imports scope, so store isolation and scope gating come from the parent).

ruby
module Spree::Api::V3::Admin
  class ImportRowsController < ResourceController
    # belongs_to import; index only
    # GET /api/v3/admin/imports/:import_id/rows?q[status_eq]=failed&sort=row_number
    def model_class = Spree::ImportRow
    def serializer_class = Spree.api.admin_import_row_serializer
  end
end

Admin API: serializers

Per the serializer split rule, Admin extends the existing V3 (event) serializers; mappings get a fresh Admin serializer (they never appear in events or the Store API).

ruby
module Spree::Api::V3::Admin
  class ImportSerializer < V3::ImportSerializer
    # Always: everything the poll needs.
    attributes :processing_errors, :preferred_delimiter
    attribute(:completed_rows_count) { |import| import.rows_status_counts['completed'] || 0 }
    attribute(:failed_rows_count)    { |import| import.rows_status_counts['failed'] || 0 }
    attribute(:schema_fields)        { |import| import.schema_fields }  # [{ name, label, required }]

    many :mappings, serializer: 'Spree::Api::V3::Admin::ImportMappingSerializer'

    # Mapping-state only: computing these downloads the attached blob, which a
    # 2s processing poll must never do. `mapping?` guards both.
    attribute(:csv_headers) { |import| import.csv_headers if import.mapping? }
    attribute(:sample_row)  { |import| import.mapping? ? first_data_row(import) : nil }
    # first_data_row reads only the first CSV data line (CSV.foreach + first),
    # never a full-file parse.
  end
end
  • Admin::ImportRowSerializer < V3::ImportRowSerializer — adds data (parsed row hash) so the failure table can show the offending CSV values. Base already carries row_number, status, validation_errors, item_type/item_id, timestamps.
  • Admin::ImportMappingSerializerid, schema_field, file_column, required (boolean). The complete_mapping endpoint accepts exactly these names back (read/write symmetry).
  • Register admin_import_serializer, admin_import_row_serializer, admin_import_mapping_serializer in Spree::Api::Dependencies.

The poll contract

GET /imports/:id drives the whole wizard; the SPA switches UI on status:

statusSPA stateNotable payload
mappingmapping stepmappings (auto-assigned), schema_fields, csv_headers, sample_row
completed_mappingprogress ("Preparing rows…", indeterminate)rows are being created; rows_count still 0
processingprogress barrows_count, completed_rows_count, failed_rows_count; indeterminate while rows_count == 0
completedresultsfinal counters; failed rows via the rows endpoint; retry_failed_rows re-enters processing
failedfile-level errorprocessing_errors (e.g. malformed CSV from CreateRowsJob's discard_on)

Terminal statuses (completed, failed) stop the poll. Because the wizard is a page (not a fire-and-forget toast like exports), there is no client-side timeout — leaving the page stops polling, returning resumes it.

Backend tests + type generation

  • RSwag integration specs (spree/api/spec/integration/api/v3/admin/imports_spec.rb): create with signed blob → 201 in mapping with auto-assigned mappings; complete_mapping happy path + 422 (required field unmapped); show during processing returns counters; rows index filtered by status_eq; retry_failed_rows happy path + 422 (no failures / wrong status); destroy 422 while processing; 401/403 paths; secret-key scope filtering (a write_customers-only key sees no product imports).
  • Controller specs for the edge cases (unknown type falls back per registry, double-assigned file_column 422, template CSV includes metafield columns).
  • Full pipeline after serializer work: typelizer:generategenerate:zod → integration specs → rswag:specs:swaggerize (the lefthook pre-commit covers 1–2).

Admin SDK: client.imports

ts
readonly imports = {
  list, get, delete,                                   // standard resource shapes
  create: (params: ImportCreateParams) => ...,         // { type, attachment: signedId, preferred_delimiter? }
  completeMapping: (id: string, params: { mappings: ImportMappingParam[] }) =>
    this.request<AdminImport>('PATCH', `/imports/${id}/complete_mapping`, { body: params }),
  retryFailedRows: (id: string) =>
    this.request<AdminImport>('PATCH', `/imports/${id}/retry_failed_rows`),
  rows: {
    list: (importId: string, params?: ListParams) => ..., // PaginatedResponse<AdminImportRow>
  },
}

ImportType union mirrors the registry ('Spree::Imports::Products' | 'Spree::Imports::Customers' | 'Spree::Imports::ProductTranslations'). The template download is not an SDK method — like the export download, it's an authed fetch in the dashboard (the SDK layer is JSON-only). MSW tests + changeset.

Dashboard

ImportButton (packages/dashboard-core/src/components/import-button.tsx) — sits beside export-button.tsx, same toolbar slot. Props: { type: ImportType; subject: SubjectName; onCreated: (imp: AdminImport) => void } — dashboard-core must not know app routes or dialogs, so what happens after create is the app's callback (the index pages set the ?import= search param, which opens the wizard dialog). The button wraps itself in <Can I="create" a={subject}> (per-context gating — see Key Decisions) and opens a Sheet containing:

  • CSV picker → the universal FileUploadField (dashboard-core): dropzone + direct upload on pick + the attached file rendered as an Attachment with uploading state and a remove action. ImageUploadField is now a thin image preset of the same component (same props, so the logo/avatar/category call sites stayed drop-in), which answers "why two upload components" with: there's one. The import button passes transformFile to force content_type: 'text/csv' — Windows browsers report .csv as application/vnd.ms-excel and the model validation rejects it. Attachment and Progress were ported into dashboard-ui from the shadcn Base UI registry (cn-* preset classes flattened to plain utilities, matching the package's pre-CSS-layer style); the wizard's processing bar is the Progress primitive with value={null} as the indeterminate "preparing" state.
  • Delimiter select (, ; | tab — the model's preferred_delimiter preference).
  • "Download template" link → authed fetch of GET /imports/template?type=…, Blob-URL download (same helper approach as use-export.ts).
  • Submit → imports.create({ type, attachment: signedId, preferred_delimiter })onCreated.

Hooks (packages/dashboard/src/hooks/use-imports.ts) — store-scoped query keys throughout:

ts
const TERMINAL = new Set(['completed', 'failed'])

export function useImport(id: string) {
  return useQuery({
    queryKey: useResourceKey('imports', id),
    queryFn: () => adminClient.imports.get(id),
    refetchInterval: (query) =>
      TERMINAL.has(query.state.data?.status ?? '') ? false : 2000,
  })
}

Plus useImports() (history list), useImportRows(id, params) (5s refetchInterval while the parent import is non-terminal), useCreateImport(), useCompleteMapping(id), useDeleteImport().

Routes, dialog + nav:

  • routes/_authenticated/$storeId/settings/imports/index.tsx — history: ResourceTable over client.imports.list (number, type, status badge, completed/failed/total counts, created_at; row click sets ?import=<id>, opening the wizard dialog in place). Registered in packages/dashboard/src/nav/settings.ts under a new audit settings-nav group (settingsNav.addGroup({ key: 'audit', ... }), positioned after team), entry { key: 'settings.imports', path: '/imports', group: 'audit' } — the group is the future home for exports history and audit logs.
  • components/spree/imports/import-wizard-dialog.tsx — the wizard as a full-window dialog (bulk-price-editor shell: !inset-3 edge-to-edge DialogContent, custom header with type · number, status badge and an X close). Rendered by the products, customers and history pages, opened by their ?import= search param, switching on status per the poll contract:
    • Mapping: step indicator (Map fields → Process → Done, mirroring the legacy wizard); a table of schema_fields rows — label + required badge, a <Select> of csv_headers (+ "Not mapped", with items array per the Base UI label rule), the sample_row value as muted helper text. "Start import" disabled until every required field is mapped client-side; server 422s render as a banner.
    • Processing: <Progress> bar from (completed + failed) / rows_count, counter copy ("1,240 of 5,000 rows processed · 12 failed"), indeterminate "Preparing rows…" while rows_count == 0, and the failed-rows table streaming in below as failures appear. A retry pass is derivable (completed + failed ≈ rows_count while processing) — show "Retrying failed rows · N remaining" driven by the shrinking failed_rows_count instead of the (already full) bar.
    • Results: success/partial summary (completed vs failed counts), paginated failed-rows table (row number, error message, expandable raw data), a "Retry failed rows (N)" button when failed_rows_count > 0 (calls retryFailedRows; status flips to processing and the poll resumes automatically), and an "Import another" back-link. File-level failed shows processing_errors prominently.

Wiring: inside the existing <ResourceTable actions={(ctx) => …}> render prop (ResourceActionsContext, packages/dashboard-core/src/components/resource-table.tsx) on the products index (type="Spree::Imports::Products" subject={Subject.Product}) and customers index (type="Spree::Imports::Customers" + the customer subject from the Subject map), next to the existing ExportButton. Unlike ExportButton, ImportButton ignores the context — imports don't consume filters. The Getting Started "Add products" task also offers it (a registered add_products slot body: manual CTA + "Import products from CSV"; the created import lands on the products page with the wizard open). Product translations import stays backend-only until the translations dashboard work (5.5-6.0-resource-translations-api.md) surfaces it.

i18n: dashboard-core keys under admin.components.import_button.*, app keys under admin.imports.* — added to all six locale files (en, de, fr, zh-CN, ar, pl) in both packages.

Frontend tests: Vitest for the pure logic the wizard hangs off (isImportActive — the poll's continue/stop predicate — and the import-types helpers); exports shipped with zero frontend coverage, don't repeat that. Plus a Playwright e2e spec driving the full wizard with an inline CSV — upload, auto-mapped columns with sample values, start, poll-driven progress, completion summary, failure report with raw row data, retry cycle, and the blocked-until-required-fields-mapped path. UI-only assertions per the testing conventions; Date.now() suffixes on imported record names. Note: the e2e Rails server pins ActiveJob to the :test adapter, so global-setup writes a guarded initializer into the generated dummy app switching it to :async (DASHBOARD_E2E=1 only, removed in teardown) — without it the pipeline never runs and the wizard sits at "Preparing rows…" forever.

Migration Path

Single PR (decided 2026-07-10), in three internal layers:

  1. Backend — core additions (for_store, can_be_deleted?, rows_status_counts, sample_row, required_scope, retry_failed_rows transition, validation_errors reset on successful reprocess, ImportRow ransack whitelist, composite index migration), API controllers + routes + Admin serializers + dependency registrations, RSwag/controller specs, typegen pipeline.
  2. Admin SDKclient.imports resource, param/response types, MSW tests, changeset.
  3. SPAImportButton (dashboard-core) + locales, use-imports hooks, /settings/imports routes + audit nav group, toolbar wiring on products/customers, Playwright spec.

Secret-key attribution note: Spree::Import requires a user (row processors resolve products through import.current_ability), so key-authenticated creates attribute to the key's created_by admin when present and 422 otherwise — JWT (dashboard) flows are unaffected.

Constraints on Current Work

  • Do not introduce ActionCable/WebSockets/SSE into packages/dashboard for this or any single feature. If a genuine realtime need accumulates (notifications, collaborative editing), design a shared layer in its own plan first; import progress explicitly does not qualify.
  • New import types plug into the registry, not the UI. Adding one = Import subclass + schema + row processor + Spree.import_types entry (+ an ImportType union member). Never hardcode type-specific logic in the dashboard flow beyond button placement.
  • The SPA must never branch on large_import_threshold. It's a backend processing knob; the UI contract is identical for 10 rows and 100k rows.
  • Read/write symmetry on mappings. The serializer exposes schema_field/file_column; complete_mapping accepts exactly those names. No client-side field translation.
  • The legacy spree/admin import wizard stays untouched — other surfaces still mount it. Changes to import behavior (new schema columns, processors) happen in core where both admins share them.
  • Poll endpoints stay cache-free. Never include Spree::Api::V3::HttpCaching in imports controllers.

Open Questions

None — all resolved 2026-07-10:

  • Status transport → polling only; no WebSocket/ActionCable/SSE.
  • Retry failed rowsin scope for v1 (state-machine transition + retry_failed_rows endpoint + results-screen button; see Design Details).
  • Cancel a processing import → out of scope (requires cooperative job cancellation; destroy stays blocked mid-flight).
  • Completion emailout of scope revised 2026-07-11: in scope. Spree::ImportMailer#import_done (subscriber on import.completed, so retry passes email again) mirrors export_done, with the failed-rows count called out. The link mechanism is fully caller-provided — no respond_to?(:admin_*_url) probing in mailer views: every creating surface passes a results_url at create (the dashboard through the Admin API, validated against the store's allowed origins exactly like the password-reset redirect_url and silently dropped otherwise; the legacy Rails admin sets its own import page URL in its controller). It persists as an Import preference — the email fires at the end of an async multi-job pipeline (and again after retry passes), so the URL must be record state, not a threaded parameter. The mailer appends ?import=<id>; no URL means no button. export_done was migrated to the same contract (spree_exports gained the preferences column; both legacy controllers and both v3 API controllers accept results_url).
  • Failed-rows CSV download → out of scope for now (retry + on-screen report cover the loop).
  • Navigation → yes: /settings/imports under a new audit settings-nav group (see Design Details).
  • Orders import type → skipped (registry has no Spree::Imports::Orders; separate backend effort if ever).
  • XLSX → skipped; CSV only.
  • Client-side gating → per-context resource ability (<Can I="create" a="Spree::Product">), not a blanket Spree::Import subject (see Key Decisions).
  • Zombie processing imports (dead last worker never re-checks completion) → accepted as-is; worker restarts cover it in practice. No sweeper, no poll backoff.

References

  • Legacy pipeline: spree/core/app/models/spree/import.rb, import_row.rb, import_mapping.rb, import_schemas/, spree/core/app/jobs/spree/imports/, spree/core/app/services/spree/imports/row_processors/
  • Legacy admin UI: spree/admin/app/controllers/spree/admin/imports_controller.rb, .../import_mappings_controller.rb, .../import_rows_controller.rb, spree/admin/app/views/spree/admin/imports/, spree/admin/app/subscribers/spree/admin/import_subscriber.rb, import_row_subscriber.rb
  • Export precedent (architecture to mirror): docs/plans/5.5-admin-spa-csv-export.md, spree/api/app/controllers/spree/api/v3/admin/exports_controller.rb, packages/dashboard-core/src/components/export-button.tsx, packages/dashboard-core/src/hooks/use-export.ts
  • Upload flow: spree/api/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb, packages/dashboard-core/src/hooks/use-direct-upload.ts
  • Event serializers (bases): spree/api/app/serializers/spree/api/v3/import_serializer.rb, import_row_serializer.rb
  • Related plans: 6.0-admin-api.md (imports endpoint checklist §Imports), 5.5-admin-api-key-scopes.md, 5.5-6.0-resource-translations-api.md (Phase 2 CSV generalization)