Skip to content

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

TopicDetail
No new routesSame GET endpoint as the table list
Export trigger?report=true&format=csv&recordsTotal=N + existing filters
Sync vs asyncTABLE_EXPORT_SYNC_MAX_ROWS (default 5000) → CSV download; above → async job + Slack
Dev work per section3 backend tasks + 3 frontend tasks
RegistryDrop 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.

javascript
'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}`) },
    ],
};
FieldRequiredDescription
queryUtilYesSection util with buildQueryContext
columnsYesArray of { headerKey, format }
mapRowNoSee 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 cell
  • row — object returned by the export query
  • ctx — export context: exportLang, filters, filterLocale, languageId, etc.

If omitted, rows pass through unchanged.

When to use it:

Use caseExample
Pass locale to amount formattersAttach _exportLang from ctx.exportLang
Compute derived fields oncetotal: row.amount - row.discount
Parse JSON from the queryitems: 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:

javascript
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:

FieldTypeDescription
queryConfigIDsobjectORM config to paginate IDs (select, from, structure, order)
buildQuery or buildQueryGuides(ids: number[]) => objectORM config for full row data
emptybooleantrue when filters match nothing
javascript
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:

javascript
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:

javascript
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):

javascript
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:

vue
<DataTable
    :loadData="loadData"
    :exportConfig="{
        strategy: 'backend',
        format: 'csv',
        name: 'my-module',
        resourceKey: 'my.module',
    }"
/>
FieldDescription
strategyMust be 'backend' (legacy client-side export uses default)
format'csv' (async exports support CSV only)
nameBase filename for sync download
resourceKeySame 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:

Keyes-MXen-US
tableExport.slack.resource.my.moduleMi móduloMy 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 response

COD reference (copy patterns from)

LayerFile
Definitionbackend/libraries/tableExport/definitions/cod.guides.js
Query contextbackend/libraries/codGuides.util.jsbuildQueryContext
Controllerbackend/controllers/cashOnDelivery.controller.jsgetGuides
Routebackend/routes/cashOnDelivery.routes.jsGET /cod
Servicefrontend/client/src/services/cashOnDelivery.service.jsgetGuides
Tablefrontend/client/src/views/cod/components/Table.vueexportConfig

Common mistakes

MistakeFix
New export route (GET /exports/my-module)Use existing list route with report=true
Implementing count / iterateForExport in section utilOnly buildQueryContext is required
Forgetting recordsTotalDataTable sends it automatically with backend strategy
resourceKey mismatchMust match definition filename and handleExportRequest third arg
Legacy + backend mixedSet strategy: 'backend' explicitly; legacy tables omit exportConfig
Export loading blocks tableBackend 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&...filters

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

Envia Admin