Table Export — Section Integration Guide
Step-by-step guide to add the new backend export to an existing DataTable section.
Reference: COD Guides (cod.guides) — already integrated.
Prerequisites (once per environment, not per section): DB migration, worker process, env vars. See System overview.
What you need to know
| Topic | Detail |
|---|---|
| No new routes | Same GET endpoint as the table list |
| Export trigger | ?report=true&format=csv&recordsTotal=N + existing filters |
| Sync vs async | ≤ TABLE_EXPORT_SYNC_MAX_ROWS (default 5000) → CSV download; above → async job + Slack |
| Dev work per section | 3 backend tasks + 3 frontend tasks |
| Registry | Drop a file in definitions/ — loaded on first export for that resource (lazy) |
Backend
Task 1 — Export definition (columns)
Create backend/libraries/tableExport/definitions/{resource.key}.js.
Filename = registry key. Example: cod.guides.js → key cod.guides.
'use strict';
const myModuleUtil = require('../../myModule.util');
const generals = require('../../generals.util');
module.exports = {
queryUtil: myModuleUtil,
// Optional: enrich row before column formatters run
mapRow: (row, ctx) => ({ ...row, _exportLang: ctx.exportLang }),
columns: [
{ headerKey: 'datatable.column.tracking', format: (r) => r.tracking_number || '' },
{ headerKey: 'datatable.column.createdAt', format: (r) => generals.formatExportDate(r.created_at) },
// Use t() for translated cell values:
{ headerKey: 'datatable.column.status', format: (r, t) => t(`status.${r.status}`) },
],
};| Field | Required | Description |
|---|---|---|
queryUtil | Yes | Section util with buildQueryContext |
columns | Yes | Array of { headerKey, format } |
mapRow | No | See What is mapRow? below — omit if not needed |
Column helpers (generals.util.js): formatExportDate, formatExportAmount.
What is mapRow?
Optional hook that transforms each raw DB row before column format functions run:
SQL row → mapRow(row, ctx) → column.format(row) → CSV cellrow— object returned by the export queryctx— export context:exportLang,filters,filterLocale,languageId, etc.
If omitted, rows pass through unchanged.
When to use it:
| Use case | Example |
|---|---|
| Pass locale to amount formatters | Attach _exportLang from ctx.exportLang |
| Compute derived fields once | total: row.amount - row.discount |
| Parse JSON from the query | items: JSON.parse(row.raw_json || '[]') |
COD example — columns use generals.formatExportAmount(value, row), which reads row._exportLang to format currency with the requesting user's locale:
mapRow: (row, ctx) => ({ ...row, _exportLang: ctx.exportLang }),Without mapRow, formatExportAmount falls back to es-MX. Most sections can omit mapRow if column formatters only need fields already present in the SQL row.
No manual registration in definitions/index.js — definition files are discovered by filename and loaded on the first export request for that resourceKey (lazy). A misconfigured export does not prevent the server from starting.
Task 2 — buildQueryContext in section util
Add one function to the section util. Reuse the same query logic as the list handler.
Must return:
| Field | Type | Description |
|---|---|---|
queryConfigIDs | object | ORM config to paginate IDs (select, from, structure, order) |
buildQuery or buildQueryGuides | (ids: number[]) => object | ORM config for full row data |
empty | boolean | true when filters match nothing |
async function buildMyModuleQueryContext(data, filterLocale = '', pagination = {}, queryOptions = orm.READ_ONLY_QUERY_OPTIONS) {
const { start = 0, length = 100 } = pagination;
// ... same filter/where logic as the list endpoint ...
const queryConfigIDs = {
select: 's.id',
from: '...',
structure: `WHERE ... ${filterLocale}`,
order: data.sortBy ? `ORDER BY ${Db.escapeId(data.sortBy)} ${data.sortType === 'asc' ? 'ASC' : 'DESC'}` : '',
};
const buildQuery = (ids) => ({
select: '...',
from: '...',
structure: `WHERE s.id IN (${ids.map((id) => orm.escape(id)).join(',')})`,
order: queryConfigIDs.order,
});
return { queryConfigIDs, buildQuery, empty: false };
}
module.exports = {
buildQueryContext: buildMyModuleQueryContext,
};TIP
Export reads use orm.READ_ONLY_QUERY_OPTIONS internally. Pass queryOptions through any async lookups inside buildQueryContext (e.g. carrier ID resolution).
INFO
You do not implement count or iterateForExport. Chunked ID pagination and CSV generation are handled by tableExport/index.js.
Task 3 — Controller + route
In the existing list handler, detect export and delegate:
const { isTableExport, TABLE_DEFAULTS } = require('../schemas/tableDefaults');
const tableExportUtil = require('../libraries/tableExport');
async getMyModuleList(request, h) {
const data = request.query;
let { start, length } = data;
const exportRequest = isTableExport(data.report);
if (exportRequest) {
start = TABLE_DEFAULTS.start;
}
const { queryConfigIDs, buildQuery, empty } = await myModuleUtil.buildQueryContext(
data,
request.pre?.filterLocale,
{ start, length }
);
if (empty || !queryConfigIDs) {
if (exportRequest) return false;
return { draw: data.length, recordsTotal: 0, recordsFiltered: 0, data: [] };
}
if (exportRequest) {
return tableExportUtil.handleExportRequest(request, h, 'my.module');
}
// ... normal paginated list response ...
}Route: spread tableQueryFields into the Joi query schema:
const { tableQueryFields } = require('../schemas/tableDefaults');
validate: {
query: Joi.object({
...tableQueryFields,
// ... section-specific filters ...
}),
},tableQueryFields adds: start, length, sortBy, sortType, localeId, report, format, recordsTotal.
Frontend
Task 4 — Service: detect export and call same path
In the section service, branch on report and use fetchReport (same URL as list):
import { isExportReport, resolveTableParams } from '../utils';
getMyModule(filters) {
const params = resolveTableParams(filters);
if (isExportReport(filters.report)) {
return this.fetchReport('/my-module', params, 'my-module');
}
return this.axios.get('/my-module', { params });
}fetchReport (in base.service.js):
- 200 →
{ sync: true, blob, filename }— immediate CSV download - 202 →
{ async: true, uuid, status, row_count }— job enqueued
recordsTotal is sent automatically by DataTable — no extra work in the service.
Task 5 — Table: exportConfig
In the section Table.vue, pass exportConfig to DataTable:
<DataTable
:loadData="loadData"
:exportConfig="{
strategy: 'backend',
format: 'csv',
name: 'my-module',
resourceKey: 'my.module',
}"
/>| Field | Description |
|---|---|
strategy | Must be 'backend' (legacy client-side export uses default) |
format | 'csv' (async exports support CSV only) |
name | Base filename for sync download |
resourceKey | Same key as the definition file / handleExportRequest |
Do not add a separate export route or export button — the header export icon (SectionTitle) triggers DataTable automatically.
Async exports: no table loading spinner, no polling. The global exports panel (header) and Slack notification handle completion.
Task 6 — i18n (Slack resource label)
Add the resource label for Slack messages:
| Key | es-MX | en-US |
|---|---|---|
tableExport.slack.resource.my.module | Mi módulo | My module |
Column headers use existing datatable.column.* keys from the definition headerKey fields.
Checklist (copy for PR)
Backend
[ ] definitions/{resource.key}.js — queryUtil, columns, optional mapRow
[ ] Section util — buildQueryContext → { queryConfigIDs, buildQuery, empty }
[ ] Controller — isTableExport → handleExportRequest(request, h, 'resource.key')
[ ] Route — ...tableQueryFields in Joi schema
Frontend
[ ] Service — isExportReport → fetchReport(samePath)
[ ] Table.vue — exportConfig { strategy: 'backend', format, name, resourceKey }
[ ] i18n — tableExport.slack.resource.{resource_key}
Manual test
[ ] Small dataset (≤ 5000 rows) — sync CSV download
[ ] Large dataset (> 5000 rows) — 202 async, panel shows job, Slack link works
[ ] Filters applied — export respects same filters as table
[ ] Empty result — appropriate error / no-data responseCOD reference (copy patterns from)
| Layer | File |
|---|---|
| Definition | backend/libraries/tableExport/definitions/cod.guides.js |
| Query context | backend/libraries/codGuides.util.js → buildQueryContext |
| Controller | backend/controllers/cashOnDelivery.controller.js → getGuides |
| Route | backend/routes/cashOnDelivery.routes.js → GET /cod |
| Service | frontend/client/src/services/cashOnDelivery.service.js → getGuides |
| Table | frontend/client/src/views/cod/components/Table.vue → exportConfig |
Common mistakes
| Mistake | Fix |
|---|---|
New export route (GET /exports/my-module) | Use existing list route with report=true |
Implementing count / iterateForExport in section util | Only buildQueryContext is required |
Forgetting recordsTotal | DataTable sends it automatically with backend strategy |
resourceKey mismatch | Must match definition filename and handleExportRequest third arg |
| Legacy + backend mixed | Set strategy: 'backend' explicitly; legacy tables omit exportConfig |
| Export loading blocks table | Backend strategy does not set table loading on async exports |
Request / response contract
Export request (same as list + export params):
GET /my-module?report=true&format=csv&recordsTotal=1200&start=0&length=100&...filtersSync response (≤ 5000 rows):
HTTP 200
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="my-module-2026-06-18_10-00-00.csv"
<body: CSV>Async response (> 5000 rows):
HTTP 202
{ "async": true, "uuid": "...", "status": "pending", "row_count": 12000 }Download later via header panel or GET /exports/{uuid}/download.
