Master Detail

The React DataGrid that Infinite Table offers has native support for master-detail rows.

Note

The single most important property for the master-detail DataGrid configuration is the rowDetailRenderer function prop - which makes the DataGrid be considered master-detail.

In addition, make sure you have a column with the renderRowDetailIcon: true flag set. columns.renderRowDetailIcon on a column makes the column display the row detail expand icon.

The row detail in the DataGrid can contain another DataGrid or any other custom content.

Note

It's very imporant that the rowDetailRenderer function prop you pass into <InfiniteTable /> is stable and doesn't change on every render. So make sure you pass a reference to the same function every time - except of course if you want the row detail to change based on some other state.

Basic master detail DataGrid example

This example shows a master DataGrid with cities & countries.

The details for each city shows a DataGrid with developers in that city.

The detail DataGrid is configured with remote sorting.

View Mode
Fork
import * 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%',
  },
};

function renderDetail(rowInfo: InfiniteTableRowInfo<City>) {
  console.log('rendering detail for master row', rowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      sortMode="remote"
      filterMode="remote"
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

export default () => {
  return (
    <>
      <DataSource<City>
        data={citiesDataSource}
        primaryKey="id"
        defaultSortInfo={[
          {
            field: 'country',
            dir: 1,
          },
          {
            field: 'name',
            dir: 1,
          },
        ]}
      >
        <InfiniteTable<City>
          domProps={domProps}
          columnDefaultWidth={150}
          columnMinWidth={50}
          columns={masterColumns}
          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,
            }

If you want to use a component instead of the rowDetailRenderer function, you can use the components.RowDetail property. This works similarly and makes the DataGrid be considered master-detail. Inside the component, you can use the useMasterRowInfo hook to get the master row information.

Loading the Detail DataSource

When master-detail is configured and the row detail renders a DataGrid, the data function for the detail <DataSource /> will be called with the masterRowInfo as a property available in the object passed as argument.

Loading the detail DataGrid data
const detailDataFn: DataSourceData<Developer> = ({
  masterRowInfo,
  sortInfo,
  ...
}) => {

  return Promise.resolve([...])
}

<DataSource<Developer> data={detailDataFn}>
  {...}
</DataSource>

You can see the live example above for more details.

Rendering a detail DataGrid

Using the rowDetailRenderer prop, you can render any custom content for the row details.

The content doesn't need to include Infinite Table.

You can, however, render an Infinite Table React DataGrid, at any level of nesting inside the row detail content.

Master detail with custom content & DataGrid

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

Configuring the master-detail height

In order to configure the height of the row details, you can use the rowDetailHeight prop.

Configuring the row detail height
<InfiniteTable<City>
  columns={masterColumns}
  rowDetailHeight={500}
  rowDetailRenderer={renderDetail}

The default value for the rowDetailHeight is 300 px.

rowDetailHeight can be one of the following:

  • number - the height in pixels
  • string - the name of a CSS variable that configures the height - eg: --master-detail-height
  • (rowInfo) => number - a function that can return a different height for each row. The sole argument is the rowInfo object.
Master detail DataGrid with custom height for row details

This master-detail DataGrid is configured with a custom rowDetailHeight of 200px.

View Mode
Fork
import * 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%',
  },
};

function renderDetail(rowInfo: InfiniteTableRowInfo<City>) {
  console.log('rendering detail for master row', rowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      sortMode="remote"
      filterMode="remote"
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

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={200}
          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,
            }

Conditional row details

Not all rows in a DataGrid need to have details. To configure which rows have details, you can use the isRowDetailEnabled function prop.

Using conditional row details
<InfiniteTable<City>
  columns={masterColumns}
  rowDetailHeight={500}
  rowDetailRenderer={renderDetail}
  isRowDetailEnabled={(rowInfo) => rowInfo.data.cityName.contains('i')}

The isRowDetailEnabled function prop is called with the rowInfo object and is expected to return a boolean value.

Master detail DataGrid with conditional details

This example shows a master DataGrid with cities & countries.

Not all rows have details - every other row is configured without details via the isRowDetailEnabled function prop.

View Mode
Fork
import * 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%',
  },
};

function renderDetail(rowInfo: InfiniteTableRowInfo<City>) {
  console.log('rendering detail for master row', rowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      sortMode="remote"
      filterMode="remote"
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

const isRowDetailEnabled = (rowInfo: InfiniteTableRowInfo<City>) => {
  return rowInfo.indexInAll % 2 === 0;
};

export default () => {
  return (
    <>
      <DataSource<City>
        data={citiesDataSource}
        primaryKey="id"
        defaultSortInfo={[
          {
            field: 'country',
            dir: 1,
          },
          {
            field: 'name',
            dir: 1,
          },
        ]}
      >
        <InfiniteTable<City>
          domProps={domProps}
          columnDefaultWidth={150}
          columnMinWidth={50}
          columns={masterColumns}
          isRowDetailEnabled={isRowDetailEnabled}
          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,
            }