docs/plans/5.5-admin-spa-csv-export.md
Status: Shipped in 5.5 (ExportsController + admin-sdk + useExport + export-button.tsx)
Target: Spree 5.5
Depends on: 6.0-admin-spa.md, 6.0-admin-api.md, 5.5-admin-api-key-scopes.md
Author: Damian Legawiec
Last updated: 2026-05-20
The legacy Rails admin (spree/admin) supports CSV export for products, orders, customers, gift cards, coupon codes, and newsletter subscribers via a modal that captures the current Ransack filters and queues a background job. The new Vite/React admin SPA (packages/dashboard) does not yet expose this. All backend infrastructure (Spree::Export polymorphic model, subclasses, GenerateJob, ActiveStorage attachment, email notification) already exists and is in production. This plan brings the feature back to the SPA by adding an Admin API CRUD surface for Spree::Export, an exports resource on the Admin SDK, and a toolbar button + modal + polling flow on the products, orders, and customers index pages.
Spree::Export model and subclasses verbatim. No new model, no new job, no new presenters. The legacy Rails Spree::Admin::ExportsController stays in place for now (spree/admin is being phased out, but other surfaces still mount it); the new endpoints live alongside it under Spree::Api::V3::Admin::ExportsController.FilterRule[] to Ransack params (resource-table.tsx:152–157) for list endpoints. The same conversion is reused for exports — the server stores it in search_params exactly as the legacy modal does. We do not invent a new SPA-shaped filter contract on the server; the API contract stays Ransack-native.Spree::ExportMailer.export_done) remains the durable backup. A future iteration can add a persistent /exports page if users ask for history.redirect_to attachment.url works for the same-origin Turbo flow but does not translate to the SPA.sk_xxx) with scopes (per 5.5-admin-api-key-scopes.md) get exports later. read_exports / write_exports scope is straightforward but out of scope here.Spree::Export.required_scope), so a key can never export data it couldn't read directly.Spree::Api::V3::Admin::ExportsControllerA new thin controller mounted under /api/v3/admin/exports. Inherits from Spree::Api::V3::Admin::ResourceController, gets index, show, create, destroy for free. Subclassed exports (Spree::Exports::Products, etc.) are dispatched via the type param exactly the way the legacy assign_params does it.
# spree/api/app/controllers/spree/api/v3/admin/exports_controller.rb
module Spree::Api::V3::Admin
class ExportsController < ResourceController
protected
def model_class
Spree::Export
end
def serializer_class
Spree.api.admin_export_serializer
end
def collection_includes
[:user, attachment_attachment: :blob]
end
def permitted_params
params.permit(:type, :format, :record_selection, search_params: {})
end
def build_resource
type_name = available_type(permitted_params[:type])
klass = type_name&.constantize || Spree::Export
attrs = permitted_params.except(:type).merge(
store: current_store,
user: current_admin_user
)
klass.new(attrs)
end
def available_type(name)
Spree::Export.available_types.map(&:to_s).find { |t| t == name }
end
end
end
update is intentionally omitted — exports are immutable once created. destroy is allowed so users can clean up old exports.
Add to spree/api/config/routes.rb inside the admin v3 namespace:
resources :exports, only: [:index, :show, :create, :destroy]
Spree::Api::V3::Admin::ExportSerializerExtends the existing Spree::Api::V3::ExportSerializer. Adds done (boolean), download_url (signed, short-lived URL via ActiveStorage), filename, byte_size. Per the CLAUDE.md store/admin split rule, timestamps stay; nothing customer-facing here so no Store serializer.
# spree/api/app/serializers/spree/api/v3/admin/export_serializer.rb
module Spree::Api::V3::Admin
class ExportSerializer < V3::ExportSerializer
typelize done: :boolean,
download_url: [:string, nullable: true],
filename: [:string, nullable: true],
byte_size: [:number, nullable: true]
attribute :done, &:done?
attribute :filename do |export|
export.attachment&.filename&.to_s if export.done?
end
attribute :byte_size do |export|
export.attachment&.byte_size if export.done?
end
attribute :download_url do |export|
next nil unless export.done?
Rails.application.routes.url_helpers.rails_blob_url(
export.attachment,
host: Spree::Current.store&.formatted_url || Rails.application.config.action_mailer.default_url_options[:host],
disposition: 'attachment',
expires_in: 5.minutes
)
end
end
end
The signed URL approach is the correct one for cross-origin SPAs and is consistent with how other ActiveStorage attachments are exposed in the Admin API (verified pattern). expires_in: 5.minutes is enough for the SPA to initiate the download; the URL is regenerated on every poll/refresh.
The Spree.api.admin_export_serializer configurable lives on Spree.api (the Dependencies system), matching how every other admin serializer is registered.
spree/api/spec/integration/api/v3/admin/exports_spec.rb — RSwag integration spec covering:
POST /admin/exports with type: 'Spree::Exports::Products' and inline Ransack search_params → 201, returns serialized export, queues job.POST with record_selection: 'all' → search_params cleared on persisted record.GET /admin/exports → paginated list, only current user's exports (or all if admin? — see Open Questions).GET /admin/exports/:id → returns done: false immediately, then done: true with download_url after the job runs.DELETE /admin/exports/:id → 204, attachment purged.manage Spree::Export ability.Then run the full pipeline (per CLAUDE.md):
bundle exec rake typelizer:generate — regenerates AdminExport.ts and Export.tspnpm --filter @spree/sdk generate:zod — regenerates Zod schemabundle exec rake rswag:specs:swaggerize — updates docs/api-reference/store.yamlclient.exportsNew resource block in packages/admin-sdk/src/admin-client.ts:
readonly exports = {
list: (params?: ListParams & Record<string, unknown>, options?: RequestOptions) =>
this.request<PaginatedResponse<AdminExport>>('GET', '/exports', {
...options,
params: params ? transformListParams(params) : undefined,
}),
get: (id: string, options?: RequestOptions) =>
this.request<AdminExport>('GET', `/exports/${id}`, options),
create: (params: ExportCreateParams, options?: RequestOptions) =>
this.request<AdminExport>('POST', '/exports', { ...options, body: params }),
delete: (id: string, options?: RequestOptions) =>
this.request<void>('DELETE', `/exports/${id}`, options),
}
Where ExportCreateParams is:
export interface ExportCreateParams {
type:
| 'Spree::Exports::Products'
| 'Spree::Exports::Orders'
| 'Spree::Exports::Customers'
/** Ransack query as a plain object. Server stores it in search_params. */
search_params?: Record<string, unknown>
/** 'filtered' (default) keeps search_params; 'all' clears them server-side. */
record_selection?: 'filtered' | 'all'
format?: 'csv'
}
A changeset goes in packages/admin-sdk/.changeset/. Per the [admin-sdk-unreleased] memory, no back-compat shim needed.
useExport hookpackages/dashboard/src/hooks/use-export.ts — wraps SDK create + polling:
export function useExport() {
const { adminClient } = useAdminClient()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (params: ExportCreateParams) => {
const exp = await adminClient.exports.create(params)
// Poll every 2s, max 5 minutes
return await pollUntilDone(adminClient, exp.id, {
intervalMs: 2000,
timeoutMs: 5 * 60 * 1000,
})
},
onSuccess: (exp) => {
if (exp.download_url) {
triggerBrowserDownload(exp.download_url, exp.filename ?? 'export.csv')
toast.success('Export ready — downloading')
} else {
toast.info('Export still processing — check your email')
}
},
onError: (err) => toast.error(`Export failed: ${err.message}`),
})
}
triggerBrowserDownload uses an anchor + download attribute with the signed URL — no fetch-and-blob needed because the URL is already signed and public for its 5-minute window.
If polling times out the toast falls back to "still processing — check your email" — the existing Spree::ExportMailer.export_done already covers this case server-side.
Extract the existing logic in resource-table.tsx:152–157 into packages/dashboard/src/lib/filters-to-ransack.ts:
export function filtersToRansack(filters: FilterRule[], columns: ColumnDef[]) {
const out: Record<string, unknown> = {}
for (const filter of filters) {
const col = columns.find((c) => c.key === filter.field)
const ransackKey = col?.ransackAttribute ?? filter.field
out[`${ransackKey}_${filter.operator}`] = filter.value
}
return out
}
Update resource-table.tsx to call this util (the inner flattening + q[] wrapping still happens server-bound via transformListParams). The export modal calls the same helper, then drops the result into search_params.
<ExportButton> and <ExportModal>packages/dashboard/src/components/spree/export-button.tsx — renders inside the <TableToolbar actions={…}> slot:
<ExportButton
type="Spree::Exports::Products"
search={search} // free-text term currently in toolbar
searchParam={table.searchParam}
filters={filters}
columns={table.columns}
/>
The button opens a Radix dialog (<ExportModal>) with two radio options ("Current filter" / "All records"), Submit button, and a tiny copy of how many records will be exported (read from the current list query's meta count). On submit it builds the Ransack hash, calls useExport().mutate(...).
The modal is a thin shell around the existing shadcn <Dialog> — same pattern used for product delete confirmation.
Three one-line additions:
// routes/_authenticated/$storeId/products/index.tsx
<ResourceTable
...
toolbarActions={(ctx) => <ExportButton type="Spree::Exports::Products" {...ctx} />}
/>
Same for orders/index.tsx (Spree::Exports::Orders) and customers/index.tsx (Spree::Exports::Customers). ResourceTable already accepts an actions prop on its toolbar — extend it to pass the current search, filters, and columns into a toolbarActions render-prop so the button has the data it needs without a context provider.
Permission check: hide the button if useAbility().can('create', 'Spree::Export') is false. The useAbility hook already exists for other gated UI.
filtersToRansack (round-trip a few rules → expected hash).useExport hook covering: create → poll → download URL → success toast; create → poll timeout → fallback toast.Spree::Api::V3::Admin::ExportsController, route, admin serializer, Spree.api.admin_export_serializer registration, RSwag spec. Run typelizer + zod + swaggerize. Ship.client.exports resource, ExportCreateParams type, changeset, vitest with MSW. Ship.filtersToRansack extraction, useExport hook, ExportButton, ExportModal, wiring on Products/Orders/Customers index pages. Test manually against local server. Ship.PRs are sequential because each depends on the previous one's API contract.
Spree::Exports::* subclasses and Spree::CSV::*Presenter classes — not the new API controller.Spree::Admin::ExportsController. Other surfaces (legacy admin, multi-vendor admin docs) still link to it. The new endpoints sit alongside; nothing in spree/admin changes.FilterRule operators or column metadata, anything that isn't a clean Ransack predicate suffix will silently fail in the export. The shared filtersToRansack util is the single source of truth for the SPA → Ransack mapping.rails_blob_url helper.index to current user vs all admins. Legacy admin uses accessible_by(current_ability) which gives full-store admins everyone's exports. The SPA polling flow only needs the user's own. Default to accessible_by for parity, but consider an mine: true filter param if it becomes noisy.download_url 30x redirect through our backend so we can revoke individual exports? Probably not in v1 — destroy already purges the attachment, which invalidates the signed URL. Revisit if compliance asks./exports history page? Not in v1 per design decision above. Reassess after dogfooding — if users export weekly and re-download often, the page is cheap to add (it's just a ResourceTable over client.exports.list).read_products for product exports, etc.) — see 5.5-admin-api-key-scopes.md.docs/plans/6.0-admin-spa.md — admin SPA architecture, table registry, extension pointsdocs/plans/6.0-admin-api.md — Admin API conventions, resource controllers, prefixed IDsdocs/plans/5.5-admin-api-key-scopes.md — future scope-based authorization (deferred for exports)spree/admin/app/controllers/spree/admin/exports_controller.rb — reference implementationspree/core/app/models/spree/export.rb, spree/core/app/models/spree/exports/spree/api/app/serializers/spree/api/v3/export_serializer.rbspree/core/app/presenters/spree/csv/