Master Detail with Custom Row Detail Contents
The Infinite Table React DataGrid allows you to render any valid JSX nodes as row details.
You can render a DataGrid directly or you can nest the DataGrid at any level of nesting inside the row details.
Or you can simply choose to render anything else - no DataGrid required.
Rendering a detail DataGrid#
Your row detail content can include another Infinite Table DataGrid.
The DataGrid you're rendering inside the row detail doesn't need to be the return value of the
rowDetailRenderer
function - it can be nested inside other valid JSX nodes you return from the function.In this example, the row detail contains custom content, along with another Infinite Table DataGrid. You can nest a child DataGrid inside the row details at any level of nesting.
View Mode
Fork Forkimport * as React from 'react'; import { DataSourceData, InfiniteTable, InfiniteTablePropColumns, DataSource, InfiniteTableRowInfo, } from '@infinite-table/infinite-react'; type Developer = { id: number; firstName: string; lastName: string; city: string; currency: string; country: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; salary: number; }; type City = { id: number; name: string; country: string; }; const masterColumns: InfiniteTablePropColumns<City> = { id: { field: 'id', header: 'ID', defaultWidth: 70, renderRowDetailIcon: true, }, country: { field: 'country', header: 'Country' }, city: { field: 'name', header: 'City', defaultFlex: 1 }, }; const detailColumns: InfiniteTablePropColumns<Developer> = { firstName: { field: 'firstName', header: 'First Name', }, salary: { field: 'salary', type: 'number', }, stack: { field: 'stack' }, currency: { field: 'currency' }, city: { field: 'city' }, }; const domProps = { style: { height: '100%', }, }; const shouldReloadData = { sortInfo: true, filterValue: true, }; function renderDetail(rowInfo: InfiniteTableRowInfo<City>) { return ( <div style={{ padding: 10, color: 'var(--infinite-cell-color)', background: 'var(--infinite-background)', height: '100%', display: 'flex', flexDirection: 'column', }} > <h3> Developers in {rowInfo.data?.name}, {rowInfo.data?.country} </h3> <DataSource<Developer> data={detailDataSource} primaryKey="id" shouldReloadData={shouldReloadData} > <InfiniteTable<Developer> columnDefaultWidth={150} columnMinWidth={50} columns={detailColumns} /> </DataSource> </div> ); } const defaultRowDetailState = { collapsedRows: true as const, expandedRows: [39, 54], }; export default () => { return ( <> <DataSource<City> data={citiesDataSource} primaryKey="id" defaultSortInfo={[ { field: 'country', dir: 1, }, { field: 'name', dir: 1, }, ]} > <InfiniteTable<City> domProps={domProps} columnDefaultWidth={150} defaultRowDetailState={defaultRowDetailState} columnMinWidth={50} columns={masterColumns} rowDetailHeight={320} rowDetailRenderer={renderDetail} /> </DataSource> </> ); }; // fetch an array of cities from the server const citiesDataSource: DataSourceData<City> = () => { const cityNames = new Set<string>(); const result: City[] = []; return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`) .then((response) => response.json()) .then((response) => { response.data.forEach((data: Developer) => { if (cityNames.has(data.city)) { return; } cityNames.add(data.city); result.push({ name: data.city, country: data.country, id: result.length, }); }); return result; }); }; const detailDataSource: DataSourceData<Developer> = ({ filterValue, sortInfo, masterRowInfo, }) => { if (sortInfo && !Array.isArray(sortInfo)) { sortInfo = [sortInfo]; } if (!filterValue) { filterValue = []; } if (masterRowInfo) { // filter by master country and city filterValue = [ { field: 'city', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.name, }, }, { field: 'country', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.country, }, }, ...filterValue, ]; } const args = [ sortInfo ? 'sortInfo=' + JSON.stringify( sortInfo.map((s) => ({ field: s.field, dir: s.dir, })), ) : null, filterValue ? 'filterBy=' + JSON.stringify( filterValue.map(({ field, filter }) => { return { field: field, operator: filter.operator, value: filter.type === 'number' ? Number(filter.value) : filter.value, }
You'll probably want to configure the height of the row detail content. Use the
rowDetailHeight
prop to do that.Rendering a chart component as row detail#
View Mode
Fork Forkimport * as React from 'react'; import { DataSourceData, InfiniteTable, InfiniteTablePropColumns, DataSource, InfiniteTableRowInfo, InfiniteTable_HasGrouping_RowInfoGroup, DataSourcePropGroupBy, DataSourcePropAggregationReducers, } from '@infinite-table/infinite-react'; import { AgCharts as AgChartsReact } from 'ag-charts-react'; type Developer = { id: number; firstName: string; lastName: string; city: string; currency: string; country: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; salary: number; }; type City = { id: number; name: string; country: string; }; const masterColumns: InfiniteTablePropColumns<City> = { id: { field: 'id', header: 'ID', defaultWidth: 70, renderRowDetailIcon: true, }, country: { field: 'country', header: 'Country' }, city: { field: 'name', header: 'City', defaultFlex: 1 }, }; const domProps = { style: { height: '100%', }, }; function renderDetail(rowInfo: InfiniteTableRowInfo<City>) { const [groupBy] = React.useState<DataSourcePropGroupBy<Developer>>([ { field: 'stack' }, ]); const [aggregationReducers] = React.useState< DataSourcePropAggregationReducers<Developer> >({ salary: { field: 'salary', initialValue: 0, reducer: (acc, value) => acc + value, done: (value, arr) => Math.round(arr.length ? value / arr.length : 0), }, }); return ( <div style={{ padding: 10, color: 'var(--infinite-cell-color)', background: 'var(--infinite-background)', height: '100%', display: 'flex', flexDirection: 'column', }} > {/** * In this example, we leverage the DataSource aggregation and grouping feature to * calculate the average salary by stack for the selected city. */} <DataSource<Developer> data={detailDataSource} primaryKey="id" groupBy={groupBy} aggregationReducers={aggregationReducers} > {/** * Notice here we're not rendering an InfiniteTable component * but rather we use a render function to access the aggregated data. */} {(params) => { const { dataArray: rowInfoArray } = params; const groups = rowInfoArray.filter( (rowInfo) => rowInfo.isGroupRow, ) as InfiniteTable_HasGrouping_RowInfoGroup<Developer>[]; const groupData = groups.map((group) => { return { stack: group.data?.stack, avgSalary: group.reducerData?.salary, }; }); return ( <AgChartsReact options={{ title: { text: `Avg salary by stack in ${rowInfo.data?.name}, ${rowInfo.data?.country}`, }, data: groupData, series: [ { type: 'bar', xKey: 'stack', yKey: 'avgSalary', yName: 'Average Salary', }, ], }} /> ); }} </DataSource> </div> ); } const defaultRowDetailState = { collapsedRows: true as const, expandedRows: [39, 54], }; export default () => { return ( <> <DataSource<City> data={citiesDataSource} primaryKey="id" defaultSortInfo={[ { field: 'country', dir: 1, }, { field: 'name', dir: 1, }, ]} > <InfiniteTable<City> domProps={domProps} columnDefaultWidth={150} defaultRowDetailState={defaultRowDetailState} columnMinWidth={50} columns={masterColumns} rowDetailHeight={320} rowDetailRenderer={renderDetail} /> </DataSource> </> ); }; // fetch an array of cities from the server const citiesDataSource: DataSourceData<City> = () => { const cityNames = new Set<string>(); const result: City[] = []; return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`) .then((response) => response.json()) .then((response) => { response.data.forEach((data: Developer) => { if (cityNames.has(data.city)) { return; } cityNames.add(data.city); result.push({ name: data.city, country: data.country, id: result.length, }); }); return result; }); }; const detailDataSource: DataSourceData<Developer> = ({ filterValue, sortInfo, masterRowInfo, }) => { if (sortInfo && !Array.isArray(sortInfo)) { sortInfo = [sortInfo]; } if (!filterValue) { filterValue = []; } if (masterRowInfo) { // filter by master country and city filterValue = [ { field: 'city', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.name, }, }, { field: 'country', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.country, }, }, ...filterValue, ]; } const args = [ sortInfo ? 'sortInfo=' + JSON.stringify( sortInfo.map((s) => ({ field: s.field, dir: s.dir, })), ) : null, filterValue ? 'filterBy=' + JSON.stringify( filterValue.map(({ field, filter }) => { return { field: field, operator: filter.operator, value: filter.type === 'number' ? Number(filter.value) : filter.value, }
In the above example, please note that on every render (after the detail component is mounted), we pass the same
dataSource
, groupBy
and aggregationReducers
props to the <DataSource />
component. The references for all those objects are stable. We don't want to pass new references on every render, as that would cause the <DataSource />
to reload and reprocess the data.Multiple levels of nesting#
The master-detail configuration for the DataGrid can contain any level of nesting.
The example below shows 3 levels of nesting - so a master DataGrid, a detail DataGrid and another third-level detail with custom content.
In this example, we have 3 levels of nesting:
- The master DataGrid shows cities/countries
- The first level of detail shows developers in each city
- The second level of detail shows custom data about each developer
View Mode
Fork Forkimport * as React from 'react'; import { DataSourceData, InfiniteTable, InfiniteTablePropColumns, DataSource, InfiniteTableRowInfo, } from '@infinite-table/infinite-react'; type Developer = { id: number; firstName: string; lastName: string; city: string; currency: string; country: string; preferredLanguage: string; stack: string; canDesign: 'yes' | 'no'; salary: number; }; type City = { id: number; name: string; country: string; }; const masterColumns: InfiniteTablePropColumns<City> = { id: { field: 'id', header: 'ID', defaultWidth: 70, renderRowDetailIcon: true, }, country: { field: 'country', header: 'Country' }, city: { field: 'name', header: 'City', defaultFlex: 1 }, }; const detailColumns: InfiniteTablePropColumns<Developer> = { firstName: { field: 'firstName', header: 'First Name', renderRowDetailIcon: true, }, salary: { field: 'salary', type: 'number', }, stack: { field: 'stack' }, currency: { field: 'currency' }, city: { field: 'city' }, }; const domProps = { style: { height: '100%', }, }; const detailStyle: React.CSSProperties = { padding: 10, color: 'var(--infinite-cell-color)', background: 'var(--infinite-background)', height: '100%', display: 'flex', flexDirection: 'column', }; function renderLastDetail(rowInfo: InfiniteTableRowInfo<Developer>) { const { data } = rowInfo; if (!data) { return <div>No data ...</div>; } return ( <div style={detailStyle}> <h3> Developer: {data.firstName} {data.lastName} </h3> <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 10, }} > <div>Preferred Language</div> <div>{data.preferredLanguage}</div> <div>Salary</div> <div>{data.salary}</div> <div>Currency</div> <div>{data.currency}</div> <div>Can Design</div> <div>{data.canDesign}</div> </div> </div> ); } const shouldReloadData = { sortInfo: true, filterValue: true, }; function renderDetail(rowInfo: InfiniteTableRowInfo<City>) { return ( <div style={detailStyle}> <h3> Developers in {rowInfo.data?.name}, {rowInfo.data?.country} </h3> <DataSource<Developer> data={detailDataSource} primaryKey="id" shouldReloadData={shouldReloadData} > <InfiniteTable<Developer> columnDefaultWidth={150} columnMinWidth={50} columns={detailColumns} rowDetailHeight={200} rowDetailRenderer={renderLastDetail} /> </DataSource> </div> ); } const defaultRowDetailState = { collapsedRows: true as const, expandedRows: [39, 54], }; export default () => { return ( <> <DataSource<City> data={citiesDataSource} primaryKey="id" defaultSortInfo={[ { field: 'country', dir: 1, }, { field: 'name', dir: 1, }, ]} > <InfiniteTable<City> domProps={domProps} columnDefaultWidth={150} defaultRowDetailState={defaultRowDetailState} columnMinWidth={50} columns={masterColumns} rowDetailHeight={350} rowDetailRenderer={renderDetail} /> </DataSource> </> ); }; // fetch an array of cities from the server const citiesDataSource: DataSourceData<City> = () => { const cityNames = new Set<string>(); const result: City[] = []; return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`) .then((response) => response.json()) .then((response) => { response.data.forEach((data: Developer) => { if (cityNames.has(data.city)) { return; } cityNames.add(data.city); result.push({ name: data.city, country: data.country, id: result.length, }); }); return result; }); }; const detailDataSource: DataSourceData<Developer> = ({ filterValue, sortInfo, masterRowInfo, }) => { if (sortInfo && !Array.isArray(sortInfo)) { sortInfo = [sortInfo]; } if (!filterValue) { filterValue = []; } if (masterRowInfo) { // filter by master country and city filterValue = [ { field: 'city', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.name, }, }, { field: 'country', filter: { operator: 'eq', type: 'string', value: masterRowInfo.data.country, }, }, ...filterValue, ]; } const args = [ sortInfo ? 'sortInfo=' + JSON.stringify( sortInfo.map((s) => ({ field: s.field, dir: s.dir, })), ) : null, filterValue ? 'filterBy=' + JSON.stringify( filterValue.map(({ field, filter }) => { return { field: field, operator: filter.operator, value: filter.type === 'number' ? Number(filter.value) : filter.value, }
Understanding the lifecycle of the row detail component#
You have to keep in mind that the content you render in the row detail can be mounted and unmounted multiple times. Whenever the user expands the row detail, it gets mounted and rendered, but then it will be unmounted when the user scrolls the content out of view. This can happen very often.
Also note that the content can be recycled - meaning the same component can be reused for different rows. If you don't want recycling to happen, make sure you use a unique key for the row detail content - you can use the
masterRowInfo.id
for that.In practice this means that it's best if your row detail content is using controlled state and avoids using local state.