React - how to implement row expander in PrimeReact using reusable custom data table?
I am creating a reusable custom data table. I want to implement row expander in my custom data table. I want to implement that in 2 ways. One is on expanding row how it looks if I have only one line of data. The other way is on expanding how it looks if it having a table. I am attaching my code. Please help me.
import React, { Component, ReactNode } from 'react';
import { DataTable } from 'primereact/datatable';
import { Checkbox } from 'primereact/checkbox';
import 'primereact/resources/themes/mdc-dark-deeppurple/theme.css';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import { PaginatorComponent } from './paginatorComponent';
import { Column, ColumnProps } from 'primereact/column';
import './customDataTable.css';
import UpArrow from './upArrow';
import DownArrow from './downArrow';
interface DataTableProps {
data: any[];
checkbox: boolean;
sortField: string | null;
sortOrder: number;
onSortChange: (field: string, order: number) => void;
onSelectionChange: (selectedRows: any[], selectAll: boolean) => void;
scrollHeight?: string;
scrollWidth?: string;
columnRenderers?: Record<string, (cellData: any) => ReactNode>;
children?: ReactNode;
scrollable?: boolean;
height?: string;
width?: string;
className?: string;
globalSearch: boolean;
resetIcon: boolean;
}
export interface DataTableState {
currentPage: number;
selectedRows: any[];
selectAll: boolean;
itemsPerPage: number;
filterData: any[];
height: string | undefined;
width: string | undefined;
globalSearch: string;
columnFilters: Record<string, string | boolean | undefined | null>;
loading: boolean;
resetIcon: boolean;
}
function debounce(func: any, delay: any) {
let timerId: any;
return function (...args: any[]) {
clearTimeout(timerId);
timerId = setTimeout(() => {
func.apply(args);
}, delay);
};
}
class CustomDataTable extends Component<DataTableProps, DataTableState> {
constructor(props: DataTableProps) {
super(props);
this.state = {
currentPage: 1,
selectedRows: [],
filterData: [],
selectAll: false,
itemsPerPage: 10,
height: props.height,
width: props.width,
globalSearch: '',
columnFilters: {},
resetIcon: false,
loading: false,
};
}
componentDidUpdate(prevProps: DataTableProps, prevState: DataTableState) {
if (
prevProps.data !== this.props.data ||
prevProps.sortField !== this.props.sortField ||
prevProps.sortOrder !== this.props.sortOrder ||
prevState.resetIcon !== this.state.resetIcon
) {
this.sortData();
}
if (prevProps.height !== this.props.height || prevProps.width !== this.props.width) {
this.setState({
height: this.props.height,
width: this.props.width,
});
}
}
handlePageChange = (newPage: number, itemsPerPage: number, data: any[]) => {
this.setState({ currentPage: newPage, itemsPerPage }, () => {
this.updateDisplayedData();
});
}
handleReset = () => {
this.setState({
currentPage: 1,
selectedRows: [],
selectAll: false,
resetIcon: !this.state.resetIcon,
filterData: this.props.data,
});
this.props.onSortChange('', 1);
};
toggleAllRows = () => {
const { selectAll } = this.state;
const { data } = this.props;
if (selectAll) {
this.setState({ selectedRows: [] });
} else {
this.setState({ selectedRows: [...data] });
}
this.setState({ selectAll: !selectAll }, () => {
this.props.onSelectionChange(this.state.selectedRows, !selectAll);
});
}
toggleRow = (rowData: any) => {
const { selectedRows } = this.state;
const rowIndex = selectedRows.findIndex((row) => row.id === rowData.id);
if (rowIndex === -1) {
this.setState(
{ selectedRows: [...selectedRows, rowData], selectAll: false },
() => {
this.props.onSelectionChange(this.state.selectedRows, false);
}
);
} else {
const newSelectedRows = [...selectedRows];
newSelectedRows.splice(rowIndex, 1);
this.setState({ selectedRows: newSelectedRows, selectAll: false }, () => {
this.props.onSelectionChange(newSelectedRows, false);
});
}
}
sortData = () => {
const { sortField, sortOrder } = this.props;
console.log('Sort Field:', sortField);
console.log('Sort Order:', sortOrder);
if (sortField) {
const sorted = [...this.props.data].sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue !== undefined && bValue !== undefined) {
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortOrder * aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder * (aValue - bValue);
} else {
// Handle other data types if needed
return 0;
}
}
return 0;
});
this.setState({ filterData: sorted });
}
};
handleItemsPerPageChange = (itemsPerPage: number) => {
this.setState({ itemsPerPage }, () => {
this.updateDisplayedData();
});
}
updateDisplayedData = () => {
const { currentPage, itemsPerPage } = this.state;
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const dataToDisplayPaginated = this.props.data.slice(startIndex, endIndex);
console.log(dataToDisplayPaginated);
}
renderSortButtons = (field: string, data: any[]) => {
const { sortField, sortOrder } = this.props;
const isActive = sortField === field;
const handleClick = () => {
let newSortOrder;
if (isActive) {
// If the current arrow is active, cycle through -1, 0, and 1
newSortOrder = sortOrder === 1 ? -1 : sortOrder === -1 ? 0 : 1;
} else {
// If the arrow is not active, start sorting in ascending order
newSortOrder = 1;
}
// Trigger sorting
this.props.onSortChange(field, newSortOrder);
};
return (
<div className="sort-buttons">
<button onClick={handleClick}>
<UpArrow active={isActive && sortOrder === 1} onClick={() => {}} />
</button>
<button onClick={handleClick}>
<DownArrow active={isActive && sortOrder === -1} onClick={() => {}} />
</button>
</div>
);
};
deepSearch = (data: any[], searchText: string) => {
searchText = searchText.toLowerCase();
function searchInObject(obj: any) {
for (const key in obj) {
const value = obj[key];
if (value !== null && typeof value === 'object') {
if (searchInObject(value)) {
return true;
}
} else if (
value !== null &&
(typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') &&
value.toString().toLowerCase().includes(searchText)
) {
return true;
}
}
return false;
}
function searchInArray(arr: any[]) {
for (const item of arr) {
if (searchInObject(item) || (Array.isArray(item) && searchInArray(item))) {
return true;
}
}
return false;
}
debugger;
return data.filter((item: any) => searchInObject(item) || (Array.isArray(item) && searchInArray(item)));
}
handleGlobalSearch = async (e?: React.KeyboardEvent<HTMLInputElement>) => {
if (e && e.key !== 'Enter') {
return;
}
// Set loading state to true
this.setState({ loading: true, currentPage: 1 });
const { globalSearch } = this.state;
const { data } = this.props;
// Simulate asynchronous data fetching
setTimeout(() => {
if (globalSearch) {
const filteredData = this.deepSearch(data, globalSearch);
console.log('Filtered Data:', filteredData);
// Set loading state back to false and update filterData
this.setState({ filterData: filteredData, currentPage: 1, loading: false });
} else {
// If global search is empty, reset filterData and show all data
this.setState({ filterData: [], currentPage: 1, loading: false });
}
}, 1000);
};
renderFilterIcon = (field: string) => {
const { columnFilters } = this.state;
return (
<span className="p-column-filter">
<i
className={`pi ${columnFilters[field] ? 'pi-times' : 'pi-filter'}`}
onClick={() => this.toggleFilterInput(field)}
/>
{columnFilters[field] && (
<input
type="text"
onChange={(e) => this.handleFilterChange(field, e.target.value)}
/>
)}
</span>
);
};
toggleFilterInput = (field: string) => {
const { columnFilters } = this.state;
const updatedFilters = { ...columnFilters, [field]: !columnFilters[field] };
this.setState({ columnFilters: updatedFilters });
};
handleFilterChange = (field: string, value: string) => {
const { columnFilters } = this.state;
const updatedFilters = { ...columnFilters, [field]: value };
this.setState({ columnFilters: updatedFilters }, () => {
this.applyFilters();
});
};
applyFilters = () => {
const { columnFilters } = this.state;
const { data } = this.props;
// Check if global search is active
const isGlobalSearchActive = this.state.globalSearch.trim() !== '';
// Create a copy of columnFilters
const updatedFilters = { ...columnFilters };
// Iterate over columnFilters
Object.entries(updatedFilters).forEach(([field, filterValue]) => {
if (isGlobalSearchActive) {
// If global search is active, set the filter value to null
updatedFilters[field] = null;
}
});
// Set the updated filters in the state
this.setState({ columnFilters: updatedFilters });
// Apply filters based on updatedFilters
const filteredData = data.filter((row) =>
Object.entries(updatedFilters).every(([field, filterValue]) => {
if (filterValue != null && typeof filterValue !== 'boolean') {
const cellValue = (row[field] as any).toString().toLowerCase();
return cellValue.includes(filterValue.toLowerCase());
}
return true;
})
);
this.setState({
filterData: filteredData,
currentPage: 1,
});
};
render() {
const { currentPage, selectedRows, selectAll, itemsPerPage, loading } = this.state;
const { checkbox, globalSearch, data } = this.props;
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const dataToDisplayPaginated = this.state.filterData.length === 0 && this.state.globalSearch.trim() !== '' ? [] : this.state.filterData.length === 0
? this.props.data.slice(startIndex, endIndex) : this.state.filterData.slice(startIndex, endIndex);
const debouncedGlobalSearch = debounce(this.handleGlobalSearch, 1000);
return (
<div className="custom-data-table-container">
<div className="global-search-container">
{globalSearch && (
<div className="global-search">
<div className="search-input-container" style={{ display: 'flex', alignItems: 'center' }}>
<div className="search">
<i
className="pi pi-search"
style={{ fontSize: '1.2em', marginRight: '10px' }}
onClick={() => this.handleGlobalSearch()}
>
</i>
<input
className="searchInput"
type="text"
placeholder="Search"
onChange={(e) => this.setState({ globalSearch: e.target.value })}
onKeyDown={(e) => debouncedGlobalSearch(e)}
/>
</div>
</div>
<div className="reset-icon-container">
<i
className="pi pi-replay"
style={{ fontSize: '1.2em' }}
onClick={() => this.handleReset()}
>
<span className="reset"> Reset All</span>
</i>
</div>
</div>
)}
</div>
<div className="custom-data-table">
{loading ? (
<div className='loader'>
<i className="pi pi-spin pi-spinner" style={{ fontSize: '2rem' }}></i>
<div>Loading...</div>
</div>
) : (
<DataTable
value={dataToDisplayPaginated}
stripedRows
resizableColumns
frozenWidth='300px'
scrollable
className="custom-data-table-scroll p-datatable-scrollable"
style={{ height: this.state.height, width: this.state.width }}
>
{checkbox && (
<Column
key="checkbox"
header={
<Checkbox
className="custom-checkbox"
checked={selectAll}
onChange={() => this.toggleAllRows()}
/>
}
body={(rowData) => (
<Checkbox
className="custom-checkbox"
checked={selectedRows.some((row) => row.id === rowData.id)}
onChange={() => this.toggleRow(rowData)}
/>
)}
className="p-frozen-column"
/>
)}
{React.Children.map(this.props.children, (child: ReactNode, index) => {
if (React.isValidElement<ColumnProps>(child)) {
const { frozen } = child.props;
return React.cloneElement(child as React.ReactElement<ColumnProps>, {
header: (
<>
{child.props.header}{' '}
{child.props.sortable &&
this.renderSortButtons(child.props.field || '', data)}
{child.props.filter &&
this.renderFilterIcon(child.props.field || '')}
</>
),
frozen,
style: { minWidth: '200px' },
className: `font-bold ${frozen ? 'p-frozen-column' : ''}`,
});
}
return null;
})}
</DataTable>
)}
</div>
{dataToDisplayPaginated.length > 0 && (
<PaginatorComponent
totalItems={data.length}
itemsPerPage={itemsPerPage}
currentPage={currentPage}
onPageChange={(newPage, itemsPerPage) =>
this.handlePageChange(newPage, itemsPerPage, data)
}
rowsCountOptions={[10, 20, 50, 100]}
/>
)}
</div>
);
}
}
export default CustomDataTable;
This is my custom data table component. And I am using it inside my App like below.
import React, { Component } from 'react';
import CustomDataTable from './datatable-component/Data table/customDataTable';
import { ProductService } from './datatable-component/mockData';
import { Chip } from './datatable-component/chip/chip';
import { Column } from 'primereact/column';
import { DateFormatter } from './datatable-component/Data table/formatter';
import './App.css';
import { DateFilter, DropdownFilter } from './datatable-component/Data table/filterComponnet';
// Define the interface for the application's state.
interface AppState {
sortField: string | null; // Current sort field for the table.
sortOrder: number; // Current sort order (1 for ascending, -1 for descending).
selectedRows: any[]; // Array of selected rows.
selectAll: boolean; // Indicates whether all rows are selected.
scrollHeight: string; // Height of the table scroll area.
scrollWidth: string; // Width of the table scroll area.
direction: 'ascending' | 'descending'; // Current sorting direction.
products: any[];
}
export interface RowData {
userId: number;
id: number;
code: string;
name: string;
description: string;
price: number;
status: string;
severity: string;
date: string;
}
// Create the main application component.
class App extends Component<{}, AppState> {
constructor(props: {}) {
super(props);
this.state = {
sortField: null, // Initially, no sort field is selected.
sortOrder: 1, // Initially, sorting is in ascending order.
selectedRows: [], // Initially, no rows are selected.
selectAll: false, // Initially, the "select all" option is unchecked.
scrollHeight: '400', // Initial height of the table scroll area.
scrollWidth: '600', // Initial width of the table scroll area.
direction: 'ascending', // Initial sorting direction is ascending.
products: []
};
}
// Define a function to render a Chip component based on a value.
getChip(value: string) {
const options = [
{ value: 'High', text: 'HIGH' },
{ value: 'Medium', text: 'MEDIUM' },
{ value: 'Low', text: 'LOW' },
];
const selectedOption = options.find((option) => option.value === value);
let view: any = null;
switch (selectedOption?.value) {
case 'High':
view = <Chip label={'HIGH'} color={'red-white'} />;
break;
case 'Medium':
view = <Chip label={'MEDIUM'} color={'yellow'} />;
break;
case 'Low':
view = <Chip label={'LOW'} color={'blue-white'} />;
break;
default:
break;
}
return view;
}
renderFilterComponent(field: string) {
switch (field) {
case 'date':
return <DateFilter onFilterChange={(value) => console.log(value)} />;
case 'status':
return <DropdownFilter options={['HIGH', 'MEDIUM', 'LOW']} onFilterChange={(value) => console.log(value)} />;
default:
return null;
}
}
// Define a function to handle sorting changes.
handleSortChange = (field: string, order: number) => {
this.setState({ sortField: field, sortOrder: order });
};
// Define a function to handle selection changes.
handleSelectionChange = (selectedRows: any[], selectAll: boolean) => {
this.setState({ selectedRows, selectAll });
};
fetchData = async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json();
console.log('Received data:', data);
this.setState({
products: data,
});
} catch (error) {
console.error('Error fetching data:', error);
}
};
componentDidMount() {
this.fetchData();
}
getHeader(field: string) {
return field.charAt(0).toUpperCase() + field.slice(1);
}
IdRenderer = (rowData: RowData) => {
return <div>{rowData.id}</div>;
};
codeRenderer = (rowData: RowData) => {
return <div>{rowData.code}</div>;
};
nameRenderer = (rowData: RowData) => {
return <div>{rowData.name}</div>;
};
descriptionRenderer = (rowData: RowData) => {
return <div>{rowData.description}</div>;
};
priceRenderer = (rowData: RowData) => {
return <div>{rowData.price}</div>;
};
statusRenderer = (rowData: RowData) => {
return <div>{this.getChip(rowData.status)}</div>;
};
severityRenderer = (rowData: RowData) => {
return <div>{this.getChip(rowData.severity)}</div>;
};
dateRenderer = (rowData: RowData) => {
return <DateFormatter data={rowData.date} />;
};
render() {
const productsData = ProductService.getProductsWithOrdersData();
console.log('Products are', this.state.products)
console.log('State:', this.state);
console.log(productsData,'products data')
console.log('Props:', this.props);
return (
<div>
<div>
<h1>Product DataTable</h1>
<CustomDataTable
data={productsData}
checkbox={true}
sortField={this.state.sortField}
sortOrder={this.state.sortOrder}
onSortChange={this.handleSortChange}
onSelectionChange={this.handleSelectionChange}
scrollHeight={this.state.scrollHeight}
scrollWidth={this.state.scrollWidth}
scrollable={true}
height="40%"
width="100%"
globalSearch={true}
resetIcon={false} >
<Column
field={'id'}
header={this.getHeader('id')}
body={this.IdRenderer}
sortable={true}
expander
/>
<Column
field={'code'}
header={this.getHeader('code')}
body={this.codeRenderer}
/>
<Column
field={'name'}
header={this.getHeader('name')}
body={this.nameRenderer}
sortable
/>
<Column
field={'description'}
header={this.getHeader('description')}
body={this.descriptionRenderer}
/>
<Column
field={'price'}
header={this.getHeader('price')}
body={this.priceRenderer}
sortable
/>
<Column
field={'status'}
header={this.getHeader('status')}
body={this.statusRenderer}
/>
<Column
field={'severity'}
header={this.getHeader('severity')}
body={this.severityRenderer}
/>
<Column
field={'date'}
header={this.getHeader('date')}
body={this.dateRenderer}
/>
</CustomDataTable>
</div>
</div>
);
}
}
primereactImplementation. This is how prime react rowexpansion implementation is working. I tried the same but I lost all the data in UI. If the child data is avilable for that row then passing expander prop in the column component, will show the icon for the row data of that column which is having child data. If not the icon will not appear.
Thanks in advance!
The below source code sould solve your problem.
add inside constructror()
function:
...
this.state = {
...
expandedRows: []
...
};
...
add inside render()
function:
...
<DataTable
...
expandedRows={this.state.expandedRows}
onRowToggle={(e) => this.setState({...this.state, expandedRows: e.data})}
rowExpansionTemplate={(rowData) => {
const subData = rowData.subData;
return (
<div>
Render row sub-data here ...
</div>
);
}}
...
>
<Column expander={(rowData) => rowData?.subData?.length > 0} style={{ width: '5rem' }} />
...
</DataTable>
...
Hint: use other field instead of
subData
.
Preview:
