3

I am trying to create a dynamic table component in React JS. The component currently only has a static header including the most common result keys. Some results also hold more information i.e. phone_number, degree. How do I dynamically extend the table with an additional column depending on presents of key/value? Should I work with state and make visible when present, or should I pre-process the table (but how do I ensure the correct order)? Are there better ways to do so?

import React, { Component } from 'react';

class ResultTable extends Component {

  addTableRow = (result) => {
    return (
      <tr key={result._id}>
        <td>{result.lastname}</td>
        <td>{result.firstname}</td>
        <td>{result.city}</td>
        <td>{result.zip}</td>
        <td>{result.street} {result.street_number}</td>
      </tr>)
  }

  createTable = (results) => {
    return (
      <table className="striped highlight ">
        <thead>
          <tr>
            <th>Lastname</th>
            <th>Firstname</th>
            <th>City</th>
            <th>ZIP</th>
            <th>Street</th>
          </tr>
        </thead>
        <tbody>
          {results.map((result, index) => {
            return this.addTableRow(result)
          })}
        </tbody>
      </table>
    )
  }

  render() {
    return (
      <div>
        {this.props.results.length ? (
          <div className="card">
            {this.createTable(this.props.results)}
          </div>
        ) : null}
      </div>
    )
  }
}

export default ResultTable

results look like that:

[
    { "_id": 1, "area_code": "555", "city": "Berlin", "firstname": "John", "lastname": "Doe", "phonenumber": "12345678", "zip": "12345"},
    { "_id": 2, "area_code": "555", "city": "Frankfurt", "firstname": "Arnold", "lastname": "Schwarzenegger", "phonenumber": "12121212", "street": "Main Street", "street_number": "99", "zip": "545454"},
    { "_id": 3, "area_code": "123", "firstname": "Jane", "lastname": "Doe", "phonenumber": "777777", "fav_color": "blue"},
    { "_id": 4, "area_code": "456", "firstname": "Scooby", "lastname": "Doo",  "phonenumber": "444444"}, "note": "Lazy but cool"
]
mayo-s
  • 65
  • 1
  • 1
  • 9
  • I'm following this to see solutions :) But you could map your results, check for other column headers that are not default, and then add them to your header accordingly. The only thing then would be ordering, like you said. – Thales Kenne Nov 25 '20 at 12:56
  • You mean you could have a column with phone_number more and some rows won't have a value in it but some yes? and you want to dynamically add the column if the columns doesnt exist ? – DarioRega Nov 25 '20 at 13:02
  • @DarioRega Yes, that is what I am aiming for. Common keys first and if existent, add more columns. – mayo-s Nov 25 '20 at 13:11
  • Alright im doing a codesandbox right now – DarioRega Nov 25 '20 at 13:13
  • Well it's look like it need more work that i thought haha, im nearly done – DarioRega Nov 25 '20 at 14:08
  • Okey i've made it work, i'll write now the documentation – DarioRega Nov 25 '20 at 15:28

3 Answers3

7

Alright alright, i took me around two hours to do it and to get back into react, it's been a while i haven't touched it. So if y'all see something to refactor, with pleasure you can edit !

The code first :

import React from "react";
import "./styles.css";

export default class App extends React.Component {
  state = {
    columns: [],
    columnsToHide: ["_id"],
    results: [
      {
        _id: 1,
        firstname: "Robert",
        lastname: "Redfort",
        city: "New York",
        zip: 1233,
        street: "Mahn Street",
        street_number: "24A",
        favoriteKebab: "cow"
      },
      {
        _id: 2,
        firstname: "Patty",
        lastname: "Koulou",
        city: "Los Angeles",
        zip: 5654,
        street: "Av 5th Central",
        street_number: 12
      },
      {
        _id: 3,
        firstname: "Matt",
        lastname: "Michiolo",
        city: "Chicago",
        zip: 43452,
        street: "Saint Usk St",
        street_number: 65,
        phoneNumber: "0321454545"
      },
      {
        _id: 4,
        firstname: "Sonia",
        lastname: "Remontada",
        city: "Buenos Aires",
        zip: "43N95D",
        street: "Viva la Revolution Paso",
        street_number: 5446,
        country: "Argentina"
      }
    ]
  };
  componentDidMount() {
    this.mappDynamicColumns();
  }

