Recipe

Keep a chart honest when the grid pages server-side

Two of six blocks are in the browser and the chart plots them without a word of warning: five correct labels, a plausible axis, and the wrong region on top. The fix is not to annotate the chart, it is to change what the grid asks the server for.

A chart fed from a server-paged grid, and the same chart fed by the grid's own aggregate requestOpen in new tab

Built with ApexGrid, ApexCharts.js

A chart fed from a server-paged grid does not plot your dataset. It plots the blocks that happen to be cached, which is a function of how far the reader scrolled. The fix is not a label on the chart: it is to make the grid's own request the aggregate request, because the datasource closure is the only place in the page that sees both the question and the server's answer.

Everything below was measured in a browser on apex-grid-enterprise 0.7.0 (riding apex-grid 3.5.0) and apexcharts 7.8.0, against one deterministic fake server of 600 orders across five regions, 606,589 in revenue, served in blocks of 100. The demo above runs the same 600 rows.

npm install apex-grid apex-grid-enterprise apexcharts

What that harness establishes, before any of the reasoning:

  • The grid's aggregate surfaces all read grid.data: getAggregations(), getTotals() and getViewChartModel() each do. Under the infinite row model grid.data is an array sized to the server's rowCount whose unloaded entries are a frozen empty object, so at 200 of 600 rows loaded getAggregations() returned { revenue: { sum: 204988 } } against a true 606,589.
  • Nothing in any return value says which rows it was over. The totals are correct for the rows they were handed. That is the whole defect: the number is right and its population is unstated.
  • Neither row model hands the page an aggregate. The infinite model publishes an exact filtered row count and nothing else; the server-side model publishes neither, and silently drops extra keys a datasource returns.
  • Setting rowGroupCols and valueCols turns the grid's own fetch into the aggregate query: one request, five group rows, exact at first paint.

Which number does the chart need?

Three populations, and they are three different questions. Pick one on purpose, because the difference between them is the entire bug.

What the number is overWho can compute itWhat it costs
The rows the grid holds right nowThe page, from grid.data or getAggregations()Nothing, and it answers no question a reader asked. It changes when they scroll.
Every row matching the current filterOnly the serverOne aggregate query, which the grid is already sending if you configure it to
Every row in the table, filter ignoredOnly the serverA second query, and nothing the grid emits will keep it in step with the first

Row two is almost always the number on the page, and row two is the one the grid can be made to fetch for you.

Why can the grid not tell you the total is partial?

Because the row model never pretends the rows are absent. It fills the gaps.

Under infiniteRowModel the first response's rowCount sizes grid.data immediately, and every entry that has not arrived is the same frozen empty object. Measured at blockSize: 100 with two of six blocks in:

grid.aggregations = { revenue: ['sum'] }  // getAggregations() takes no
                                          // arguments: it reads this
grid.data.length                              // 600
grid.data.filter((r) => JSON.stringify(r) === '{}').length  // 400
grid.isRowLoading(grid.data[0])                // false
grid.isRowLoading(grid.data[250])              // true
grid.totalItems                                // 600
grid.pageCount                                 // 24
grid.getAggregations()                         // { revenue: { sum: 204988 } }

The true revenue sum is 606,589. Note what is confident in that list: the grid reports 600 items and 24 pages while two thirds of the rows are not in the browser, because the count came from the server and the values did not. After scrolling until all six windows had been requested (0 to 100 through 500 to 600), the same call returned exactly 606,589. With totalRow set, getTotals() returned 204,988 as well: the grand-total row tracks the same partial population, which is consistent and no more informative.

isRowLoading() is the documented way to tell a cached row from a placeholder, and it takes one row at a time. Aggregations documents what getAggregations() and totalRow compute; what it cannot tell you, because the row model is not its subject, is that "the view" and "the data" stop being the same thing the moment a datasource is involved.

What does the chart look like when it is wrong?

Entirely plausible. That is the finding.

