简体   繁体   中英

How can I create a search with multiple, optional, parameters using JavaScript?

What I currently have "works", however each parameter depends on the last. My goal was to allow the user to use any amount of the search fields to filter through posts, but can't seem to be able to wrap my head around how to actually execute it.

Code for the search fields:

import React from "react";
import { Input, DropDown } from "../Form";
import "./index.css";

function Sidebar(props) {
  return (
    <div className="sidebar-container">
      <p>Search Posts: {props.carMake}</p>
      <div className="field-wrap">
        <Input
          value={props.carMake}
          onChange={props.handleInputChange}
          name="carMake"
          type="text"
          placeholder="Manufacturer"
        />
      </div>
      <div className="field-wrap">
        <Input
          value={props.carModel}
          onChange={props.handleInputChange}
          disabled={!props.carMake}
          name="carModel"
          type="text"
          placeholder="Model"
        />
      </div>
      <div className="field-wrap">
        <Input
          disabled={!props.carModel || !props.carMake}
          value={props.carYear}
          onChange={props.handleInputChange}
          name="carYear"
          type="text"
          placeholder="Year"
        />
      </div>
      <div className="field-wrap">
        <DropDown
          //disabled={!props.carModel || !props.carMake || !props.carYear}
          value={props.category}
          onChange={props.handleInputChange}
          name="category"
          type="text"
          id="category"
        >
          <option>Select a category...</option>
          <option>Brakes</option>
          <option>Drivetrain</option>
          <option>Engine</option>
          <option>Exhaust</option>
          <option>Exterior</option>
          <option>Intake</option>
          <option>Interior</option>
          <option>Lights</option>
          <option>Suspension</option>
          <option>Wheels & Tires</option>
        </DropDown>
      </div>
    </div>
  );
}

export default Sidebar;

Here is the code for the parent component (Where the data is actually filtered):

import React, { Component } from 'react';
import Sidebar from '../../components/Sidebar';
import API from '../../utils/API';
import PostContainer from '../../components/PostContainer';
import { withRouter } from 'react-router';
import axios from 'axios';
import './index.css';

class Posts extends Component {
  constructor(props) {
    super(props);
    this.state = {
      posts: [],
      carMake: '',
      carModel: '',
      carYear: '',
      category: 'Select A Category...'
    };
    this.signal = axios.CancelToken.source();
  }

  componentDidMount() {
    API.getAllPosts({
      cancelToken: this.signal.token
    })
      .then(resp => {
        this.setState({
          posts: resp.data
        });
      })
      .catch(function(error) {
        if (axios.isCancel(error)) {
          console.log('Error: ', error.message);
        } else {
          console.log(error);
        }
      });
  }

  componentWillUnmount() {
    this.signal.cancel('Api is being canceled');
  }

  handleInputChange = event => {
    const { name, value } = event.target;
    this.setState({
      [name]: value
    });
  };

  handleFormSubmit = event => {
    event.preventDefault();
    console.log('Form Submitted');
  };

  render() {
    const { carMake, carModel, carYear, category, posts } = this.state;

    const filterMake = posts.filter(
      post => post.carMake.toLowerCase().indexOf(carMake.toLowerCase()) !== -1
    );
    const filterModel = posts.filter(
      post => post.carModel.toLowerCase().indexOf(carModel.toLowerCase()) !== -1
    );
    const filterYear = posts.filter(
      post => post.carYear.toString().indexOf(carYear.toString()) !== -1
    );
    const filterCategory = posts.filter(
      post => post.category.toLowerCase().indexOf(category.toLowerCase()) !== -1
    );

    return (
      <div className='container-fluid'>
        <div className='row'>
          <div className='col-xl-2 col-lg-3 col-md-4 col-sm-12'>
            <Sidebar
              carMake={carMake}
              carModel={carModel}
              carYear={carYear}
              category={category}
              handleInputChange={this.handleInputChange}
              handleFormSubmit={event => {
                event.preventDefault();
                this.handleFormSubmit(event);
              }}
            />
          </div>
          <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
            {carMake && carModel && carYear && category
              ? filterCategory.map(post => (
                  <PostContainer post={post} key={post.id} />
                ))
              : carMake && carModel && carYear
              ? filterYear.map(post => (
                  <PostContainer post={post} key={post.id} />
                ))
              : carMake && carModel
              ? filterModel.map(post => (
                  <PostContainer post={post} key={post.id} />
                ))
              : carMake
              ? filterMake.map(post => (
                  <PostContainer post={post} key={post.id} />
                ))
              : posts.map(post => <PostContainer post={post} key={post.id} />)}
          </div>
        </div>
      </div>
    );
  }
}

