Back to Spree

Admin SPA CSV Export

docs/plans/5.5-admin-spa-csv-export.md

5.5.015.8 KB
Original Source

Admin SPA CSV Export

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

Summary

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.

Key Decisions (do not deviate without discussion)

  • Reuse the existing 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.
  • Filters travel as Ransack JSON. The SPA already converts its structured 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.
  • Status feedback is toast + polling, then auto-download. No separate page in v1. Email notification (existing Spree::ExportMailer.export_done) remains the durable backup. A future iteration can add a persistent /exports page if users ask for history.
  • Scope for v1: Products, Orders, Customers. Other types (gift cards, coupons, newsletter, translations) work backend-side once endpoints exist; we just won't surface buttons for them in v1.
  • Download via signed Blob URL, not a server-side redirect. The Admin API serializer exposes a short-lived signed URL the SPA fetches and triggers a browser download. This is the only way an SPA on a different origin can reliably download an attachment without losing JWT auth context. The legacy Rails admin's redirect_to attachment.url works for the same-origin Turbo flow but does not translate to the SPA.
  • Auth: JWT only in v1. Secret API keys (sk_xxx) with scopes (per 5.5-admin-api-key-scopes.md) get exports later. Adding a read_exports / write_exports scope is straightforward but out of scope here. Superseded (2026-06-12): no dedicated exports scope — each export type is gated by the read scope of the exported resource (Spree::Export.required_scope), so a key can never export data it couldn't read directly.
  • No multi-select bulk export. Like the legacy admin: export "filtered" (current Ransack query) or "all" (clears filters). Multi-select is a broader bulk-actions concern not yet built into the SPA.

Design Details

Backend: Spree::Api::V3::Admin::ExportsController

A 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.

ruby
# 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.

Backend: API routes

Add to spree/api/config/routes.rb inside the admin v3 namespace:

ruby
resources :exports, only: [:index, :show, :create, :destroy]

Backend: Spree::Api::V3::Admin::ExportSerializer

Extends 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.

ruby
# 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.

Backend: tests

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.
  • Authorization: 401 without JWT, 403 for users without manage Spree::Export ability.

Then run the full pipeline (per CLAUDE.md):

  1. bundle exec rake typelizer:generate — regenerates AdminExport.ts and Export.ts
  2. pnpm --filter @spree/sdk generate:zod — regenerates Zod schema
  3. bundle exec rake rswag:specs:swaggerize — updates docs/api-reference/store.yaml

Admin SDK: client.exports

New resource block in packages/admin-sdk/src/admin-client.ts:

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:

ts
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.

SPA: shared useExport hook

packages/dashboard/src/hooks/use-export.ts — wraps SDK create + polling:

ts
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.

SPA: shared filter conversion util

Extract the existing logic in resource-table.tsx:152–157 into packages/dashboard/src/lib/filters-to-ransack.ts:

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.

SPA: <ExportButton> and <ExportModal>

packages/dashboard/src/components/spree/export-button.tsx — renders inside the <TableToolbar actions={…}> slot:

tsx
<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.

SPA: wiring on index pages

Three one-line additions:

tsx
// 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.

SPA: tests

  • Vitest unit test for filtersToRansack (round-trip a few rules → expected hash).
  • Vitest + MSW test for useExport hook covering: create → poll → download URL → success toast; create → poll timeout → fallback toast.
  • No Playwright/E2E in v1 — the admin SPA does not yet have an E2E harness.

Migration Path

  1. Backend (PR 1)Spree::Api::V3::Admin::ExportsController, route, admin serializer, Spree.api.admin_export_serializer registration, RSwag spec. Run typelizer + zod + swaggerize. Ship.
  2. Admin SDK (PR 2)client.exports resource, ExportCreateParams type, changeset, vitest with MSW. Ship.
  3. SPA (PR 3)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.

Constraints on Current Work

  • Do not re-implement CSV generation logic. Adding columns, changing format, or supporting new export types means editing the existing Spree::Exports::* subclasses and Spree::CSV::*Presenter classes — not the new API controller.
  • Do not break the legacy 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.
  • Filters must round-trip through Ransack. When extending 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.
  • Signed URL expiration must stay short (≤ 5 min). Long-lived URLs leak via shared screen captures, browser history, and Slack link unfurls. The polling flow regenerates them; the email mailer regenerates on click via the standard rails_blob_url helper.

Open Questions

  • Scoping 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.
  • Should 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.
  • Do we want a dedicated /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).
  • Admin API key scopes for exports. Resolved (2026-06-12): secret-key clients hit these endpoints using the exported resource's read scope (read_products for product exports, etc.) — see 5.5-admin-api-key-scopes.md.

References

  • docs/plans/6.0-admin-spa.md — admin SPA architecture, table registry, extension points
  • docs/plans/6.0-admin-api.md — Admin API conventions, resource controllers, prefixed IDs
  • docs/plans/5.5-admin-api-key-scopes.md — future scope-based authorization (deferred for exports)
  • Legacy: spree/admin/app/controllers/spree/admin/exports_controller.rb — reference implementation
  • Existing model: spree/core/app/models/spree/export.rb, spree/core/app/models/spree/exports/
  • Existing serializer: spree/api/app/serializers/spree/api/v3/export_serializer.rb
  • CSV presenters: spree/core/app/presenters/spree/csv/