Filter an object by multiple properties

Hi freeCodeCamp fam :wave:

This has been driving me mad nearly all day, if anyone could validate this I’d muchly appreciate.

Ok, so the setup is, I have a Gatsby site with an index page and some data, the data looks a lot like this:

const data = [
  {
    frontmatter: {
      title: 'Alfred',
    },
    headings: [
      {
        value: 'Add custom search',
        depth: 2,
      },
      {
        value: 'Change the default search in Alfred',
        depth: 2,
      },
    ],
    fields: {
      slug: '/alfred/',
    },
  },
  {
    frontmatter: {
      title: 'Fish Shell',
    },
    headings: [
      {
        value: 'Aliases',
        depth: 2,
      },
      {
        value: 'Oh My Fish',
        depth: 2,
      },
      {
        value: 'Use nvm with fish',
        depth: 2,
      },
      {
        value: 'List out added aliases',
        depth: 2,
      },
    ],
    fields: {
      slug: '/fish/',
    },
  },
  {
    frontmatter: {
      title: 'Bash',
    },
    headings: [
      {
        value: 'Add an alias',
        depth: 2,
      },
      {
        value: 'Sort alphabetically 👌',
        depth: 2,
      },
      {
        value:
          'Open the SSH agent each time you open a new terminal.',
        depth: 2,
      },
    ],
    fields: {
      slug: '/bash/',
    },
  },
];

I’m trying to do a fuzzy search, which I’ve partially achieved on the slug and title fields it’s just this value field in the headings array that’s driving me loopy!

function filterData(data, filters = {}) {
    const defaults = {
      fields: { slug: null },
      frontmatter: { title: null },
      headings: { value: null },
    };

    filters = Object.assign({}, defaults, filters);

    return data.filter(sheet => {
      return (
        sheet.fields.slug
          .toLowerCase()
          .includes(filters.fields.slug) ||
        sheet.frontmatter.title
          .toLowerCase()
          .includes(filters.frontmatter.title)
      );
    });
  }

  const result = filterData(nodes, {
    fields: { slug: searchTerm },
    frontmatter: { title: searchTerm },
    headings: { value: searchTerm },
  });

The above works, it’s when I try filter on the headings, so for now let’s take out the filters for slug and title and take a look at the last part:

function filterData(data, filters = {}) {
    const defaults = {
      fields: { slug: null },
      frontmatter: { title: null },
      headings: { value: null },
    };

    filters = Object.assign({}, defaults, filters);

    return data.filter(sheet => {
      return (
        sheet.headings.map(h =>
          h.value.toLowerCase().includes(filters.headings.value)
        )
      );
    });
  }

  const result = filterData(nodes, {
    fields: { slug: searchTerm },
    frontmatter: { title: searchTerm },
    headings: { value: searchTerm },
  });

If I console.log, title and slug I’ll get true for each of the three objects.

If I do the same for the for the headings value I’ll get an array for the headings, like, say if I input fish I’ll get one element in one of the arrays, like:

[false, false]
[false, true, true, false]
[false, false, false]

What I need to do, but cant work out how to right now is return true for the whole node, I think.

What are you thoughts?

I have made a codesandbox.io example for everyone to take a look at too.

If you are trying to return an array of the objects in data where frontmatter.title, fields.slug, or any of the values of the objects the headings array include the search term, the following could work using the example data you posted above.

function filterData(data, searchTerm) {
  return data.filter(({
    frontmatter: { title },
    headings,
    fields: { slug }
  }) => {
    const headingValues = headings.map(({ value }) => value);
    return [ title, ...headingValues, slug ]
      .some(value => value.toLowerCase().includes(searchTerm)); 
  });
}

const result = filterData(data, 'fish');

/*  result contains the following array.
[{
  "frontmatter": {
    "title": "Fish Shell"
  },
  "headings": [{
    "value": "Aliases",
    "depth": 2
  }, {
    "value": "Oh My Fish",
    "depth": 2
  }, {
    "value": "Use nvm with fish",
    "depth": 2
  }, {
    "value": "List out added aliases",
    "depth": 2
  }],
  "fields": {
    "slug": "/fish/"
  }
}]
*/
return data.filter(sheet => {
      return (
        sheet.headings.map(h =>
          h.value.toLowerCase().includes(filters.headings.value)
        )
      );
    });

Remember that a filter function should return a boolean. You’re using map which returns an array. If I understand what you’re trying to do, I believe that you want to use a reduce instead of map.

1 Like

@RandellDawson and @ArielLeslie thank you both!

@RandellDawson I think your solution will work but I will also use @ArielLeslie’s suggestion of using reduce in this section:

const headingValues = headings.map(({ value }) => value);

So that I’m only getting back the elements I want to show rather than the whole list, brilliant!

Thanks so much :pray:

Which specific elements are you wanting to show? Don’t you still need each entire object of the data array which has one of the matching property values?

1 Like

So in the data headings.value there are two instances of add in the bash and alfred objects:

headings: [
  {
    value: "Add custom search",
    depth: 2
  },

and

headings: [
  {
    value: "Add an alias",
    depth: 2
  },

How it is currently, the whole array is being returned whereas I’d only want to have the two values.

So, currently the results look like, this:

{
  "frontmatter": {
   "title": "Alfred"
  },
  "headings": [
   {
    "value": "Add custom search",
    "depth": 2
   },
   {
    "value": "Change the default search in Alfred",
    "depth": 2
   }
  ],
  "fields": {
   "slug": "/alfred/"
  }
 },
 {
  "frontmatter": {
   "title": "Bash"
  },
  "headings": [
   {
    "value": "Add an alias",
    "depth": 2
   },
   {
    "value": "Sort alphabetically 👌",
    "depth": 2
   },
   {
    "value": "Open the SSH agent each time you open a new terminal.",
    "depth": 2
   }
  ],
  "fields": {
   "slug": "/bash/"
  }
 }

Desired result is like:

{
  "frontmatter": {
   "title": "Alfred"
  },
  "headings": [
   {
    "value": "Add custom search",
    "depth": 2
   }
  ],
  "fields": {
   "slug": "/alfred/"
  }
 },
 {
  "frontmatter": {
   "title": "Bash"
  },
  "headings": [
   {
    "value": "Add an alias",
    "depth": 2
   }
  ],
  "fields": {
   "slug": "/bash/"
  }
 }

Got it. You want to limit the return of the heading values to only the ones which match too.

1 Like

@spences10 In the 4th element of the headings array of the “Fish Shell” page, the word “added” appears. If the search term was “add”, then technically that page should be captured also. If you only want to capture the exact word “add”, then you would need to use a regular expression instead of the includes method.

Assuming you are OK adding the one with “added” when the search term is “add”, then the following revised version (which uses reduce) of my original reply should work:

function filterData(data, searchTerm) {
  const hasSearchTerm = (value) => value
    .toLowerCase()
    .includes(searchTerm);

  return data.reduce((matches, page) => {
    const headings = page.headings
      .filter(({ value }) => hasSearchTerm(value));
    return [page.frontmatter.title, page.fields.slug]
      .some(hasSearchTerm) || headings.length
      ? matches.concat(Object.assign(page, { headings }))
      : matches
  }, []);
}

EDIT: If you want to use the regex version I mentioned above, then you would only change the hasSearchTerm function.

const hasSearchTerm = value => (new RegExp(`\\b${searchTerm}\\b`, 'i')).test(value);

Brilliant! Thanks so much @RandellDawson :pray: