Server-side data with Ruby on Rails
This tutorial shows how to wire Handsontable’s dataProvider plugin to a Ruby on Rails API-only backend. The backend handles pagination, sorting, and filtering on the server. The frontend displays results and sends every edit back to the API.
Difficulty: Intermediate
Time: ~30 minutes
Backend: Ruby 3.2+, Rails 7.1+, kaminari for pagination, rack-cors for CORS
What you’ll build
An Order Management grid that:
- Loads orders page by page from a Rails API
- Sorts orders by any column on the server
- Filters orders by column value on the server
- Creates, updates, and deletes rows via dedicated collection endpoints
- Handles Rails’ default snake_case payloads without mismatching Handsontable column keys
Before you begin
- Docker and Docker Compose installed
- Node.js 18 or later and npm installed
No local Ruby or Rails installation is required — the backend runs inside Docker.
Step 1 — Add the Ruby gems
Add kaminari (pagination) and rack-cors (cross-origin requests) to the Gemfile:
# Gemfile -- relevant additions only.# Add these two gems to the Gemfile generated by `rails new orders-api --api`,# then run `bundle install`.
# Pagination helpers for ActiveRecord.# Provides .page(n).per(size) on any relation and exposes .total_count,# which this recipe returns to Handsontable as `total_rows`.gem "kaminari"
# Cross-origin request support.# Lets the Rails API accept requests from the frontend dev server# (for example, Vite on http://localhost:5173).gem "rack-cors"Install them:
bundle installWhy these two gems?
kaminarigives you a.page(n).per(size)query method on any ActiveRecord relation. It also exposestotal_count, which you return to Handsontable astotalRows.rack-corslets the Rails API accept requests from a different origin than the frontend dev server. Without it, the browser blocks the requests before Rails sees them.
Step 2 — Generate the Order model
Use the generator to create the model, the migration, and a matching controller file:
rails generate model Order order_number:string customer:string status:string total:decimalrails db:migrateThe generated migration adds id and created_at / updated_at columns automatically. The final schema has the six fields referenced throughout this recipe: id, order_number, customer, status, total, and created_at.
What’s happening:
- Rails’
decimaltype stores currency values without floating-point rounding. For production, specify precision and scale:total:decimal{10,2}. - The primary key
idis auto-incremented by the database. It becomes therowIdvalue on the Handsontable side. created_atis filled automatically by ActiveRecord on insert.
The server/order.rb file contains the minimal model with validations and a status enum:
# app/models/order.rb## Order model for the Order Management demo grid.## Fields match the Handsontable column definitions:# id - auto-increment primary key, used as rowId on the frontend# order_number - short identifier shown in the first column# customer - customer display name# status - workflow state (pending, paid, shipped, delivered, cancelled)# total - order total; DecimalField avoids floating-point rounding# created_at - read-only timestamp filled on insertclass Order < ApplicationRecord STATUSES = %w[pending paid shipped delivered cancelled].freeze
validates :order_number, presence: true, uniqueness: true validates :customer, presence: true validates :status, inclusion: { in: STATUSES } validates :total, numericality: { greater_than_or_equal_to: 0 }
# Keeps the initial grid display predictable before the user sorts. default_scope -> { order(created_at: :desc) }endStep 3 — Seed the database
Add realistic seed data in db/seeds.rb:
# db/seeds.rb## Run with: rails db:seed## Inserts 50 realistic order rows. The check against existing data makes# the seed idempotent -- running it twice does not duplicate rows.
if Order.exists? puts "Database already seeded -- skipping."else customers = [ "Acme Corp", "Vertex Industries", "Harbor Goods", "Alpine Supply Co.", "Summit Partners", "Meridian Analytics", "Riverstone Ltd.", "Orbit Logistics", "Beacon Retail", "Northwind Traders" ]
statuses = %w[pending paid shipped delivered cancelled]
orders = 50.times.map do |i| { order_number: "ORD-#{(1000 + i).to_s.rjust(5, '0')}", customer: customers.sample, status: statuses.sample, total: (50 + rand * 4950).round(2), created_at: rand(120).days.ago } end
Order.insert_all!(orders)
puts "Seeded #{orders.size} orders."endRun it:
rails db:seedThe seed script inserts 50 orders across realistic statuses (pending, paid, shipped, delivered, cancelled). It checks whether data already exists, so running it twice does not duplicate rows.
Step 4 — Configure the routes
Open config/routes.rb and register the orders resource inside an api namespace:
# config/routes.rb## All routes are mounted under /api so the controller lives in Api::OrdersController# and the JSON API is clearly separated from any server-rendered views.## only: [:index] keeps the single built-in RESTful route (GET /api/orders)# and replaces single-resource create/update/destroy with the batch endpoints# below. Handsontable's dataProvider sends every mutation as an array in a# single request, so batch endpoints fit its payload shape naturally.Rails.application.routes.draw do namespace :api do resources :orders, only: [:index] do collection do # POST /api/orders/create_rows -- insert many rows in one request post :create_rows
# PATCH /api/orders/update_rows -- update many rows in one request patch :update_rows
# DELETE /api/orders/remove_rows -- delete many rows in one request delete :remove_rows end end endendWhat’s happening:
namespace :apiprefixes all routes with/apiand scopes the controller underApi::OrdersController. This keeps the API separate from any server-rendered views you might add later.only: [:index]restricts the generated RESTful routes toGET /api/orders— standard per-resource create/update/destroy routes are replaced by the batchcollectionroutes below.collection do ... endregisters three custom routes at the collection URL (/api/orders/...) instead of the detail URL (/api/orders/:id). Handsontable’sdataProvidersends every mutation as an array in a single request, so batch endpoints are what you want.
The three resulting routes are:
| Method | URL | Controller action |
|---|---|---|
POST | /api/orders/create_rows | create_rows |
PATCH | /api/orders/update_rows | update_rows |
DELETE | /api/orders/remove_rows | remove_rows |
Step 5 — Configure CORS
Create config/initializers/cors.rb:
# config/initializers/cors.rb## Allow cross-origin requests from the frontend dev servers.# Without rack-cors the browser blocks the preflight OPTIONS request# before Rails ever sees it.## insert_before 0 puts this middleware at the very front of the stack# so it runs before Rails' routing can reject a cross-origin preflight.## Replace the dev-server origins with your actual production domain(s)# before deploying. Never use `origins "*"` with credentialed requests.Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "http://localhost:5173", # Vite dev server "http://localhost:3000" # Create React App / Next.js dev server
resource "/api/*", headers: :any, methods: [:get, :post, :patch, :put, :delete, :options], expose: ["Content-Type"]
# If the frontend sends credentials (cookies, Authorization headers), # also set `credentials: true` here and pin `origins` to exact URLs -- # wildcards are not allowed with credentialed requests. endendWhat’s happening:
insert_before 0puts the middleware at the very front of the stack so it runs before Rails’ routing.originslists the URLs that are allowed to call the API. Replace these with your production domain before deploying.resource "/api/*"scopes the CORS rule to the API endpoints only.methodsmust include:optionsfor browsers to complete preflight requests on non-simple methods (PATCH,DELETE).
Production note: Never use origins "*" in production. Pin the list to the exact domains that own your Handsontable deployment.
Step 6 — Decide on the case convention
Rails emits snake_case JSON by default (order_number, created_at). Handsontable’s column data keys must match whatever the server returns.
You have two practical options:
Option A — Keep snake_case (used in this recipe). Set data: 'order_number' on the matching Handsontable column. No transformation on either side.
Option B — Emit camelCase from Rails. Override serialization globally:
class ActiveRecord::Base def as_json(options = {}) super(options).deep_transform_keys { |key| key.to_s.camelize(:lower) } endendThen use data: 'orderNumber' on the frontend and also translate the sort_prop and filters[][prop] values back to snake_case in the controller.
Why this matters: mixing conventions — Rails returning order_number while Handsontable columns declare data: 'orderNumber' — silently breaks pagination, sorting, and filtering. The grid renders empty cells and the server receives unknown column names. Pick one convention and stick to it.
The rest of this recipe uses Option A (snake_case everywhere).
Step 7 — Build the controller
Create app/controllers/api/orders_controller.rb:
# app/controllers/api/orders_controller.rb## API controller providing paginated, sortable, and filterable order data,# plus three batch CRUD actions that match Handsontable's dataProvider# payload shape.## The standard RESTful routes would operate on single resources at a time.# Handsontable sends all row mutations as arrays, so we expose dedicated# collection endpoints: create_rows / update_rows / remove_rows.module Api class OrdersController < ApplicationController # Whitelist of columns that are allowed to appear in `order()` and # raw SQL fragments. Values that reach these positions are not quoted # as bind parameters, so anything that is not validated is an # SQL-injection risk. Keep this list tight. SORTABLE_COLUMNS = %w[order_number customer status total created_at].freeze
# ------------------------------------------------------------------ # GET /api/orders # # Accepts: # page, page_size -- pagination # sort_prop, sort_order -- single-column sort # filters[N][prop|value|condition] -- column filters # # Returns: # { rows: [...], total_rows: 123 } # # The JSON shape is converted to { rows, totalRows } on the frontend # (or you can override serialization globally to emit camelCase -- # see the recipe for the trade-offs). # ------------------------------------------------------------------ def index scope = Order.all scope = apply_filters(scope) scope = apply_sort(scope) scope = scope.page(params[:page]).per(params[:page_size] || 10)
render json: { rows: scope.as_json, total_rows: scope.total_count } end
# ------------------------------------------------------------------ # POST /api/orders/create_rows # # Accepts the onRowsCreate payload: { rows: [ { ... }, ... ] }. # Returns the created rows with their database-assigned ids so # dataProvider can replace client-side placeholders in its row map. # ------------------------------------------------------------------ def create_rows payload = Array(params[:rows]) allowed = Order.column_names - %w[id created_at updated_at]
rows = Order.transaction do payload.map do |row| Order.create!(row.to_unsafe_h.slice(*allowed)) end end
render json: { rows: rows.as_json }, status: :created rescue ActiveRecord::RecordInvalid => e render json: { error: e.message }, status: :unprocessable_entity end
# ------------------------------------------------------------------ # PATCH /api/orders/update_rows # # Accepts { rows: [ { id, changes: { ... } }, ... ] }. # `changes` only contains the fields that the user edited. # ------------------------------------------------------------------ def update_rows payload = Array(params[:rows]) allowed = Order.column_names - %w[id created_at updated_at]
updated = Order.transaction do payload.map do |row| record = Order.find(row[:id]) changes = row[:changes].to_unsafe_h.slice(*allowed) record.update!(changes) record end end
render json: { rows: updated.as_json } rescue ActiveRecord::RecordNotFound => e render json: { error: e.message }, status: :not_found rescue ActiveRecord::RecordInvalid => e render json: { error: e.message }, status: :unprocessable_entity end
# ------------------------------------------------------------------ # DELETE /api/orders/remove_rows # # Accepts { row_ids: [1, 2, 3] }. # Uses delete_all so the whole batch runs in a single SQL statement. # Returns 204 No Content on success. # ------------------------------------------------------------------ def remove_rows ids = Array(params[:row_ids]) Order.where(id: ids).delete_all head :no_content end
private
# Translate Handsontable's sort state into ActiveRecord's .reorder() call. # Uses .reorder (not .order) so any default_scope ordering is replaced # rather than appended, which guarantees a single deterministic sort. # The hash form quotes the column name safely. # Values that are not on the whitelist are silently ignored -- no SQL # is generated for them. def apply_sort(scope) prop = params[:sort_prop] order = params[:sort_order] == "desc" ? :desc : :asc
return scope unless SORTABLE_COLUMNS.include?(prop)
scope.reorder(prop => order) end
# Translate Handsontable's filters[] param into chained .where calls. # Each condition is validated against the same whitelist used for sort. # Multiple conditions combine with AND. # # Conditions supported: # contains / not_contains -- case-insensitive substring match (ILIKE) # eq / neq -- exact match # begins_with / ends_with -- case-insensitive prefix / suffix match # gt / gte / lt / lte -- numeric comparisons # empty / not_empty -- checks for NULL or empty string def apply_filters(scope) filters = params[:filters] return scope if filters.blank?
Array(filters.values).each do |filter| prop = filter[:prop] value = filter[:value] condition = filter[:condition].presence || "contains"
next unless SORTABLE_COLUMNS.include?(prop)
safe = Order.sanitize_sql_like(value.to_s) scope = case condition when "contains" then scope.where("#{prop} ILIKE ?", "%#{safe}%") when "not_contains" then scope.where.not("#{prop} ILIKE ?", "%#{safe}%") when "eq" then scope.where(prop => value) when "neq" then scope.where.not(prop => value) when "begins_with" then scope.where("#{prop} ILIKE ?", "#{safe}%") when "ends_with" then scope.where("#{prop} ILIKE ?", "%#{safe}") when "gt" then scope.where("#{prop} > ?", value) when "gte" then scope.where("#{prop} >= ?", value) when "lt" then scope.where("#{prop} < ?", value) when "lte" then scope.where("#{prop} <= ?", value) when "empty" string_col?(prop) ? scope.where("#{prop} IS NULL OR #{prop} = ''") : scope.where(prop => nil) when "not_empty" string_col?(prop) ? scope.where.not("#{prop} IS NULL OR #{prop} = ''") : scope.where.not(prop => nil) else scope end end
scope end
def string_col?(prop) Order.column_for_attribute(prop)&.type&.in?(%i[string text]) end endendThis single file implements paginated index, server-side sort and filter, and the three batch CRUD actions.
Whitelist sortable columns
Sort inputs that flow into order() reach the SQL ORDER BY clause. Treating them as raw strings is an SQL-injection risk. Whitelist them once at the top of the class (SORTABLE_COLUMNS in orders_controller.rb).
index — paginated list with sort and filter
See the index action in orders_controller.rb.
What’s happening:
- Start from
Order.all. This builds a base ActiveRecord relation without hitting the database yet. apply_filtersadds.where(...)clauses from the parsed Handsontable filter list.apply_sortadds an.order(...)clause if the request includes a whitelistedsort_prop.kaminariaddsLIMITandOFFSETvia.page(n).per(size). The query is still not executed.as_jsontriggers the query and serializes results.total_countissues a separateSELECT COUNT(*)that Handsontable uses to size the paginator.
The response shape is exactly what dataProvider expects: { rows, total_rows }. The frontend maps total_rows to totalRows (see Step 9).
Sort helper
See apply_sort in orders_controller.rb.
What’s happening:
params[:sort_prop]comes directly from the frontend’ssort_prop=query param (see Step 9).SORTABLE_COLUMNS.include?(prop)is the whitelist check. Any column not on the list is silently ignored — no SQL is generated for it.scope.reorder(prop => order)uses the hash form of.reorder(), which ActiveRecord quotes safely.reorderis used instead oforderbecause theOrdermodel has adefault_scopethat sorts bycreated_at DESC—reorderreplaces that default, whileorderwould append to it.params[:sort_order]falls back to:ascunless the client explicitly sendsdesc. This prevents arbitrary SQL fragments (for example,created_at; DROP TABLE orders) from reaching the database.
Filter helper
Handsontable sends filters as an indexed structure:
?filters[0][prop]=status&filters[0][value]=shipped&filters[0][condition]=eq?filters[1][prop]=total&filters[1][value]=100&filters[1][condition]=gteRails parses bracket-indexed params into a nested hash automatically. See apply_filters and string_col? in orders_controller.rb.
What’s happening:
- The
SORTABLE_COLUMNScheck is reused as a filter whitelist. Column names that reach the raw SQL fragment (ILIKE,>=, etc.) must be validated against a fixed list. sanitize_sql_likeescapes LIKE metacharacters (%,_,\) in the user-supplied value so they are treated as literals, not wildcards.ILIKEis PostgreSQL-specific. On SQLite or MySQL, useLIKEwith aCOLLATEclause or case-normalize the input.empty/not_emptydistinguish between string columns (check for NULL and blank string) and non-string columns (check for NULL only) via thestring_col?helper.- Each condition rebinds
scope, so multiple filters combine withAND.dataProviderdoes not sendORgroups by default.
Batch CRUD actions
See create_rows, update_rows, and remove_rows in orders_controller.rb.
What’s happening:
create_rows— receives{ rows: [...] }. Each row is inserted withcreate!inside a transaction, which raises on validation errors and rolls back all inserts if one fails.column_names - ['id', 'created_at', 'updated_at']blocks the client from setting system-managed fields. Status 201 tellsdataProviderthe rows were created.update_rows— receives{ rows: [{ id, changes: { ... } }] }.changes.slice(*allowed)(whereallowedalso excludesid,created_at,updated_at) drops any unknown or system-managed keys so they never reach the ORM. All updates run inside a transaction. Returning the updated rows letsdataProviderreconcile its internal row map.remove_rows— receives{ row_ids: [1, 2, 3] }.delete_allissues a singleDELETE ... WHERE id IN (...)statement, which is faster than deleting each row one by one. Status 204 signals a successful delete with no response body.
Step 8 — CSRF in API mode
ApplicationController in API mode inherits from ActionController::API, which does not include RequestForgeryProtection. That means CSRF verification is off by default for this project, and fetch() requests from the browser do not need an X-CSRF-Token header.
When does CSRF matter?
- If you mount the Rails API under the same origin as a classic Rails app that uses cookie-based sessions, the classic app enforces CSRF protection — your fetch calls will need the token.
- Token-based auth (for example, a
Authorization: Bearerheader) does not require CSRF protection because the browser never attaches the token automatically.
This recipe assumes a stateless API and an Authorization header (or no auth) in production. Add protect_from_forgery with: :null_session inside the controller only if you re-enable session-based auth.
Step 9 — Build the request URL on the frontend
Handsontable’s dataProvider calls fetchRows with { page, pageSize, sort, filters }. Map those to the Rails parameter names.
What’s happening:
pageSizeis converted topage_sizebecause Rails and kaminari use snake_case parameter names.sortis split into two flat params,sort_propandsort_order. The controller’sapply_sortreads them directly.- Filters are flattened from
DataProviderFilterColumn[](each withpropandconditions: [{name, args}]) into one bracket-notation entry per condition. Rails converts the bracket notation to a nested hash automatically.
Step 10 — Initialize Handsontable
Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The Rails API runs on http://localhost:3000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.
/* file: app.component.ts */import { Component, ViewChild, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA,} from '@angular/core';import { HotTableModule, HotTableComponent } from '@handsontable/angular-wrapper';import type { DataProviderQueryParameters, RowsCreatePayload, RowUpdatePayload, RowMutationPayload, RowMutationRemovePayload,} from 'handsontable/plugins/dataProvider';
const API_BASE = '/api/orders';
// Rails reads: page_size, sort_prop, sort_order, filters[N][prop/condition/value]function buildUrl(base: string, params: DataProviderQueryParameters): string { const query = new URLSearchParams();
query.set('page', String(params.page)); query.set('page_size', String(params.pageSize));
if (params.sort?.prop) { query.set('sort_prop', params.sort.prop); query.set('sort_order', params.sort.order ?? 'asc'); }
if (params.filters?.length) { let idx = 0; params.filters.forEach(({ prop, conditions }) => { conditions.forEach((cond) => { if (!cond?.name) return; query.set(`filters[${idx}][prop]`, prop); query.set(`filters[${idx}][condition]`, cond.name); if (cond.args?.[0] != null) { query.set(`filters[${idx}][value]`, String(cond.args[0])); } idx++; }); }); }
return `${base}?${query}`;}
@Component({ standalone: true, encapsulation: ViewEncapsulation.None, imports: [HotTableModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'example1-server-side-rails', template: ` <div> <hot-table [settings]="settings"></hot-table> </div> `,})export class AppComponent { @ViewChild(HotTableComponent) readonly hotRef!: HotTableComponent;
private removeConfirmed = false;
settings = { dataProvider: { rowId: 'id',
// Called on every page change, sort, and filter. fetchRows: async (queryParameters: DataProviderQueryParameters, { signal }: { signal: AbortSignal }) => { const url = buildUrl(API_BASE, queryParameters); const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
// Rails returns: { rows: [...], total_rows: n } const json = await res.json(); return { rows: json.rows, totalRows: json.total_rows }; },
// Fires when the user inserts rows via the context menu. // payload: { position: 'above'|'below', referenceRowId, rowsAmount } onRowsCreate: async (payload: RowsCreatePayload) => { const newRows = Array.from({ length: payload.rowsAmount }, () => ({ order_number: `ORD-${Date.now()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`, customer: 'New Customer', status: 'pending', total: 0, }));
const res = await fetch(`${API_BASE}/create_rows`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: newRows }), });
if (!res.ok) { const err = await res.json().catch(() => ({})) as { error?: string }; throw new Error(err.error ?? `Create failed: ${res.status}`); }
const json = await res.json(); const info = json.rows.map((r: { order_number: string }) => `(order: ${r.order_number})`).join(', '); // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.hotRef.hotInstance!.getPlugin('notification') as any).showMessage({ variant: 'success', title: 'Row added', message: `Created: ${info}`, duration: 3000, });
return json.rows; },
// Fires after a cell edit, paste, or autofill batch. onRowsUpdate: async (rows: RowUpdatePayload[]) => { const res = await fetch(`${API_BASE}/update_rows`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: rows.map((r) => ({ id: r.id, changes: r.changes })), }), });
if (!res.ok) { const err = await res.json().catch(() => ({})) as { error?: string }; throw new Error(err.error ?? `Update failed: ${res.status}`); } },
// Fires after the user confirms deletion. onRowsRemove: async (rowIds: unknown[]) => { const res = await fetch(`${API_BASE}/remove_rows`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ row_ids: rowIds }), });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`); }, },
// beforeRowsMutation is sync (checks for a strict `=== false` return), so // we can't await an async prompt inline. Instead: cancel the original // attempt, show a notification with Delete/Cancel actions, and on Delete // re-issue the remove via the DataProvider API. The flag lets the second // pass through without re-prompting. beforeRowsMutation: (operation: 'create' | 'update' | 'remove', payload: RowMutationPayload): false | void => { if (operation === 'remove' && !this.removeConfirmed) { const { rowsRemove } = payload as RowMutationRemovePayload; const hot = this.hotRef.hotInstance!; const count = rowsRemove.length; // eslint-disable-next-line @typescript-eslint/no-explicit-any const notification = (hot.getPlugin('notification') as any); const id = notification.showMessage({ variant: 'warning', title: 'Delete rows', message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`, duration: 0, actions: [ { label: 'Delete', type: 'primary', callback: () => { notification.hide(id); this.removeConfirmed = true; // eslint-disable-next-line @typescript-eslint/no-explicit-any (hot.getPlugin('dataProvider') as any) .removeRows(rowsRemove) .finally(() => { this.removeConfirmed = false; }); }, }, { label: 'Cancel', type: 'secondary', callback: () => notification.hide(id), }, ], }); return false; } },
pagination: { pageSize: 10 }, columnSorting: true, filters: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any dropdownMenu: ['filter_by_condition', 'filter_action_bar'] as any, contextMenu: true, emptyDataState: true, notification: true, dialog: true, rowHeaders: true, colHeaders: ['Order #', 'Customer', 'Status', 'Total', 'Created'], columns: [ { data: 'order_number', type: 'text' }, { data: 'customer', type: 'text' }, { data: 'status', type: 'text' }, { data: 'total', type: 'numeric', numericFormat: { pattern: '$0,0.00' } }, { data: 'created_at', type: 'date', dateFormat: 'YYYY-MM-DD', readOnly: true }, ], height: 'auto', licenseKey: 'non-commercial-and-evaluation', };}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-server-side-rails></example1-server-side-rails></div>Key options explained:
| Option | What it does |
|---|---|
rowId: 'id' | Tells dataProvider which field uniquely identifies a row. Must match the Rails primary key name. |
{ signal } in fetchRows | Pass the AbortSignal to fetch() so in-flight requests are cancelled when the user sorts or filters before the previous response arrives. |
{ rowsAmount } in onRowsCreate | dataProvider passes the number of rows to add. The frontend builds default objects and sends them as { rows: [...] }. Returning json.rows lets dataProvider replace client-side placeholder ids with the ids assigned by Rails. |
beforeRowsMutation | Intercepts mutations before they run. Return false to cancel. Used here to show a delete-confirmation notification with Delete/Cancel actions instead of deleting immediately. |
pagination: { pageSize: 10 } | Enables the pagination toolbar. dataProvider passes the current page and size to fetchRows automatically. |
columnSorting: true | Enables column header click-to-sort. The sort state is passed to fetchRows. |
filters: true with dropdownMenu | Renders the column filter UI. Active conditions are passed to fetchRows. |
contextMenu: true | Enables right-click context menu with Insert row above / below and Remove row options. |
emptyDataState: true | Shows a friendly illustration when the API returns zero rows (for example, when a filter matches nothing). |
notification: true | Shows automatic error toasts when fetchRows or a mutation callback throws. Fetch failures include a Refetch action. |
dialog: true | Enables the Dialog plugin used internally by other plugins for confirmation prompts. |
How it works — Complete flow
- Initial load:
dataProvidercallsfetchRows({ page: 1, pageSize: 10 }). Rails returns the first 10 orders and the total row count. - User clicks a column header:
columnSortingupdates its sort state anddataProvidercallsfetchRowsagain withsort: { prop: 'total', order: 'desc' }. The controller’sapply_sortchecksSORTABLE_COLUMNS, then issuesorder(total: :desc). - User applies a column filter:
Filtersupdates its condition list anddataProvidercallsfetchRowswith thefiltersarray. The controller’sapply_filtersparses the indexed hash and chains.wherecalls. - User navigates to page 2:
dataProvidercallsfetchRows({ page: 2, pageSize: 10, ... }). kaminari returns rows 11-20. - User edits a cell:
dataProvidercallsonRowsUpdatewith[{ id: 7, changes: { total: 142.5 } }]. The frontend maps this to{ id, changes }and sends it.update_rowsapplies the change inside a transaction. - User adds a row:
dataProvidercallsonRowsCreate.create_rowsinserts the row and returns it with the database-assignedid.dataProviderupdates its row map so subsequent edits target the correct id. - User deletes rows:
dataProvidercallsonRowsRemove([3, 7, 14]).remove_rowsissues a singleDELETE ... WHERE id IN (3, 7, 14).
What you learned
- Rails API mode (
rails new ... --api) skips middleware you do not need for a JSON API and leaves CSRF protection off by default. - kaminari adds
.page(n).per(size)plus.total_countto any ActiveRecord relation — exactly what you need to build a{ rows, total_rows }response. - Validate every column name that reaches
order()or a raw SQL fragment against a fixed whitelist. Never trustparams[:sort_prop]orparams[:filters]directly. - Pick one case convention (snake_case or camelCase) for the whole round trip. Mixing conventions silently breaks pagination, sorting, and filtering.
- Translate
sort: { prop, order }on the frontend to flatsort_prop/sort_orderquery params. This matches Rails’ parameter-naming conventions and keeps the controller focused. - Handsontable’s
DataProviderFilterColumn[]({prop, conditions: [{name, args}]}) must be flattened into indexedfilters[N][prop/condition/value]bracket params before sending. Rails parses the bracket notation into a nested hash automatically — no custom decoder is required on the backend. - Use
rack-corsto allow requests from the frontend dev server. Place the middleware before0so it runs before Rails’ routing.