Over that half-loaded grid, an <apex-grid-chart source="view" type="column" mode="inline"> with definition: { category: 'region', measures: ['revenue'] } rendered five correctly labelled bars (EMEA, LATAM, APAC, NA, MEA) against a value axis of 120000, 100000, 80000, 60000, 40000, 20000, 0, with no text node anywhere in the SVG matching partial, loading, incomplete, estimate or approx. Reading every <text> node out of it is how that was checked, not by eye.

After the scroll pulled the remaining four blocks in, the same element, same mapping, redrew with an axis to 250000 and five bars instead of six.

The ranking is the part worth dwelling on. The orders arrive in id order, which is date order, and the regional mix drifts across the year:

Revenue by regionFirst two blocks (200 rows)All 600 rows
EMEA104,069202,214
LATAM25,15250,263
APAC36,591239,974
NA26,49777,967
MEA12,67936,171
Total204,988606,589

The category order is identical in both columns, so nothing looks rearranged. The chart does not merely understate: it names EMEA the biggest region when the answer is APAC. A reader who acts on the first column acts on the wrong region, and no part of the picture is a clue.

There is one small tell, and it is worth stating precisely because it is ambiguous. That chart actually drew a sixth bar alongside the five: getViewChartModel() returned six categories, the last an empty string at 0, because the 400 placeholder rows aggregate into a category of their own. It is unlabelled and zero-height, so it reads as a gap rather than a warning, and it has two independent causes. Both reproduce: placeholder rows with totalRow never set, and totalRow set on a client grid holding all 600 rows, where the categories went from five to six the moment the grand-total row was configured. So an empty trailing category means "a row in data that is not a data row", which is not the same as a diagnosis of partial loading.

Why not call a separate aggregate endpoint?

Because you would have to keep that second query in step with the grid's filter, sort and quick filter by hand, and the grid gives you almost nothing to do it with. Measured, both events, exhaustively:

Infinite row modelServer-side row model
Eventapex-rows-loadedapex-server-rows-loaded
Object.keys(e.detail)exactly rowCount, exact, loadedBlocks, blockSizeexactly rows, loadedGroups
A row countYes, and exact: trueNo
An aggregateNoNo

So the infinite model publishes an exact count of the rows matching the current query and an inexact sum of them, side by side, with nothing in either to tell them apart: { rowCount: 600, exact: true, loadedBlocks: 2, blockSize: 100 } alongside a revenue sum of 204,988 over 200 rows.

Returning the aggregate from the datasource as an extra key does not help either. Returning grandTotals and filteredRowCount from getRows in both models left no trace of either: the event detail keys were unchanged, 'grandTotals' in grid was false, typeof grid.filteredRowCount was 'undefined', and getTotals() was {}. Each model reads exactly the result keys its docs describe (rows and rowCount, plus pivotResultFields and pivotResultGroups in pivot mode) and nothing else, which is confirmed in the shipped source as well as by the probe.

Which leaves exactly one place that sees the query and the answer at the same moment: inside getRows.

How do I make the grid's own request the aggregate request?

Configure the server-side row model to group and aggregate, and the single top-level request returns the five numbers the chart wants.

import 'apex-grid-enterprise/define'

const grid = document.createElement('apex-grid-enterprise')
grid.columns = columns

grid.serverSideRowModel = {
  rowGroupCols: ['region'],
  valueCols: { revenue: ['sum'], deals: ['sum'] },
  datasource: {
    async getRows(params) {
      // params carries the whole question: groupKeys, rowGroupCols, valueCols,
      // pivotCols, pivotMode, sortModel, filterModel, quickFilter.
      const res = await fetch('/api/orders/group', {
        method: 'POST',
        body: JSON.stringify(params),
      })
      const { rows } = await res.json()
      // One GROUP BY per level the reader opens, and the top level is the
      // aggregate the chart wants. Hand it over from right here, the one
      // place where the question (params) and the answer (rows) both exist.
      if (params.groupKeys.length === 0) redrawChart(rows)
      return { rows, rowCount: rows.length }
    },
  },
}
document.body.appendChild(grid)

