Grouping rows
You can use any field
available in the DataSource
to do the grouping - it can even be a field
that is not a column.
Note
When using TypeScript, both DataSource
and InfiniteTable
components are generic and need to be rendered/instantiated with a DATA_TYPE
parameter. The fields in that DATA_TYPE
can then be used for grouping.
type Person = {
name: string;
age: number;
country: string;
id: string;
}
const groupBy = [{field: 'country'}]
<DataSource<Person> groupBy={groupBy}>
<InfiniteTable<Person> />
</DataSource>
In the example above, we’re grouping by country
, which is a field available in the Person
type. Specifying a field not defined in the Person
type would be a type error.
Additionally, a column
object can be used together with the field
to define how the group column should be rendered.
const groupBy = [
{
field: 'country',
column: {
width: 150,
header: 'Country group',
}
}
]
The example below puts it all together.
Also see the groupBy API reference to find out more.
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropGroupBy, } from '@infinite-table/infinite-react'; import { columns, Employee } from './columns'; const groupBy: DataSourcePropGroupBy<Employee> = [ { field: 'country', column: { header: 'Country group', renderValue: ({ value }) => <>Country: {value}</>, }, }, ]; export default function App() { return ( <DataSource<Employee> data={dataSource} primaryKey="id" groupBy={groupBy}> <InfiniteTable<Employee> columns={columns} /> </DataSource> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/employees1k' ) .then((r) => r.json()) .then((data: Employee[]) => data); };
In groupBy.column
you can use any column property - so, for example, you can define a custom renderValue
function to customize the rendering.
const groupBy = [
{
field: 'country',
column: {
renderValue: ({ value }) => <>Country: {value}</>,
}
}
]
Grouping strategies
When you want to group by multiple fields, InfiniteTable
offers multiple grouping strategies:
- multi column mode - the default.
- single column mode
- inline mode
You can specify the rendering strategy by setting the groupRenderStrategy
property to any of the following: multi-column
, single-column
or inline
.
Multiple groups columns
When grouping by multiple fields, by default the component will render a group column for each group field
const groupBy = [
{
field: 'age',
column: {
width: 100,
renderValue: ({ value }) => <>Age: {value}</>,
}
},
{
field: 'companyName'
},
{
field: 'country'
}
]
Let’s see an example of how the component would render the table with the multi-column strategy.
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropGroupBy, } from '@infinite-table/infinite-react'; import { columns, Employee } from './columns'; const groupBy: DataSourcePropGroupBy<Employee> = [ { field: 'age', column: { renderValue: ({ value }: { value: any }) => `Age: ${value}`, }, }, { field: 'companyName', }, ]; export default function App() { return ( <DataSource<Employee> data={dataSource} primaryKey="id" groupBy={groupBy}> <InfiniteTable<Employee> columns={columns} columnDefaultWidth={150} /> </DataSource> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/employees10k' ) .then((r) => r.json()) .then((data: Employee[]) => data); };
For the multi-column
strategy, you can use hideEmptyGroupColumns
in order to hide columns for groups which are currently not visible.
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropGroupBy, InfiniteTablePropGroupRenderStrategy, GroupRowsState, } from '@infinite-table/infinite-react'; import { columns, Employee } from './employee-columns'; const groupBy: DataSourcePropGroupBy<Employee> = [ { field: 'department', }, { field: 'companyName', }, ]; const domProps = { style: { flex: 1 }, }; const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function App() { const [groupRenderStrategy, setGroupRenderStrategy] = React.useState<InfiniteTablePropGroupRenderStrategy>( 'multi-column' ); const [hideEmptyGroupColumns, setHideEmptyGroupColumns] = React.useState(true); return ( <div style={{ display: 'flex', flex: 1, color: 'var(--infinite-row-color)', flexFlow: 'column', background: 'var(--infinite-background)', }}> <div style={{ padding: 10 }}> <label> <input type="checkbox" checked={hideEmptyGroupColumns} onChange={() => { setHideEmptyGroupColumns( !hideEmptyGroupColumns ); }} /> Hide Empty Group Columns (make sure all `Department` groups are collapsed to see it in action) </label> </div> <DataSource<Employee> data={dataSource} primaryKey="id" defaultGroupRowsState={groupRowsState} groupBy={groupBy}> <InfiniteTable<Employee> domProps={domProps} hideEmptyGroupColumns={hideEmptyGroupColumns} groupRenderStrategy={'multi-column'} columns={columns} columnDefaultWidth={250} /> </DataSource> </div> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/employees10' ) .then((r) => r.json()) .then((data: Employee[]) => data); };
Gotcha
You can specify an id
for group columns. This is helpful if you want to size those columns (via columnSizing
) or pin them (via columnPinning
) or configure them in other ways. If no id
is specified, it will be generated like this: "group-by-${field}"
Single group column
You can group by multiple fields, yet only render a single group column. To choose this rendering strategy, specify groupRenderStrategy
property to be single-column
.
In this case, you can’t override the group column for each group field, as there’s only one group column being generated. However, you can specify a groupColumn
property to customize the generated column.
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropGroupBy, InfiniteTableColumnRenderValueParam, } from '@infinite-table/infinite-react'; import { columns, Employee } from './columns'; const groupBy: DataSourcePropGroupBy<Employee> = [ { field: 'age', }, { field: 'companyName', }, ]; const groupColumn = { header: 'Grouping', width: 250, // in this function we have access to collapsed info // and grouping info about the current row - see rowInfo.groupBy renderValue: ({ value, rowInfo, }: InfiniteTableColumnRenderValueParam<Employee>) => { if (!rowInfo.isGroupRow) { return value } const groupBy = rowInfo.groupBy || []; const collapsed = rowInfo.collapsed; const groupField = groupBy[groupBy.length - 1]; if (groupField === 'age') { return `🥳 ${value}${collapsed ? ' 🤷♂️' : ''}`; } return `🎉 ${value}`; }, }; export default function App() { return ( <DataSource<Employee> data={dataSource} primaryKey="id" groupBy={groupBy}> <InfiniteTable<Employee> groupRenderStrategy="single-column" groupColumn={groupColumn} columns={columns} columnDefaultWidth={150} /> </DataSource> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/employees10k' ) .then((r) => r.json()) .then((data: Employee[]) => data); };
Note
If groupColumn
is specified to an object and no groupRenderStrategy
is passed, the render strategy will be single-column
.
groupColumn
can also be a function, which allows you to individually customize each group column - in case the `multi-column` strategy is used.Gotcha
You can specify an id
for the single group column by using groupColumn
, as detailed above. This is helpful if you want to size this column (via columnSizing
) or pin it (via columnPinning
) or configure it in other ways. If no id
is specified, it will default to "group-by"
.
Inline group column
When inline group rendering is used (groupRenderStrategy=“inline”), the columns bound to the corresponding group by fields are used for rendering, so no group columns are generated. This way of rendering groups is only recommended when you’re sure you have small groups (smaller than the number of rows visible in the viewport).
Aggregations
When grouping, you can also aggregate the values of the grouped rows. This is done via the DataSource.aggregationReducers=true
property. See the example below
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropGroupBy, InfiniteTableColumnRenderValueParam, DataSourcePropAggregationReducers, InfiniteTablePropColumns, DataSourceGroupBy, GroupRowsState, } from '@infinite-table/infinite-react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const avgReducer = { initialValue: 0, reducer: (acc: number, sum: number) => acc + sum, done: (value: number, arr: any[]) => arr.length ? Math.floor(value / arr.length) : 0, }; const aggregationReducers: DataSourcePropAggregationReducers<Developer> = { salary: { field: 'salary', ...avgReducer, }, age: { field: 'age', ...avgReducer, }, }; const columns: InfiniteTablePropColumns<Developer> = { preferredLanguage: { field: 'preferredLanguage' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number', }, canDesign: { field: 'canDesign' }, country: { field: 'country' }, firstName: { field: 'firstName' }, stack: { field: 'stack' }, id: { field: 'id' }, hobby: { field: 'hobby' }, city: { field: 'city' }, currency: { field: 'currency' }, }; const groupColumn = { header: 'Grouping', defaultWidth: 250, // in this function we have access to collapsed info // and grouping info about the current row - see rowInfo.groupBy renderValue: ({ value, rowInfo, }: InfiniteTableColumnRenderValueParam<Developer>) => { if (!rowInfo.isGroupRow) { return value } const groupBy = rowInfo.groupBy || []; const collapsed = rowInfo.collapsed; const groupField = groupBy[groupBy.length - 1]; if (groupField === 'age') { return `🥳 ${value}${collapsed ? ' 🤷♂️' : ''}`; } return `🎉 ${value}`; }, }; const defaultGroupRowsState = new GroupRowsState({ //make all groups collapsed by default collapsedRows: true, expandedRows: [], }); export default function App() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'country', }, { field: 'stack' }, ], [] ); return ( <DataSource<Developer> data={dataSource} primaryKey="id" defaultGroupRowsState={defaultGroupRowsState} aggregationReducers={aggregationReducers} groupBy={groupBy}> <InfiniteTable<Developer> groupRenderStrategy="single-column" groupColumn={groupColumn} columns={columns} columnDefaultWidth={150} /> </DataSource> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/developers10k' ) .then((r) => r.json()) .then((data: Developer[]) => data); };
Each reducer from the aggregationReducers
map can have the following properties:
field
- the field to aggregate ongetter(data)
- a value-getter function, if the aggregation values are are not mapped directly to afield
initialValue
- the initial value to start with when computing the aggregation (for client-side aggregations only)reducer: string | (acc, current)=>value
- the reducer function to use when computing the aggregation (for client-side aggregations only). For server-side aggregations, this will be astring
done(value, arr)
- a function that is called when the aggregation is done (for client-side aggregations only) and returns the final value of the aggregationname
- useful especially in combination withpivotBy
, as it will be used as the pivot column header.
If an aggregation reducer is bound to a field
in the dataset, and there is a column mapped to the same field
, that column will show the corresponding aggregation value for each group row, as shown in the example above.
Gotcha
If you want to prevent the user to expand the last level of group rows, you can override the render
function for the group column
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourcePropAggregationReducers, InfiniteTablePropColumns, DataSourceGroupBy, GroupRowsState, InfiniteTableGroupColumnFunction, InfiniteTableGroupColumnBase, InfiniteTableColumnRenderParam, } from '@infinite-table/infinite-react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const avgReducer = { initialValue: 0, reducer: (acc: number, sum: number) => acc + sum, done: (value: number, arr: any[]) => arr.length ? Math.floor(value / arr.length) : 0, }; const aggregationReducers: DataSourcePropAggregationReducers<Developer> = { salary: { field: 'salary', ...avgReducer, }, age: { field: 'age', ...avgReducer, }, }; const columns: InfiniteTablePropColumns<Developer> = { preferredLanguage: { field: 'preferredLanguage' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number', }, canDesign: { field: 'canDesign' }, country: { field: 'country' }, firstName: { field: 'firstName' }, stack: { field: 'stack' }, id: { field: 'id' }, hobby: { field: 'hobby' }, city: { field: 'city' }, currency: { field: 'currency' }, }; // TODO remove this after the next release //@ts-ignore const groupColumn: InfiniteTableGroupColumnFunction< Developer > = (arg) => { const column = {} as Partial< InfiniteTableGroupColumnBase<Developer> >; if (arg.groupIndexForColumn === arg.groupBy.length - 1) { column.render = ( param: InfiniteTableColumnRenderParam<Developer> ) => { const { value, rowInfo } = param; if ( rowInfo.isGroupRow && rowInfo.groupBy?.length != rowInfo.rootGroupBy?.length ) { // we are on a group row that is the last grouping level return null; } return value; }; } return column; }; const defaultGroupRowsState = new GroupRowsState({ //make all groups collapsed by default collapsedRows: true, expandedRows: [], }); export default function App() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'country', }, { field: 'stack' }, ], [] ); return ( <DataSource<Developer> data={dataSource} primaryKey="id" defaultGroupRowsState={defaultGroupRowsState} aggregationReducers={aggregationReducers} groupBy={groupBy}> <InfiniteTable<Developer> groupRenderStrategy="multi-column" groupColumn={groupColumn} columns={columns} columnDefaultWidth={250} /> </DataSource> ); } const dataSource = () => { return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + '/developers10k' ) .then((r) => r.json()) .then((data: Developer[]) => data); };
Server side grouping with lazy loading
Lazy loading becomes all the more useful when working with grouped data.
The DataSource
data
function is called with an object that has all the information about the current DataSource
state(grouping/pivoting/sorting/lazy-loading, etc) - see the paragraphs above for details.
Server side grouping needs two kinds of data responses in order to work properly:
- response for non-leaf row groups - these are groups that have children. For such groups (including the top-level group), the
DataSource.data
function must return a promise that’s resolved to an object with the following properties:totalCount
- the total number of records in the groupdata
- an array of objects that describes non-leaf child groups, each object has the following properties:keys
- an array of the group keys (usually strings) that uniquely identifies the group, from the root to the current groupdata
- an object that describes the common properties of the groupaggregations
- an object that describes the aggregations for the current group
- response for leaf rows - these are normal rows - rows that would have been served in the non-grouped response. The resolved object should have the following properties:
data
- an array of objects that describes the rowstotalCount
- the total number of records on the server, that are part of the current group
Here’s an example, that assumes grouping by country
and city
and aggregations by age
and salary
(average values):
//request:
groupKeys: [] // empty keys array, so it's a top-level group
groupBy: [{"field":"country"},{"field":"city"}]
reducers: [{"field":"salary","id":"avgSalary","name":"avg"},{"field":"age","id":"avgAge","name":"avg"}]
// lazyLoadStartIndex: 0, - passed if lazyLoad is configured with a batchSize
// lazyLoadBatchSize: 20 - passed if lazyLoad is configured with a batchSize
//response
{
cache: true,
totalCount: 20,
data: [
{
data: {country: "Argentina"},
aggregations: {avgSalary: 20000, avgAge: 30},
keys: ["Argentina"],
},
{
data: {country: "Australia"},
aggregations: {avgSalary: 25000, avgAge: 35},
keys: ["Australia"],
}
//...
]
}
Now let’s expand the first group and see how the request/response would look like:
//request:
groupKeys: ["Argentina"]
groupBy: [{"field":"country"},{"field":"city"}]
reducers: [{"field":"salary","id":"avgSalary","name":"avg"},{"field":"age","id":"avgAge","name":"avg"}]
//response
{
totalCount: 4,
data: [
{
data: {country: "Argentina", city: "Buenos Aires"},
aggregations: {avgSalary: 20000, avgAge: 30},
keys: ["Argentina", "Buenos Aires"],
},
{
data: {country: "Argentina", city: "Cordoba"},
aggregations: {avgSalary: 25000, avgAge: 35},
keys: ["Argentina", "Cordoba"],
},
//...
]
}
Finally, let’s have a look at the leaf/normal rows and a request for them:
//request
groupKeys: ["Argentina","Buenos Aires"]
groupBy: [{"field":"country"},{"field":"city"}]
reducers: [{"field":"salary","id":"avgSalary","name":"avg"},{"field":"age","id":"avgAge","name":"avg"}]
//response
{
totalCount: 20,
data: [
{
id: 34,
country: "Argentina",
city: "Buenos Aires",
age: 30,
salary: 20000,
stack: "full-stack",
firstName: "John",
//...
},
{
id: 35,
country: "Argentina",
city: "Buenos Aires",
age: 35,
salary: 25000,
stack: "backend",
firstName: "Jane",
//...
},
//...
]
}
Note
When a row group is expanded, since InfiniteTable
has the group keys
from the previous response when the node was loaded, it will use the keys
array and pass them to the DataSource.data
function when requesting for the children of the respective group.
You know when to serve last-level rows, because in that case, the length of the groupKeys
array will be equal to the length of the groupBy
array.
import * as React from 'react'; import { InfiniteTable, DataSource, DataSourceData, InfiniteTablePropColumns, GroupRowsState, DataSourceGroupBy, DataSourcePropAggregationReducers, } from '@infinite-table/infinite-react'; type Developer = { id: number; firstName: string; lastName: string; country: string; city: string; currency: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; hobby: string; salary: number; age: number; }; const aggregationReducers: DataSourcePropAggregationReducers<Developer> = { salary: { name: 'Salary (avg)', field: 'salary', reducer: 'avg', }, age: { name: 'Age (avg)', field: 'age', reducer: 'avg', }, }; const columns: InfiniteTablePropColumns<Developer> = { preferredLanguage: { field: 'preferredLanguage' }, age: { field: 'age' }, salary: { field: 'salary', type: 'number', }, canDesign: { field: 'canDesign' }, country: { field: 'country' }, firstName: { field: 'firstName' }, stack: { field: 'stack' }, id: { field: 'id' }, hobby: { field: 'hobby' }, city: { field: 'city' }, currency: { field: 'currency' }, }; const groupRowsState = new GroupRowsState({ expandedRows: [], collapsedRows: true, }); export default function RemotePivotExample() { const groupBy: DataSourceGroupBy<Developer>[] = React.useMemo( () => [ { field: 'country', }, { field: 'city' }, { field: 'stack' }, ], [] ); return ( <DataSource<Developer> primaryKey="id" data={dataSource} groupBy={groupBy} aggregationReducers={aggregationReducers} defaultGroupRowsState={groupRowsState} lazyLoad={true}> <InfiniteTable<Developer> scrollStopDelay={10} hideEmptyGroupColumns columns={columns} columnDefaultWidth={220} /> </DataSource> ); } const dataSource: DataSourceData<Developer> = ({ pivotBy, aggregationReducers, groupBy, lazyLoadStartIndex, lazyLoadBatchSize, groupKeys = [], sortInfo, }) => { if (sortInfo && !Array.isArray(sortInfo)) { sortInfo = [sortInfo]; } const startLimit: string[] = []; if (lazyLoadBatchSize && lazyLoadBatchSize > 0) { const start = lazyLoadStartIndex || 0; startLimit.push(`start=${start}`); startLimit.push(`limit=${lazyLoadBatchSize}`); } const args = [ ...startLimit, pivotBy ? 'pivotBy=' + JSON.stringify( pivotBy.map((p) => ({ field: p.field })) ) : null, `groupKeys=${JSON.stringify(groupKeys)}`, groupBy ? 'groupBy=' + JSON.stringify( groupBy.map((p) => ({ field: p.field })) ) : null, sortInfo ? 'sortInfo=' + JSON.stringify( sortInfo.map((s) => ({ field: s.field, dir: s.dir, })) ) : null, aggregationReducers ? 'reducers=' + JSON.stringify( Object.keys(aggregationReducers).map((key) => ({ field: aggregationReducers[key].field, id: key, name: aggregationReducers[key].reducer, })) ) : null, ] .filter(Boolean) .join('&'); return fetch( 'https://infinite-table.com/.netlify/functions/json-server' + `/developers30k-sql?` + args ).then((r) => r.json()); };
Eager loading for group row nodes
When using lazy-loading together with batching, node data (without children) is loaded when a node (normal or grouped) comes into view. Only when a group node is expanded will its children be loaded. However, you can do this loading eagerly, by using the dataset
property on the node you want to load.
Note
This can be useful in combination with using dataParams.groupRowsState
from the data
function - so your datasource can know which groups are expanded, and thus it can serve those groups already loaded with children.
//request:
groupKeys: [] // empty keys array, so it's a top-level group
groupBy: [{"field":"country"},{"field":"city"}]
reducers: [{"field":"salary","id":"avgSalary","name":"avg"},{"field":"age","id":"avgAge","name":"avg"}]
// lazyLoadStartIndex: 0, - passed if lazyLoad is configured with a batchSize
// lazyLoadBatchSize: 20 - passed if lazyLoad is configured with a batchSize
//response
{
cache: true,
totalCount: 20,
data: [
{
data: {country: "Argentina"},
aggregations: {avgSalary: 20000, avgAge: 30},
keys: ["Argentina"],
// NOTE this dataset property used for eager-loading of group nodes
dataset: {
// the shape of the dataset is the same as the one normally returned by the datasource
cache: true,
totalCount: 4,
data: [
{
data: {country: "Argentina", city: "Buenos Aires"},
aggregations: {avgSalary: 20000, avgAge: 30},
keys: ["Argentina", "Buenos Aires"],
},
{
data: {country: "Argentina", city: "Cordoba"},
aggregations: {avgSalary: 25000, avgAge: 35},
keys: ["Argentina", "Cordoba"],
},
]
}
},
{
data: {country: "Australia"},
aggregations: {avgSalary: 25000, avgAge: 35},
keys: ["Australia"],
}
//...
]
}