When a Master Row is expanded, the grid uses the default Detail Cell Renderer to create and display the Detail Grid inside one row of the Master Grid. You can provide a custom Detail Cell Renderer to display something else if the default Detail Cell Renderer doesn't do what you want.
Configure the grid to use a custom Detail Cell Renderer using the grid property detailCellRenderer.
// normally left blank, the grid will use the default Detail Cell Renderer
const detailCellRenderer = 'myCellRendererComp';
// params sent to the Detail Cell Renderer, in this case your MyCellRendererComp
const detailCellRendererParams = useMemo(() => {
return {};
}, []);
<AgGridReact
detailCellRenderer={detailCellRenderer}
detailCellRendererParams={detailCellRendererParams}
/>The Detail Cell Renderer should be a Cell Renderer component. See Cell Renderer on how to build and register a Cell Renderer with the grid.
The following examples demonstrate minimalist custom Detail Cell Renderer. Note that where a Detail Grid would normally appear, only the message "My Custom Detail" is shown.
"use client";
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgGridReact, AgGridProvider } from "ag-grid-react";
import {
ClientSideRowModelModule,
ColDef,
ColGroupDef,
FirstDataRenderedEvent,
GridApi,
GridOptions,
ModuleRegistry,
RowApiModule,
enableDevValidations,
} from "ag-grid-community";
import {
ColumnMenuModule,
ColumnsToolPanelModule,
ContextMenuModule,
MasterDetailModule,
} from "ag-grid-enterprise";
import DetailCellRenderer from "./detailCellRenderer.tsx";
import { IAccount } from "./interfaces";
import { useFetchJson } from "./useFetchJson";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableDevValidations();
}
const modules = [
RowApiModule,
ClientSideRowModelModule,
ColumnsToolPanelModule,
MasterDetailModule,
ColumnMenuModule,
ContextMenuModule,
];
const GridExample = () => {
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const gridStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const detailCellRenderer = useCallback(DetailCellRenderer, []);
const [columnDefs, setColumnDefs] = useState<ColDef[]>([
// group cell renderer needed for expand / collapse icons
{ field: "name", cellRenderer: "agGroupCellRenderer" },
{ field: "account" },
{ field: "calls" },
{ field: "minutes", valueFormatter: "x.toLocaleString() + 'm'" },
]);
const defaultColDef = useMemo<ColDef>(() => {
return {
flex: 1,
};
}, []);
const { data, loading } = useFetchJson<IAccount>(
"https://www.ag-grid.com/example-assets/master-detail-data.json",
);
const onFirstDataRendered = useCallback((params: FirstDataRenderedEvent) => {
params.api.forEachNode(function (node) {
node.setExpanded(node.id === "1");
});
}, []);
return (
<AgGridProvider modules={modules}>
<div style={containerStyle}>
<div style={gridStyle}>
<AgGridReact<IAccount>
rowData={data}
loading={loading}
masterDetail={true}
detailCellRenderer={detailCellRenderer}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
onFirstDataRendered={onFirstDataRendered}
/>
</div>
</div>
</AgGridProvider>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<GridExample />
</StrictMode>,
);
import React from 'react';
const DetailCellRenderer = () => (
<div role="gridcell">
<h1 style={{ padding: '20px' }}>My Custom Detail</h1>
</div>
);
export default DetailCellRenderer;
export interface ICallRecord {
name: string;
callId: number;
duration: number;
switchCode: string;
direction: string;
number: string;
}
export interface IAccount {
name: string;
account: number;
calls: number;
minutes: number;
callRecords: ICallRecord[];
} import { useState, useEffect } from 'react';
/**
* Fetch example Json data
* Not recommended for production use!
*/
export const useFetchJson = <T,>(url:string, limit?: number) => {
const [data, setData] = useState<T[]>();
const [loading, setLoading] = useState(true);
useEffect(() => {
// StrictMode runs this effect twice: drop the superseded run's response rather than applying both.
let cancelled = false;
const fetchData = async () => {
setLoading(true);
// Note error handling is omitted here for brevity
const response = await fetch(url);
const json = await response.json();
const data = limit ? json.slice(0, limit) : json;
if (cancelled) {
return;
}
setData(data);
setLoading(false);
};
fetchData();
return () => {
cancelled = true;
};
}, [url, limit]);
return { data, loading };
}; Custom Detail With Form Copy Link
It is not mandatory to display a grid inside the detail section. As you are providing a custom component, there are no restrictions as to what can appear inside the custom component.
This example shows a custom Detail Cell Renderer that uses a form rather than a grid.
"use client";
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgGridReact, AgGridProvider } from "ag-grid-react";
import "./styles.css";
import {
ClientSideRowModelModule,
ColDef,
ColGroupDef,
FirstDataRenderedEvent,
GridApi,
GridOptions,
ModuleRegistry,
RowApiModule,
enableDevValidations,
} from "ag-grid-community";
import {
ColumnMenuModule,
ColumnsToolPanelModule,
ContextMenuModule,
MasterDetailModule,
} from "ag-grid-enterprise";
import DetailCellRenderer from "./detailCellRenderer.tsx";
import { IAccount } from "./interfaces";
import { useFetchJson } from "./useFetchJson";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableDevValidations();
}
const modules = [
RowApiModule,
ClientSideRowModelModule,
ColumnsToolPanelModule,
MasterDetailModule,
ColumnMenuModule,
ContextMenuModule,
];
const GridExample = () => {
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const gridStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [columnDefs, setColumnDefs] = useState<ColDef[]>([
// group cell renderer needed for expand / collapse icons
{ field: "name", cellRenderer: "agGroupCellRenderer" },
{ field: "account" },
{ field: "calls" },
{ field: "minutes", valueFormatter: "x.toLocaleString() + 'm'" },
]);
const defaultColDef = useMemo<ColDef>(() => {
return {
flex: 1,
};
}, []);
const detailCellRenderer = useCallback(DetailCellRenderer, []);
const { data, loading } = useFetchJson<IAccount>(
"https://www.ag-grid.com/example-assets/master-detail-data.json",
);
const onFirstDataRendered = useCallback((params: FirstDataRenderedEvent) => {
params.api.forEachNode(function (node) {
node.setExpanded(node.id === "1");
});
}, []);
return (
<AgGridProvider modules={modules}>
<div style={containerStyle}>
<div style={gridStyle}>
<AgGridReact<IAccount>
rowData={data}
loading={loading}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
masterDetail={true}
detailCellRenderer={detailCellRenderer}
detailRowHeight={80}
groupDefaultExpanded={1}
onFirstDataRendered={onFirstDataRendered}
/>
</div>
</div>
</AgGridProvider>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<GridExample />
</StrictMode>,
);
p {
font-size: 1em;
margin: 0;
}
.cell-renderer-outer {
height: 80px;
}
.cell-renderer-outer form {
height: 100%;
}
.container {
max-width: 960px;
height: 100%;
margin: 10% auto;
padding: 2.5em;
}
form > div {
height: 100%;
display: flex;
background-color: #99999944;
}
form > div > div {
min-width: 33.3%;
}
label {
display: block;
margin: 0.75em 25%;
font-weight: bold;
}
import React, { useState } from 'react';
import type { CustomCellRendererProps } from 'ag-grid-react';
const DetailCellRenderer = ({ data }: CustomCellRendererProps) => {
const firstRecord = data.callRecords[0];
const [callId, setCallId] = useState(firstRecord.callId);
const [number, setNumber] = useState(firstRecord.number);
const [direction, setDirection] = useState(firstRecord.direction);
return (
<div role="gridcell" className="cell-renderer-outer">
<form>
<div>
<div>
<label>
Call Id:
<br />
<input type="text" value={callId} onChange={(e) => setCallId(e.target.value)} />
</label>
</div>
<div>
<label>
Number:
<br />
<input type="text" value={number} onChange={(e) => setNumber(e.target.value)} />
</label>
</div>
<div>
<label>
Direction:
<br />
<input type="text" value={direction} onChange={(e) => setDirection(e.target.value)} />
</label>
</div>
</div>
</form>
</div>
);
};
export default DetailCellRenderer;
export interface ICallRecord {
name: string;
callId: number;
duration: number;
switchCode: string;
direction: string;
number: string;
}
export interface IAccount {
name: string;
account: number;
calls: number;
minutes: number;
callRecords: ICallRecord[];
} import { useState, useEffect } from 'react';
/**
* Fetch example Json data
* Not recommended for production use!
*/
export const useFetchJson = <T,>(url:string, limit?: number) => {
const [data, setData] = useState<T[]>();
const [loading, setLoading] = useState(true);
useEffect(() => {
// StrictMode runs this effect twice: drop the superseded run's response rather than applying both.
let cancelled = false;
const fetchData = async () => {
setLoading(true);
// Note error handling is omitted here for brevity
const response = await fetch(url);
const json = await response.json();
const data = limit ? json.slice(0, limit) : json;
if (cancelled) {
return;
}
setData(data);
setLoading(false);
};
fetchData();
return () => {
cancelled = true;
};
}, [url, limit]);
return { data, loading };
}; Custom Detail With Grid Copy Link
It is possible to provide a Custom Detail Grid that does a similar job to the default Detail Cell Renderer. This example demonstrates displaying a custom grid as the detail. Details are logged to the developer console.
("use client");
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgGridReact, AgGridProvider } from "ag-grid-react";
import "./styles.css";
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
ColDef,
ColGroupDef,
FirstDataRenderedEvent,
GridApi,
GridOptions,
ModuleRegistry,
RowApiModule,
enableDevValidations,
} from "ag-grid-community";
import {
ColumnMenuModule,
ColumnsToolPanelModule,
ContextMenuModule,
MasterDetailModule,
} from "ag-grid-enterprise";
import DetailCellRenderer from "./detailCellRenderer.tsx";
import { IAccount } from "./interfaces";
import { useFetchJson } from "./useFetchJson";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableDevValidations();
}
const modules = [
ClientSideRowModelApiModule,
RowApiModule,
ClientSideRowModelModule,
ColumnsToolPanelModule,
MasterDetailModule,
ColumnMenuModule,
ContextMenuModule,
];
declare let window: any;
const GridExample = () => {
const gridRef = useRef<AgGridReact<IAccount>>(null);
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const gridStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [columnDefs, setColumnDefs] = useState<ColDef[]>([
// group cell renderer needed for expand / collapse icons
{ field: "name", cellRenderer: "agGroupCellRenderer" },
{ field: "account" },
{ field: "calls" },
{ field: "minutes", valueFormatter: "x.toLocaleString() + 'm'" },
]);
const defaultColDef = useMemo<ColDef>(() => {
return {
flex: 1,
};
}, []);
const detailCellRenderer = useCallback(DetailCellRenderer, []);
const { data, loading } = useFetchJson<IAccount>(
"https://www.ag-grid.com/example-assets/master-detail-data.json",
);
const onFirstDataRendered = useCallback((params: FirstDataRenderedEvent) => {
// arbitrarily expand a row for presentational purposes
setTimeout(() => {
params.api.getDisplayedRowAtIndex(1)!.setExpanded(true);
}, 0);
}, []);
const expandCollapseAll = useCallback(() => {
gridRef.current!.api.forEachNode(function (node) {
node.expanded = !!window.collapsed;
});
window.collapsed = !window.collapsed;
gridRef.current!.api.onGroupExpandedOrCollapsed();
}, [window]);
const printDetailGridInfo = useCallback(() => {
console.log("Currently registered detail grid's: ");
gridRef.current!.api.forEachDetailGridInfo(function (detailGridInfo) {
console.log(detailGridInfo);
});
}, []);
return (
<AgGridProvider modules={modules}>
<div style={containerStyle}>
<div className="example-wrapper">
<div style={{ marginBottom: "5px" }}>
<button onClick={printDetailGridInfo}>
Print Detail Grid Info
</button>
<button onClick={expandCollapseAll}>
Toggle Expand / Collapse
</button>
</div>
<div style={gridStyle}>
<AgGridReact<IAccount>
ref={gridRef}
rowData={data}
loading={loading}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
masterDetail={true}
detailRowHeight={310}
detailCellRenderer={detailCellRenderer}
onFirstDataRendered={onFirstDataRendered}
/>
</div>
</div>
</div>
</AgGridProvider>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<GridExample />
</StrictMode>,
);
.example-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
#myGrid {
flex: 1 1 0px;
width: 100%;
}
.full-width-panel {
position: relative;
height: 100%;
width: 100%;
padding: 5px;
}
.call-record-cell {
text-align: right;
}
.full-width-detail {
padding-top: 4px;
}
.full-width-details {
float: left;
padding: 5px;
margin: 5px;
width: 150px;
}
.full-width-grid {
margin-left: 125px;
padding: 25px;
display: block;
height: calc(100% - 50px);
}
.full-width-grid-toolbar {
top: 4px;
left: 30px;
margin-left: 150px;
display: block;
position: absolute;
}
.full-width-phone-icon {
padding-right: 10px;
}
.full-width-search {
margin-left: 10px;
}
import React, { useEffect } from 'react';
import type { ColDef, DetailGridInfo, GridReadyEvent } from 'ag-grid-community';
import type { CustomCellRendererProps } from 'ag-grid-react';
import { AgGridReact } from 'ag-grid-react';
const DetailCellRenderer = ({ data, node, api }: CustomCellRendererProps) => {
const rowId = node.id!;
useEffect(() => {
return () => {
if (!api.isDestroyed()) {
console.log('removing detail grid info with id: ', rowId);
api.removeDetailGridInfo(rowId);
}
};
}, []);
const colDefs = [
{ field: 'callId' },
{ field: 'direction' },
{ field: 'number' },
{ field: 'duration', valueFormatter: "x.toLocaleString() + 's'" },
{ field: 'switchCode' },
];
const defaultColDef: ColDef = {
flex: 1,
minWidth: 120,
};
const onGridReady = (params: GridReadyEvent) => {
const gridInfo: DetailGridInfo = {
id: rowId,
api: params.api,
};
console.log('adding detail grid info with id: ', rowId);
api.addDetailGridInfo(rowId, gridInfo);
};
return (
<div role="gridcell" className="full-width-panel">
<div className="full-width-details">
<div className="full-width-detail">
<b>Name: </b>
{data.name}
</div>
<div className="full-width-detail">
<b>Account: </b>
{data.account}
</div>
</div>
<div className="full-width-grid">
<AgGridReact
columnDefs={colDefs}
defaultColDef={defaultColDef}
rowData={data.callRecords}
onGridReady={onGridReady}
/>
</div>
</div>
);
};
export default DetailCellRenderer;
export interface ICallRecord {
name: string;
callId: number;
duration: number;
switchCode: string;
direction: string;
number: string;
}
export interface IAccount {
name: string;
account: number;
calls: number;
minutes: number;
callRecords: ICallRecord[];
} import { useState, useEffect } from 'react';
/**
* Fetch example Json data
* Not recommended for production use!
*/
export const useFetchJson = <T,>(url:string, limit?: number) => {
const [data, setData] = useState<T[]>();
const [loading, setLoading] = useState(true);
useEffect(() => {
// StrictMode runs this effect twice: drop the superseded run's response rather than applying both.
let cancelled = false;
const fetchData = async () => {
setLoading(true);
// Note error handling is omitted here for brevity
const response = await fetch(url);
const json = await response.json();
const data = limit ? json.slice(0, limit) : json;
if (cancelled) {
return;
}
setData(data);
setLoading(false);
};
fetchData();
return () => {
cancelled = true;
};
}, [url, limit]);
return { data, loading };
}; Register Detail Grid Copy Link
In order for the Detail Grid's API to be available via the Master Grid as explained in Accessing Detail Grids, a Grid Info object needs to be registered with the Master Grid.
Register a detail grid with the master grid when it is created. |
Unregister a detail grid from the master grid when it is destroyed. |
When the Detail Grid is created, register it via masterGridApi.addDetailGridInfo(id, info) and when the Detail Grid is destroyed, unregister it via masterGridApi.removeDetailGridInfo(id). A Detail ID is required when calling these methods. Any unique ID can be used, however for consistency with how the default Detail Cell Renderer works it's recommended to use the ID of the detail Row Node.
//////////////////////////////
// Register with Master Grid
const detailId = params.node.id;
// Create Grid Info object
const detailGridInfo = {
id: detailId,
api: params.api,
};
this.masterGridApi.addDetailGridInfo(detailId, detailGridInfo);
//////////////////////////////
// Unregister with Master Grid
this.masterGridApi.removeDetailGridInfo(detailId); Custom Detail Height Copy Link
When using a custom Detail Cell Renderer the height of the detail section can be customised as explained in Detail Height.
Refreshing Copy Link
When data is updated in the grid using Transaction Updates, Detail Cell Renderers will be refreshed with updated props. This is demonstrated in the below example.
'use client';
import React, { StrictMode, useCallback, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import type {
ColDef,
FirstDataRenderedEvent,
GridReadyEvent,
} from "ag-grid-community";
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
HighlightChangesModule,
enableDevValidations,
} from "ag-grid-community";
import {
ColumnMenuModule,
ColumnsToolPanelModule,
ContextMenuModule,
MasterDetailModule,
} from "ag-grid-enterprise";
import { AgGridProvider, AgGridReact } from "ag-grid-react";
import DetailCellRenderer from "./detailCellRenderer";
import type { IAccount } from "./interfaces";
import "./styles.css";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableDevValidations();
}
const modules = [
ClientSideRowModelApiModule,
HighlightChangesModule,
ClientSideRowModelModule,
MasterDetailModule,
ColumnMenuModule,
ContextMenuModule,
ColumnsToolPanelModule,
];
let allRowData: any[];
const GridExample = () => {
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const gridStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [rowData, setRowData] = useState<IAccount[]>();
const [columnDefs, setColumnDefs] = useState<ColDef[]>([
// group cell renderer needed for expand / collapse icons
{ field: "name", cellRenderer: "agGroupCellRenderer" },
{ field: "account" },
{ field: "calls" },
{ field: "minutes", valueFormatter: "x.toLocaleString() + 'm'" },
]);
const defaultColDef = useMemo<ColDef>(() => {
return {
flex: 1,
enableCellChangeFlash: true,
};
}, []);
const detailCellRenderer = useMemo(() => {
return DetailCellRenderer;
}, []);
const onGridReady = useCallback((params: GridReadyEvent) => {
fetch("https://www.ag-grid.com/example-assets/master-detail-data.json")
.then((resp) => resp.json())
.then((data: IAccount[]) => {
allRowData = data;
setRowData(allRowData);
});
}, []);
const onFirstDataRendered = useCallback(
(params: FirstDataRenderedEvent) => {
setInterval(() => {
if (!allRowData) {
return;
}
const data = allRowData[0];
const newCallRecords: any[] = [];
data.callRecords.forEach((record: any, index: number) => {
newCallRecords.push({
name: record.name,
callId: record.callId,
duration: record.duration + (index % 2),
switchCode: record.switchCode,
direction: record.direction,
number: record.number,
});
});
data.callRecords = newCallRecords;
data.calls++;
const tran = {
update: [data],
};
params.api.applyTransaction(tran);
}, 2000);
},
[allRowData],
);
return (
<AgGridProvider modules={modules}>
<div style={containerStyle}>
<div style={gridStyle}>
<AgGridReact<IAccount>
rowData={rowData}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
masterDetail={true}
detailCellRenderer={detailCellRenderer}
detailRowHeight={70}
groupDefaultExpanded={1}
onGridReady={onGridReady}
onFirstDataRendered={onFirstDataRendered}
/>
</div>
</div>
</AgGridProvider>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<GridExample />
</StrictMode>,
);
p {
font-size: 1em;
margin-top: 0;
}
.container {
max-width: 960px;
height: 100%;
margin: 10% auto;
padding: 2.5em;
}
[role='gridcell'] {
display: flex;
height: 100%;
width: 100%;
}
form {
flex: 1;
}
form > div {
display: flex;
flex: 1;
height: 100%;
background-color: #99999944;
}
form > div > p {
min-width: 33.33%;
}
label {
display: block;
margin: 0.75em 25%;
font-weight: bold;
}
import React, { memo, useEffect, useState } from "react";
import type { CustomCellRendererProps } from "ag-grid-react";
const DetailCellRenderer = ({ data }: CustomCellRendererProps) => {
const [callCount, setCallCount] = useState(data.calls);
useEffect(() => {
setCallCount(data.calls);
}, [data.calls]);
return (
<div role="gridcell">
<form>
<div>
<p>
<label>
Calls:
<br />
<input
type="text"
value={callCount}
onChange={(e) => setCallCount(e.target.value)}
/>
</label>
</p>
<p>
<label>Last Updated: {new Date().toLocaleTimeString()}</label>
</p>
</div>
</form>
</div>
);
};
export default memo(DetailCellRenderer);
export interface ICallRecord {
name: string;
callId: number;
duration: number;
switchCode: string;
direction: string;
number: string;
}
export interface IAccount {
name: string;
account: number;
calls: number;
minutes: number;
callRecords: ICallRecord[];
} Keyboard Navigation Copy Link
To add keyboard navigation to custom detail panels, it must be implemented in the custom Detail Cell Renderer. There are several parts to this:
- Create a listener function for the
focusevent when the custom detail panel receives focus. Within this function, the event objecttargetvalue is the custom detail row element, and event objectrelatedTargetvalue is the previous element that was previously focused on. You will need to find the parent of therelatedTargetwithrole=rowattribute to get the previous row element. With the current row element and the previous row element, checking therow-indexattribute allows you to see if the user is entering the focus from the previous or current row (ie,row-indexincreases or is the same from previous to current) or the next row (ie,row-indexdecreases from previous to current). With this knowledge, you can set focus usingelement.focus()on the relevant element in your custom detail panel - Attach the above function to a
focuslistener on theeParentOfValueparam value in the component initialisation - Remove the above function from the
focuslistener in the component destroy or unmount method
The following example shows an implementation of keyboard navigation in a custom detail panel:
- Click a cell in the
Mila Smithmaster row and press ⇥ Tab key to move focus to the custom detail panel inputs of theMila Smithmaster row. - Click a cell in the
Evelyn Taylormaster row and press ⇧ Shift+⇥ Tab to focus the inputs in the custom detail panel of theMila Smithmaster row.
This example is illustrative of the main concepts, but the actual implementation of custom keyboard navigation will vary based on the specific custom detail panel.