What that produced, measured:

  • One request, at groupKeys: [], with parameter keys exactly groupKeys, rowGroupCols, valueCols, pivotCols, pivotMode, sortModel, filterModel, quickFilter.
  • grid.data became five group rows whose sums are identical to a GROUP BY over the full 600: EMEA 202,214, LATAM 50,263, APAC 239,974, NA 77,967, MEA 36,171.
  • A filter of revenue greater than 1200 (204 of the 600 rows) produced exactly one further request, and the five rows came back identical to the truth for that predicate: EMEA 103,566, APAC 103,642, NA 31,783, LATAM 28,192, MEA 16,968.
  • Sort and quick filter reach the datasource too: sortModel arrived as [{ key: 'revenue', direction: 'descending', caseSensitive: false }] and quickFilter as the raw string.

One shape detail that will bite a datasource author: filterModel's condition arrives resolved to the operation object, not as the string you set. Filtering revenue greater than 1200 delivered { key: 'revenue', condition: { name: 'greaterThan', label: 'Greater than', unary: false }, caseSensitive: false, searchTerm: 1200, criteria: 'and' }, so a server translating to SQL reads condition.name, and should accept a plain string as well since that is what the property takes going in.

Feeding a separate ApexCharts 7.8.0 instance from those five rows is then one options object, and it is exact at first paint:

new ApexCharts(el, {
  chart: { type: 'bar' },
  series: [{ name: 'Revenue', data: grid.data.map((r) => r.revenue) }],
  xaxis: { categories: grid.data.map((r) => r.region) },
}).render()

Rendered and read back: five correctly labelled bars on a 250000 axis, matching the truth row of the table above.

Why does the built-in chart element not do this for you?

Because grouping on the server takes the grouped field out of the columns, and the chart model maps categories by column key. Under rowGroupCols: ['region']:

grid.columns.map((c) => c.key)
// ['__ssrm_group__', 'id', 'month', 'rep', 'revenue', 'deals']   no 'region'
Object.keys(grid.data[0])
// ['region', 'revenue', 'deals']

The group value lives on the row and the column it came from is gone, replaced by one synthetic auto group column. Four ChartDefinition attempts on the live element, each read back from the rendered SVG:

definitionWhat rendered
{} (the automatic mapping)One unlabelled category, one bar per numeric column, revenue at 606,589 on an 800000 axis: the grand total
{ category: 'region', measures: ['revenue'] }The right five values under the labels 1, 2, 3, 4, 5
{ category: '__ssrm_group__', measures: ['revenue'] }Back to one unlabelled bar at 606,589
{ category: 'rep', measures: ['revenue'] }One unlabelled bar at 606,589 again

So the values are reachable and the labels are not. The sharpest version of this: getChartFields(), the list the built-in Data popover offers a user, advertises { key: '__ssrm_group__', label: 'Region', numeric: false }. The mapping UI offers a field the chart model cannot label by.

Filtering by the grouped column is gone for the same reason. grid.filterExpressions = [{ key: 'region', condition: 'equals', searchTerm: 'EMEA' }] threw TypeError: Cannot read properties of undefined (reading 'type') synchronously, no request reached the datasource, and the view did not change. Filtering revenue worked. So filter on the fields that are still columns. A restriction on the grouped field belongs in groupKeys instead, which is what expandServerGroup(['EMEA']) sends, or on a field you kept out of rowGroupCols.

What else is quietly wrong?

Two more, both reproduced on 0.7.0, and both the same shape: a number that is right about the rows it saw.