export default withRouter(Posts);

The data returned from the API is in the form of an array of objects as follows:

[{

"id":4,
"title":"1995 Toyota Supra",
"desc":"asdf",
"itemImg":"https://i.imgur.com/zsd7N8M.jpg",
"price":32546,
"carYear":1995,
"carMake":"Toyota",
"carModel":"Supra",
"location":"Phoenix, AZ",
"category":"Exhaust",
"createdAt":"2019-07-09T00:00:46.000Z",
"updatedAt":"2019-07-09T00:00:46.000Z",
"UserId":1

},{

"id":3,
"title":"Trash",
"desc":"sdfasdf",
"itemImg":"https://i.imgur.com/rcyWOQG.jpg",
"price":2345,
"carYear":2009,
"carMake":"Yes",
"carModel":"Ayylmao",
"location":"asdf",
"category":"Drivetrain",
"createdAt":"2019-07-08T23:33:04.000Z",
"updatedAt":"2019-07-08T23:33:04.000Z",
"UserId":1

}]

As can be seen above, I had attempted to just comment out the dropdown's "Disabled" attribute, but that causes it to stop working as a filter completely, and returns all results no matter the selection. This is caused by my mess of ternary operators checking for each filter. Is there a better way I could be doing this?

Even though the answer from @Nina Lisitsinskaya is correct, I would not have a huge list of if s and have all just done with a filter concatenation.

That way adding another way to filter is easier and quite readable. The solution though is similar.

render() {
    const { carMake = '', carModel = '', carYear = '', category = '', posts } = this.state;

    let filtered = [...posts];

        filtered = filtered
            .filter(post => post.carMake.toLowerCase().indexOf(carMake.toLowerCase()) !== -1)
            .filter(post => post.carModel.toLowerCase().indexOf(carModel.toLowerCase()) !== -1)
            .filter(post => post.carYear.toString().indexOf(carYear.toString()) !== -1)
            .filter(post => post.category.toLowerCase().indexOf(category.toLowerCase()) !== -1)

    ...
}

Of course then later you want to use filtered similarly to this in the JSX expression, otherwise there is nothing to show.

  ...

  <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
    {filtered.map(post => <PostContainer post={post} key={post.id} />)}
  </div>

There is no need to use terrible huge ternary operators in the JSX at all. First you can filter the collection sequentially with each filter:

