Languages

React - how to implement row expander in PrimeReact using reusable custom data table?

3 points
Asked by:
jan
1090

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!

4 comments
Root-ssh
It looks like https://stackblitz.com/run?file&#61;src%2FApp.js,src%2Fservice%2FCustomerService.js link doesn&#39;t work.
Root-ssh
As I suppose you want expand rows like here: https://dirask.com/posts/React-simple-animated-expander-example-1w4A2D
a_horse
Did you try to use: https://primereact.org/datatable/#row_expansion ?
jan
Yes, I have tried that. As I am trying to create a reusable custom data table, I am unable to give a correct structure in my custom data table. I have passed a new prop called expander, and in my parent component where I am using this custom data table will have the expander as the expanding value and the data to be shown on expanding. But how can I implement something more dynamic in my data table!
Add comment
1 answer
0 points
Answered by:
jan
1090

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:

PrimeReact DataTable with expandable row.
PrimeReact DataTable with expandable row.

 

Referneces

  1. https://primereact.org/datatable/#row_expansion
5 comments
jan
Thank you &#64;a_horse. I tried implementing as you suggested. const renderRowExpansion &#61; (rowData: RowData) &#61;&gt; { const subData &#61; rowData.id; return ( <div> Hello World </div> ); }; This is how I added in my render method in my App compoennt. rowData?.subData?.length &gt; 0} style&#61;{{ width: &#39;5rem&#39; }} resizeable /&gt; This is how I called it in my data table and assigned to id column. But I cannot see any expander. Can you please help me with this. Thank you
Root-ssh
Could you share yours source code somehow?
a_horse
Debug your source code in &#96;rowData?.subData?.length &gt; 0&#96; condition place to be sure there is any data available.
jan
Yeah thank you, I did that and it is working now. But is there any way that I can hide the expander icon for the rows which are not having child data. Sharing my code {this.props.includeExpander &amp;&amp; } {React.Children.map(this.props.children, (child: ReactNode, index) &#61;&gt; { if (React.isValidElement(child)) { const { frozen } &#61; child.props; return React.cloneElement(child as React.ReactElement, { header: ( &lt;&gt; {child.props.header}{&#39; &#39;} {child.props.sortable &amp;&amp; this.renderSortButtons(child.props.field || &#39;&#39;, data)} {child.props.filter &amp;&amp; this.renderFilterIcon(child.props.field || &#39;&#39;)} &lt;/&gt; ), frozen, style: { minWidth: &#39;200px&#39; }, className: &#96;font-bold ${frozen ? &#39;p-frozen-column&#39; : &#39;&#39;}&#96;, }); } return null; })} This is how I added expander in my reusable custom data table by passing a new prop called includeExpander. This is how I used it in my App, for the column that I want expander I passed expander prop from primerecat Column Component and OrdersExpansionTemplate &#61; (rowData: any) &#61;&gt; { const { orders } &#61; rowData; if (!orders || orders.length &#61;&#61;&#61; 0) { return <div>No data found</div>; } return ( <div> </div> ); }; This is how I am passing the expansion data. I tried different conditions from custom data table table but none of them worked so, if the rowData&#39;s child length is 0 then it should show No data found. I know it&#39;s a bit misleading for the user, showing the expander icon when there is no child data. Can you help me fix this! Thanks in advance!
Root-ssh
As I suppose the problem must be in &#96;expander&#96; inside &#96; rowData?.subData?.length &gt; 0} style&#61;{{ width: &#39;5rem&#39; }} /&gt;&#96;. Check it again.
Add comment
Donate to Dirask
Our content is created by volunteers - like Wikipedia. If you think, the things we do are good, donate us. Thanks!
Join to our subscribers to be up to date with content, news and offers.
Native Advertising
🚀
Get your tech brand or product in front of software developers.
For more information Contact us
Dirask - we help you to
solve coding problems.
Ask question.

❤️💻 🙂

Join