Expanding a group counts it twice. expandServerGroup(['EMEA']) took grid.data from 5 rows to 200 (the five group rows, plus EMEA's 195 leaves), and the revenue over the view went from 606,589 to 808,803, which is 606,589 plus EMEA's own 202,214. The server-computed group row and its newly arrived leaf rows both count, because both are rows in grid.data. getAggregations() returned 808,803 too.

Two totals on one grid can disagree, and one of them can be zero. On the flat server-side model (rowGroupCols: [], blockSize: 100, 100 of 600 rows loaded) with totalRow set, getTotals() returned { revenue: { sum: 0 } } while getAggregations() returned { revenue: { sum: 104391 } }. Neither is 606,589.

The lesson is not that these surfaces are broken. They compute correctly over the rows they are given. It is that a row model makes "the rows it was given" a moving target, and no return value mentions it.

When does none of this apply?

When the dataset fits in the browser. Hand the grid an array, let it group and aggregate client-side, and the built-in integrated chart is the right tool: it follows the view, redraws on sort, filter and edit, labels its categories correctly, and needs no second data path. source="view" is honest the moment every row is present.

The threshold is not row count, it is whether a datasource is involved at all. A grid with data set and no row model has one population, and the ambiguity this page is about cannot arise.

Which plan covers this?

What you needPackagePlan
The grid, client-side dataapex-gridCommunity and up
The chartapexchartsCommunity and up
Infinite row model, server-side row model, integrated charts, totalRowapex-grid-enterprisePremium and OEM

Checked against the one entitlements table the pricing page is generated from, rather than inferred from a docs page: ApexGrid-Enterprise is an add-on from the Premium plan upward, while ApexGrid and ApexCharts.js both start at Community. Community is the entry plan, and it is free only for organizations under $2M USD in annual revenue, budget or funding; at or above that it is a paid licence like the others. Nothing in the family is open source. The grid renders in full without a key, watermarked, so both row models can be tried against your own server before any of this matters. The pricing page has the matrix.

The server-side row model in full: the parameter tables, intra-group paging and pivot

See the pieces running

Reference documentation

Frequently Asked Questions

Why does my chart show the wrong totals when the grid loads rows from a server?

Because it is summing the blocks that happen to be cached. Under the infinite row model, apex-grid-enterprise 0.7.0 sizes grid.data to the rowCount the server returned and fills every unloaded entry with a frozen empty object, so getAggregations(), getTotals() and getViewChartModel() all compute over whatever has arrived. Measured at 200 of 600 rows loaded, getAggregations() returned a revenue sum of 204,988 against a true 606,589, and the same call returned exactly 606,589 once scrolling had pulled all six blocks in. Nothing in the return value says which rows it covered.

Should the chart show the current page, the filtered total, or the whole table?

Those are three different numbers, so decide before wiring anything. The rows currently loaded are the only population the page can compute by itself, and they change when the reader scrolls, so they answer no question a reader actually asked. The filtered total and the unfiltered total can only come from the server. The filtered total is almost always the one you want, and it is the one the grid will fetch for you: set rowGroupCols and valueCols and the request it already sends is the aggregate query.

Can I read the aggregate out of the ApexGrid row-model events?

No. On apex-grid-enterprise 0.7.0 the infinite model's apex-rows-loaded detail has exactly four keys, rowCount, exact, loadedBlocks and blockSize, and the server-side model's apex-server-rows-loaded has exactly rows and loadedGroups. Neither carries an aggregate. Extra keys returned from getRows are dropped as well: returning grandTotals and filteredRowCount left both event details unchanged, with 'grandTotals' in grid false and grid.filteredRowCount undefined. That leaves the datasource closure as the only place in the page that sees both the query and the server's answer.

Which ApexGrid row model should I use, infinite or server-side?

The infinite row model serves one flat list in fixed-size blocks and computes no aggregates, so a chart over it is always a chart of the loaded blocks. The server-side row model asks for one group level at a time and the server computes the aggregates carried on the group rows, which is what makes an honest chart possible: with rowGroupCols ['region'] and valueCols { revenue: ['sum'] }, one request returned five group rows whose sums were identical to a GROUP BY over all 600 rows. The two are mutually exclusive, as is client-side grouping.

Can the integrated chart element chart a server-side grouped grid?

It can plot the values and it cannot label them. Grouping on the server replaces the grouped column with one synthetic auto group column, so on apex-grid-enterprise 0.7.0 grid.columns held __ssrm_group__ and no region key while grid.data[0] still carried region. A definition of { category: 'region', measures: ['revenue'] } drew the right five values under the labels 1, 2, 3, 4 and 5, and a definition on __ssrm_group__ collapsed to one unlabelled bar at the grand total, even though getChartFields() advertises that field with the label Region. Drawing the five group rows with a separate ApexCharts instance gets both the values and the labels right.

Related

See the server-side row model on its own

The product demo runs grouping, server-computed aggregates, pivotCols and blockSize paging in one page.

Get started