A working implementation of the Server-side Data with Ruby on Rails recipe. Handsontable's dataProvider plugin connects to a Rails 7.1 API backed by PostgreSQL. All data operations — pagination, sorting, filtering, and CRUD — are handled server-side.
| Layer | Stack |
|---|---|
| Database | PostgreSQL 15 (Docker) |
| Backend | Rails 7.1 API-only, kaminari, rack-cors |
| Frontend (JS) | Vite + Handsontable (dataProvider, Pagination, Filters, ColumnSorting, ContextMenu, Notification) |
| Frontend (Angular) | Angular 21, @handsontable/angular-wrapper |
| Frontend (React) | React 19, @handsontable/react-wrapper |
The grid loads 50 seed orders and supports:
- Paginated fetching — kaminari returns
{ rows, total_rows }per page - Server-side sorting — click any column header
- Server-side filtering — use the column dropdown filter UI
- Create — add rows via the grid context menu
- Update — edit any cell inline
- Delete — remove rows via the grid context menu
- Docker with the Compose plugin
- Node.js 18+
- npm
bash setup.sh
# or: make setupThis single command:
- Builds the Rails Docker image
- Starts PostgreSQL and waits for it to be healthy
- Creates the database, runs migrations, and seeds 50 orders
- Starts the Rails API on http://localhost:3000
- Installs all frontend npm dependencies (JS, Angular, React)
- Builds Angular and React apps, then starts watchers for live rebuilds
- Starts the Vite dev server on http://localhost:5173 (proxies
/apito the Rails API)
| URL | Description |
|---|---|
| http://localhost:5173 | JS frontend |
| http://localhost:5173/angular.html | Angular frontend |
| http://localhost:5173/react.html | React frontend |
make setup # Build images, run migrations + seeds, start Rails API and the Vite dev server
make start # Start Docker services and the frontend dev server (after initial setup)
make stop # Stop Docker services (preserves data)
make logs # Stream Rails API container logs
make clean # Remove containers, volumes, and frontend node_modules
make backend-only # Start only the Rails API (DB + API containers, no frontend)
make frontend-only # Start only the Vite dev server (assumes API is already running)
make help # Show all available commandsrails/
├── setup.sh # One-command bootstrap script
├── Makefile # Convenience targets
├── README.md
├── docker-compose.yml
├── backend/ # Rails 7.1 API-only app
│ ├── Dockerfile
│ ├── Gemfile
│ ├── app/
│ │ ├── controllers/api/
│ │ │ └── orders_controller.rb # index, create_rows, update_rows, remove_rows
│ │ └── models/order.rb
│ ├── config/
│ │ ├── initializers/cors.rb
│ │ └── routes.rb
│ └── db/
│ ├── migrate/20240101000000_create_orders.rb
│ └── seeds.rb
├── frontend/ # JS entry point + Vite dev server (serves all 3 variants)
│ ├── package.json
│ ├── vite.config.js # proxies /api → localhost:3000; serves Angular/React builds
│ ├── index.html
│ └── src/main.js
├── frontend-angular/ # Angular variant (ng build --watch → served via Vite)
│ ├── angular.json
│ ├── package.json
│ └── src/app/
│ ├── app.component.ts
│ └── app.component.html
└── frontend-react/ # React variant (vite build --watch → served via Vite)
├── vite.config.ts
├── package.json
└── src/
├── main.tsx
└── App.tsx
| Method | URL | Action |
|---|---|---|
GET |
/api/orders?page=1&page_size=10&sort_prop=total&sort_order=desc&filters[0][prop]=status&filters[0][condition]=eq&filters[0][value]=paid |
Paginated, sorted, filtered list |
POST |
/api/orders/create_rows |
Insert rows — body: { rows: [...] } |
PATCH |
/api/orders/update_rows |
Update rows — body: { rows: [{ id, changes }] } |
DELETE |
/api/orders/remove_rows |
Delete rows — body: { row_ids: [1, 2] } |
Handsontable's Filters plugin emits conditions in its native format:
[{ prop: 'status', operation: 'conjunction', conditions: [{ name: 'eq', args: ['paid'] }] }]The buildUrl helper in frontend/src/main.js flattens this into the bracket notation that Rails parses automatically:
filters[0][prop]=status&filters[0][condition]=eq&filters[0][value]=paid
The controller's apply_filters method reads params[:filters].values and chains .where clauses — one per condition. Every column name is validated against SORTABLE_COLUMNS before reaching any SQL fragment.