DEV Community

Cover image for Expand & collapse groups of items in a list.
Jan Schröder
Jan Schröder

Posted on • Updated on

Expand & collapse groups of items in a list.

My ticket this week: Show documents with the same file name as a version stack.

Tech used: Javascript, React, lodash

Some background. In the app, the user can upload files and attach those files to messages. Since our backend only cares about the unique id, that every uploaded file gets, users can upload multiple files with the same name. The creation date on the saved files is the timestamp from the upload.

If we're now to display a list of those files, we'll get lots of duplicates, which can get out of hand pretty quick. Like so:

|- oneFile.pdf      21.03.2019 16:34
|- oneFile.pdf      19.03.2019 14:23
|- anotherFile.pdf  18.03.2019 15:10
|- oneFile.pdf      14.03.2019 10:50
Enter fullscreen mode Exit fullscreen mode

Not too cool, especially since 95% of the times, the user would not need to access the older versions.

So, my task last week was to improve this, by only displaying the most recent file of each these groups. Each file that had multiple versions should display a button next to it, that would display the older versions when clicked.

To get started, let's take a look at our incoming data. We receive references to the files from the backend as objects in an array that looks something like this:

const files = [{
  id: 1,
  file_name: 'oneFile.pdf',
  created_at: '21.03.2019 16:34'
}, {
  id: 2,
  file_name: 'oneFile.pdf',
  created_at: '19.03.2019 14:23'
}, {
  id: 3,
  file_name: 'anotherFile.pdf',
  created_at: '18.03.2019 15:10'
}, {
  id: 4,
  file_name: 'oneFile.pdf',
  created_at: '14.03.2019 10:50'
}]
Enter fullscreen mode Exit fullscreen mode

This is a flat array, with all the objects that are going to be displayed.

The first thing I did, was grouping the objects by the file_name key. For that, I used the groupBy() function from lodash.

const groupedFiles = groupBy(files, 'file_name')
// Expected output:
// { 
//  oneFile.pdf: {
//    { 
//      id: 1,
//      file_name: 'oneFile.pdf',
//      created_at: '21.03.2019 16:34
//    }, {
//      id: 2,
//      file_name: 'oneFile.pdf',
//      created_at: '19.03.2019 14:23'
//    } ...
//  },
//  anotherFile.pdf: { ... }
// }
Enter fullscreen mode Exit fullscreen mode

Now, we don't need those keys really. In order to display a list, the array was fine. Lodash offers a function for that: values().
Values() takes the values of an object and puts them into an array. Exactly what we need.

const groupedList = values(groupedFiles)
// Expected output:
// [{
//    { 
//      id: 1,
//      file_name: 'oneFile.pdf',
//      created_at: '21.03.2019 16:34
//    }, {
//      id: 2,
//      file_name: 'oneFile.pdf',
//      created_at: '19.03.2019 14:23'
//    } ...
//  }, { ... }
// }
Enter fullscreen mode Exit fullscreen mode

When we print this to the console. It should look something like this:

Array: [{{...}{...}{...}}, {{...}{...}}, ...]
Enter fullscreen mode Exit fullscreen mode

Neat. Now we have our groups of files. Next is to render out the list.

This is the App component that contains our list. Here the files are passed to the List component as props.

import React from 'react';
import './App.css';
import List from './components/List'

const files = [{
  id: 1,
  file_name: 'oneFile.pdf',
  created_at: '21.03.2019 16:34'
}, {
  id: 2,
  file_name: 'oneFile.pdf',
  created_at: '19.03.2019 14:23'
}, {
  id: 3,
  file_name: 'anotherFile.pdf',
  created_at: '18.03.2019 15:10'
}, {
  id: 4,
  file_name: 'oneFile.pdf',
  created_at: '14.03.2019 10:50'
}]

const App = () => {
  return (
    <div className="App">
      <List files={files}/>
    </div>
  );
}
export default App;
Enter fullscreen mode Exit fullscreen mode

Now on to the List itself.

import React, { useState } from 'react';
import { groupBy, values, orderBy, take, includes } from 'lodash';

const List = (props) => {

  // Take in the original array and group the files by filename 
  const groupedFiles = groupBy(props.files, 'file_name');
  const groupedList = values(groupedFiles);

  // Set a hook to manage the state of each list item.
  // Using an array, multiple items can get added. 
  // When an item is added to the list, the complete group will be rendered. 
  const [filter, setFilter] = useState([])

  // Here items are being added or excluded from the array in the hook.
  const toggleFilter = ({ file_name }) => {
    if (includes(filter, file_name)) {
      return setFilter(filter.filter(item => item !== file_name))
    } else {
      return setFilter(filter.concat(file_name))
    }
  }

  // This function takes one individual group and return the JSX elements to render the data
  const renderGroup = (group) => {

    // to make sure, that the most recent file is at the beginning of the group, sort by creation date
    const sortedGroup = orderBy(group, 'created_at', 'desc');

    // Only render the button on a list element that is the first of a group bigger than 1.
    // This could be done inline, but I think that it is cleaner this way.
    const renderButton = (file) => sortedGroup.indexOf(file) === 0 && group.length > 1;

    let files, buttonLabel;

    if (includes(filter, group[0].file_name)) {
      files = sortedGroup;
      buttonLabel = 'show less'
    } else {
      files = take(sortedGroup);
      buttonLabel = 'show more'
    }

    return files.map(file => (
      <li key={file.id}>
        <p>{file.file_name} - {file.created_at}</p>
        {/* We can render an element conditionally, by including it into a statement like the following. 
        The button gets rendered only when the renderButton() function returns true. Nifty. */}
        {renderButton(file) && <button onClick={() => toggleFilter(file)}>{buttonLabel}</button>}
      </li>
    ))
  }

  return (
    <ul>
      {groupedList.map(group => renderGroup(group))}
    </ul>
  );
};

export default List;
Enter fullscreen mode Exit fullscreen mode

Let's walk through this. First, our component receives the ungrouped list through props. Then we take them in, group them as discussed above and finally pass them to the render function.

In the render function we first set up the elements which will contain our list. Then we take our array with the groups and use map() to iterate over it. Inside map, we get to process each group individually.

Now we need to decide wether we want to display all items or just the most recent one. Since we're going to render a button that enables thje user to switch between both, we need some form of state managment. A great opportunity to use hooks.

So we end up with a conditional statement that, depending on the state, passes all items of the group or just the first one.

Finally, we use map again to process the group. Here we return the JSX elements that we want to pass to the List component.

In order to not render the button on all the elements, we wrap the button element inside another conditional statement, so that the button only renders if the group is bigger than 1 and the element is at index 0 of the array.

And there you have it. Obviously there was no styling whatsoever done, but I hope this small example demonstrated some real life use cases for map(), conditional rendering and hooks in React, to set up a component to dynamically render a list of items.

Discussion (0)