render() {
  const { carMake, carModel, carYear, category, posts } = this.state;

  let filtered = [...posts];

  if (carMake) {
    filtered = filtered.filter(post => post.carMake.toLowerCase().indexOf(carMake.toLowerCase()) !== -1);
  }

  if (carModel) {
    filtered = filtered.filter(post => post.carModel.toLowerCase().indexOf(carModel.toLowerCase()) !== -1);
  }

  if (carYear) {
    filtered = filtered.filter(post => post.carYear.toString().indexOf(carYear.toString()) !== -1);
  }

  if (category) {
    filtered = filtered.filter(post => post.category.toLowerCase().indexOf(category.toLowerCase()) !== -1);
  }

  ...

Then you can just use filtered in the JSX expression:

  ...

  <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
    {filtered.map(post => <PostContainer post={post} key={post.id} />)}
  </div>

You should never do such calculations in your render method - it should work with clean calculated state/props . Basically filtering should happen on your backend , but if you want to filter on frontend you should move filtering logic to service method, something like this:

function getPosts({ cancelToken, filter }) {
    // first fetch your posts
    // const posts = ...

    const { carMake, carModel, carYear, category } = filter;

    let filtered = [];
    for (let i = 0; i < posts.length; i++) {
        const post = posts[i];

        let add = true;
        if (carMake && add) {
            add = post.carMake.toLowerCase().indexOf(carMake.toLowerCase()) !== -1;
        }

        if (carModel && add) {
            add = post.carModel.toLowerCase().indexOf(carModel.toLowerCase()) !== -1;
        }

        if (carYear && add) {
            add = post.carYear.toLowerCase().indexOf(carYear.toLowerCase()) !== -1;
        }

        if (category && add) {
            add = post.category.toLowerCase().indexOf(category.toLowerCase()) !== -1;
        }

        if (add) {
            filtered.push(post);
        }
    }

    return filtered;
}

For loop is used because with this approach you iterate posts only once. If you are not going to change your service method, at least add this post filtering inside of your resolved promise in componentDidMount , but I stronlgy advise not to do such things in render method.

Try the following (instructions in code comments):

// 1. don't default category to a placeholder.
// If the value is empty it will default to your empty option,
// which shows the placeholder text in the dropdown.
this.state = {
  posts: [],
  carMake: '',
  carModel: '',
  carYear: '',
  category: ''
}

// 2. write a method to filter your posts and do the filtering in a single pass.
getFilteredPosts = () => {
  const { posts, ...filters } = this.state
  // get a set of filters that actually have values
  const activeFilters = Object.entries(filters).filter(([key, value]) => !!value)
  // return all posts if no filters
  if (!activeFilters.length) return posts

  return posts.filter(post => {
    // check all the active filters
    // we're using a traditional for loop so we can exit as soon as the first check fails
    for (let i; i > activeFilters.length; i++) {
      const [key, value] = activeFilters[i]
      // bail on the first failure
      if (post[key].toLowerCase().indexOf(value.toLowerCase()) < 0) {
        return false
      }
    }
    // all filters passed
    return true
  })
}

// 3. Simplify render
render() {
  // destructure filters so you can just spread them into SideBar
  const { posts, ...filters } = this.state
  const filteredPosts = this.getFilteredPosts()

  return (
    <div className='container-fluid'>
      <div className='row'>
        <div className='col-xl-2 col-lg-3 col-md-4 col-sm-12'>
          <Sidebar
            {...filters}
            handleInputChange={this.handleInputChange}
            handleFormSubmit={this.handleFormSubmit}
          />
        </div>
        <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
          {filteredPosts.map(post => <PostContainer post={post} key={post.id} />)}
        </div>
      </div>
    </div>
  )
}

Another thing to consider is that PostContainer is being passed a single prop post that is an object. You could probably simplify prop access quite a bit in that component if you spread that post object so it became the props:

{filteredPosts.map(post => <PostContainer key={post.id} {...post} />)}

Then in PostContainer , props.post.id would become props.id . The props api becomes simpler and the component will be easier to optimize (if that becomes necessary).

I think you can use Lodash _.filter collection method to help you:

Lodash documentation: https://lodash.com/docs/4.17.15#filter

Multiple inputs search

/*
 * `searchOption` is something like: { carMake: 'Yes', carYear: 2009 }
 */
function filterData(data = [], searchOption = {}) {
  let filteredData = Array.from(data); // clone data
  // Loop through every search key-value and filter them
  Object.entries(searchOption).forEach(([key, value]) => {
    // Ignore `undefined` value
    if (value) {
      filteredData = _.filter(filteredData, [key, value]);
    }
  });
  // Return filtered data
  return filteredData;
}

render method

    return (
      <div className='container-fluid'>
        <div className='row'>
          <div className='col-xl-2 col-lg-3 col-md-4 col-sm-12'>
            <Sidebar
              carMake={carMake}
              carModel={carModel}
              carYear={carYear}
              category={category}
              handleInputChange={this.handleInputChange}
              handleFormSubmit={event => {
                event.preventDefault();
                this.handleFormSubmit(event);
              }}
            />
          </div>
          <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
            {
              filterData(post, { carMake, carModel, carYear, category }).map(post => (
                <PostContainer post={post} key={post.id} />
              ))
             }
          </div>
        </div>
      </div>
    );
  }
}

Single input search

Or you can have one single search input field, and that will filter the whole data

function filterData(data = [], searchString = '') {
  return _.filter(data, obj => {
    // Go through each set and see if any of the value contains the search string
    return Object.values(obj).some(value => {
      // Stringify the value (so that we can search numbers, boolean, etc.)
      return `${value}`.toLowerCase().includes(searchString.toLowerCase()));
    });
  });
}

render method

    return (
      <div className='container-fluid'>
        <div className='row'>
          <div className='col-xl-2 col-lg-3 col-md-4 col-sm-12'>
            <input
              onChange={this.handleInputChange}
              value={this.state.searchString}
            />
          </div>
          <div className='col-xl-8 col-lg-7 col-md-6 col-sm-12 offset-md-1'>
            {filterData(posts, searchString).map(post => <PostContainer post={post} key={post.id} />)}
          </div>
        </div>
      </div>
    );
  }
}

This can be achieved with one single filter function.

render() {
  const { carMake, carModel, carYear, category, posts } = this.state;

  const filteredPost = posts.filter(post =>
    post.category.toLowerCase().includes(category.toLowerCase()) && 
    post.carYear === carYear &&
    post.carModel.toLowerCase().includes(carModel.toLowerCase()) && 
    post.carMake.toLowerCase().includes(carMake.toLowerCase())
  );

  return
    ...
    filteredPost.map(post => <PostContainer post={post} key={post.id} />);
}

Just one single loop through the list. No hassle of lots of ifs and else, or ternary operators. Just ordered way of filtering according to the need.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM