docs/plans/5.6-admin-spa-csv-import.md
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
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.
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.
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.5.5-admin-auth-cookie-refresh.md). Passing tokens in the WS query string leaks them into proxy/server logs.COUNT per watcher every 2s against counters that the pipeline already persists (rows_count, per-row status, processing_groups_count/completed_groups_count). Negligible.packages/dashboard-core/src/hooks/use-export.ts, 2s interval).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::Products → write_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.
| Piece | Location | Notes |
|---|---|---|
Spree::Import | spree/core/app/models/spree/import.rb | STI, 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.rb | schema_field ↔ file_column, auto-assign on start_mapping |
Spree::ImportRow | .../import_row.rb | raw row data (JSON), row_number, per-row status + validation_errors, polymorphic item link to the created record |
| Schemas | .../import_schemas/*.rb | field name/label/required; Import#schema_fields appends per-store metafield definition columns |
| Jobs | spree/core/app/jobs/spree/imports/ | CreateRowsJob (streams CSV → bulk upsert rows) → ProcessRowsJob (groups/batches) → ProcessGroupJob (processes, tracks completed_groups_count, completes import) |
| Row processors | spree/core/app/services/spree/imports/row_processors/ | ProductVariant, Customer, ProductTranslation |
| Events | publishes_lifecycle_events | import.completed, import.progress, import_row.completed/failed — webhook-ready, untouched |
| Event serializers | spree/api/app/serializers/spree/api/v3/import_serializer.rb, import_row_serializer.rb | become the base classes for the new Admin serializers |
| Direct uploads | spree/api/.../admin/direct_uploads_controller.rb + useDirectUpload | presign + browser PUT + signed_id, dev-proxy safe |
| Export SPA pattern | packages/dashboard-core/src/components/export-button.tsx, hooks/use-export.ts | toolbar wiring via ResourceActionsContext, 2s polling, authed blob download |
# 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
# 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:
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.
# 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.
ImportsControllerFollows the shipped ExportsController closely (type resolution against the trusted registry, scope-limited index filtering, dynamic scoped_resource_name).
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.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).ImportRowsControllerNested 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).
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
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).
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::ImportMappingSerializer — id, schema_field, file_column, required (boolean). The complete_mapping endpoint accepts exactly these names back (read/write symmetry).admin_import_serializer, admin_import_row_serializer, admin_import_mapping_serializer in Spree::Api::Dependencies.GET /imports/:id drives the whole wizard; the SPA switches UI on status:
status | SPA state | Notable payload |
|---|---|---|
mapping | mapping step | mappings (auto-assigned), schema_fields, csv_headers, sample_row |
completed_mapping | progress ("Preparing rows…", indeterminate) | rows are being created; rows_count still 0 |
processing | progress bar | rows_count, completed_rows_count, failed_rows_count; indeterminate while rows_count == 0 |
completed | results | final counters; failed rows via the rows endpoint; retry_failed_rows re-enters processing |
failed | file-level error | processing_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.
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).type falls back per registry, double-assigned file_column 422, template CSV includes metafield columns).typelizer:generate → generate:zod → integration specs → rswag:specs:swaggerize (the lefthook pre-commit covers 1–2).client.importsreadonly 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.
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:
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., ; | tab — the model's preferred_delimiter preference).GET /imports/template?type=…, Blob-URL download (same helper approach as use-export.ts).imports.create({ type, attachment: signedId, preferred_delimiter }) → onCreated.Hooks (packages/dashboard/src/hooks/use-imports.ts) — store-scoped query keys throughout:
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:
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.<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.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.
Single PR (decided 2026-07-10), in three internal layers:
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.client.imports resource, param/response types, MSW tests, changeset.ImportButton (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.
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.Spree.import_types entry (+ an ImportType union member). Never hardcode type-specific logic in the dashboard flow beyond button placement.large_import_threshold. It's a backend processing knob; the UI contract is identical for 10 rows and 100k rows.schema_field/file_column; complete_mapping accepts exactly those names. No client-side field translation.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.Spree::Api::V3::HttpCaching in imports controllers.None — all resolved 2026-07-10:
retry_failed_rows endpoint + results-screen button; see Design Details).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)./settings/imports under a new audit settings-nav group (see Design Details).Spree::Imports::Orders; separate backend effort if ever).<Can I="create" a="Spree::Product">), not a blanket Spree::Import subject (see Key Decisions).processing imports (dead last worker never re-checks completion) → accepted as-is; worker restarts cover it in practice. No sweeper, no poll backoff.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/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.rbdocs/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.tsspree/api/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb, packages/dashboard-core/src/hooks/use-direct-upload.tsspree/api/app/serializers/spree/api/v3/import_serializer.rb, import_row_serializer.rb6.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)