  mappDynamicColumns = () => {
    let columns = [];
    this.state.results.forEach((result) => {
      Object.keys(result).forEach((col) => {
        if (!columns.includes(col)) {
          columns.push(col);
        }
      });
      this.setState({ columns });
    });
  };

  addTableRow = (result) => {
    let row = [];
    this.state.columns.forEach((col) => {
      if (!this.state.columnsToHide.includes(col)) {
        row.push(
          Object.keys(result).map((item) => {
            if (result[item] && item === col) {
              return result[item];
            } else if (item === col) {
              return "No Value";
            }
          })
        );
        row = this.filterDeepUndefinedValues(row);
      }
    });

    return row.map((item, index) => {
      // console.log(item, "item ?");
      return (
        <td
          key={`${item}--${index}`}
          className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
        >
          {item}
        </td>
      );
    });
  };

  mapTableColumns = () => {
    return this.state.columns.map((col) => {
      if (!this.state.columnsToHide.includes(col)) {
        const overridedColumnName = this.overrideColumnName(col);
        return (
          <th
            key={col}
            scope="col"
            className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
          >
            {overridedColumnName}
          </th>
        );
      }
    });
  };

  filterDeepUndefinedValues = (arr) => {
    return arr
      .map((val) =>
        val.map((deepVal) => deepVal).filter((deeperVal) => deeperVal)
      )
      .map((val) => {
        if (val.length < 1) {
          val = ["-"];
          return val;
        }
        return val;
      });
  };

  // if you want to change the text of the col you could do here in the .map() with another function that handle the display text

  overrideColumnName = (colName) => {
    switch (colName) {
      case "phoneNumber":
        return "Phone number";
      case "lastname":
        return "Custom Last Name";
      default:
        return colName;
    }
  };

  createTable = (results) => {
    return (
      <table class="min-w-full divide-y divide-gray-200">
        <thead>
          <tr>{this.mapTableColumns()}</tr>
        </thead>
        <tbody>
          {results.map((result, index) => {
            return <tr key={result._id}>{this.addTableRow(result)}</tr>;
          })}
        </tbody>
      </table>
    );
  };

  render() {
    return (
      <div class="flex flex-col">
        <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
          <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
            <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
              {this.state.results.length ? (
                <div className="card">
                  {this.createTable(this.state.results)}
                </div>
              ) : null}
            </div>
          </div>
        </div>
      </div>
    );
  }
}

Why undefined ? Well, like i said, im kind of rotten on react (and maybe on codding ;) ) But i didn't find any other way to keep the orders of each value under her belonging column without it being at her current index in the array.

To come back to our code, we end up with a variable row populated with each values belonging to her current column name, so at the right index.

the payload of row before being sending back on the html is so :

As you see, it isn't clean. But i did not find any solution for that, i believe it's possible but it requires more time to think.

So since it's really deeply nested, i had to come up with some filters, to allow the user to see when the property doens't exist, or we could just left it blank.

After each push, i need to clean my deep nested values in arrays, so i've come up with a function filterDeepUndefinedValues row = this.filterDeepUndefinedValues(row)

And the function itself

filterDeepUndefinedValues = (arr) => {
  return arr
    .map((val) => val.map((deepVal) => deepVal).filter((deeperVal) => deeperVal))
    .map((val) => {
      if (val.length < 1) {
        val = ["-"];
        return val;
      }
      return val;
    });
};

To make short, i have arrays nested in array which contain undefined values, so i'll return a new array filtered out without undefined value, so it return an empty array.

How the array is received in the function initially enter image description here

In the second part, i just replace empty array content by a hyphen in array

First treatment with .map and filter() enter image description here

Replace empty array with a hyphen enter image description here

The return of addTableRow

return row.map((item, index) => {
  return (
    <td
      key={`${item}--${index}`}
      className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
    >
      {item}
    </td>
  );
});

Here we simply map all the values from rows and render a td for each array of row

Im running out of time to end the details, but all it's here and i will try to come back later to clean a bit this answer and it's grammar.

Codesandbox link: https://codesandbox.io/s/bold-mendel-vmfsk?file=/src/App.js

DarioRega
  • 992
  • 4
  • 13
  • Good job! S+ for effort – Thales Kenne Nov 25 '20 at 17:41
  • Thanks! I got carried away with the challenge haha – DarioRega Nov 25 '20 at 19:15
  • @DarioRega Thank you for your efforts. I added a results array to my entry post. Do I get it right, that if I pre-fill columns state with some values I can influence the table header order? I also believe that if a property does not exist it will be ignored, at least it's currently working like that. Will try and update. Thanks again. – mayo-s Nov 26 '20 at 10:16
1

Based on @DarioRega's answer I will post my solution underneath. The key to my solution is his mappDynamicColumns function. In this solution the headers state is pre-filled with those headers that I wish to have first when rendering the table. fetchTableHeaders() is looping through all results and adds headers as they appear in a result. It is also possible to ignore certain result keys if they are not wanted as columns (credits to @DarioRega).

import React, { Component } from 'react';

class ResultTable extends Component {

  state = {
    headers: ['lastname', 'firstname', 'city', 'zip', 'street'],
    ignore_headers: ['_id', 'flags', 'street_number'],
  }

  addTableRow = (headers, result) => {
    return (
      <tr key={result._id}>
        {headers.map((h) => {
          if(this.state.ignore_headers.includes(h)) return null
          if(h === 'street') return (<td>{result.street} {result.street_number}</td>)
          return (<td>{result[h]}</td>)
        })}
      </tr>)
  }

  createTable = (results) => {

    let headers = this.fetchTableHeaders(results);

    return (
      <table className="striped highlight ">
        <thead>
          <tr>
            {headers.map((h) => {
              return (<th>{this.makeHeaderStr(h)}</th>)
            })}
          </tr>
        </thead>
        <tbody>
          {results.map((result) => {
            return this.addTableRow(headers, result)
          })}
        </tbody>
      </table>
    )
  }

  fetchTableHeaders = (results) => {
    let headers = [...this.state.headers];
    results.forEach((result) => {
      Object.keys(result).forEach((r) => {
        if(!this.state.ignore_headers.includes(r) && !headers.includes(r)) {
          headers.push(r);
        }
      });
    });
    return headers
  }

  makeHeaderStr = (header_str) => {
    if(typeof header_str !== 'string') return header_str
    return (header_str.charAt(0).toUpperCase() + header_str.slice(1)).replace('_', ' ')
  }

  render() {
    return (
      <div>
        {this.props.results.length ? (
          <div className="card">
            {this.createTable(this.props.results)}
          </div>
        ) : null}
      </div>
    )
  }
}

export default ResultTable
mayo-s
  • 65
  • 1
  • 1
  • 9
0

Another way to do it is to have a "private" function that sets the state of the component. The table can render based on a pre-computed DOM stored in this state.

The computing function that infers the DOM elements can be dynamically set based on the prop data passed to the component. Ex. you can have a table with 1 column, 4 columns, etc.

A side-benefit of this approach is that you can set the computed state again whenever this component's props change.

Here's an example:


interface ITableCellData {
    value: any
}

interface IFooProps {
    rowValues: Array<Array<ITableCellData>>
}

interface IFooState {
    computedRowValues: Array<Array<any>>
}

class Foo extends Component<IFooProps, IFooState> {

    constructor(props: IFooProps) {
        super(props)
        this.state = { computedRowValues: [] }
        this._computeAndSetState(props)
    }

    _computeAndSetState(props: IFooProps) {
        const computedRows = []
        for (let rowData of props.rowValues) {
            const computedRowCells: Array<any> = []

            for (let i = 0; i < rowData.length; i++) {
                const cell = rowData[i]

                // Logic for constructing your cell
                computedRowCells.push(<td>{cell.value}</td>)
            }

            computedRows.push(computedRowCells)
        }

        this.setState({ computedRows: computedRows })
    }

    render() {
       return (
          <table>
             <tbody>
             {this.state.computedRows.map((rowData, idx) => {
                 return <tr key={idx}>{rowData}</tr>
              })}
             </tbody>
          </table>
       )
    }

}

Achintya Ashok
  • 521
  • 4
  • 9