2
\$\begingroup\$

This component is meant to take in an array of available options and provide the user an easy way to choose multiple options and filter on the available options. Each time the selected options are changed/updated, those options are console logged.

I'm hoping that someone could provide any criticism on my current code. I want to make sure that the code is as simple and clean as it could be, while keeping all logic outside of the JSX. Photo of component at bottom of post. If you notice any complexities that could be replaced with some alternative design, i'd be interested to know.

import React, { useState, useEffect } from 'react';
import { Icon, MyInput } from '@mylib/library';
import './MultiSelection.scss';

    export default function MultiSelection({
      initialOptions=['A', 'B', 'C', 'D', 'Aaron', 'Britney', 'Carter'], 
      componentLabel='Select Contract Types',
      availableLabel='Available Types',
      selectedLabel='Selected Types',
      filterLabel='Filter available Types',
      showFilter=true,
    }) {
      const [options, setOptions] = useState([]);
      const [availableOptions, setAvailableOptions] = useState([]);
      const [selectedOptions, setSelectedOptions] = useState([]);
      const [availableFilterValue, setAvailableFilterValue] = useState('');

      // Convert options into a manageable structure
      // on initial render
      useEffect(() => {
        let tempOptions = [];
        initialOptions.forEach(option => tempOptions.push({'text': option, 'isAvailable': true, 'shouldShow': true}));
        setOptions(tempOptions);
      }, []);

      // Separates options into a available and selected array
      // in order to keep logic out of the JSX
      useEffect(() => {
        console.log('Build available and selected options');
        const availableOptionsText = [];
        const selectedOptionsText = [];

        const available = options.filter(option => option.isAvailable && option.shouldShow);
        const selected = options.filter(option => !option.isAvailable);

        available.forEach(option => availableOptionsText.push(option.text));
        selected.forEach(option => selectedOptionsText.push(option.text));

        setAvailableOptions(availableOptionsText);
        setSelectedOptions(selectedOptionsText);
      }, [options]);

      const updateStateOfOption = clickedOption => {
        const tempOptions = [...options];
        tempOptions.forEach(option => {
          if(option.text === clickedOption){
            option.isAvailable = !tempOptions.find(option => option.text === clickedOption).isAvailable
          }
        });
        setOptions(tempOptions);
      }

      const handleFilterOnChange = e => {
        console.log('Hit handleFilterOnChange');
        setAvailableFilterValue(e.target.value);
      }

      const handleFilterOnBlur = e => {
        console.log('Hit handleFilterOnBlur');
        setAvailableFilterValue(e.target.value);
      }

      // Modify which options' shouldShow property depending on filter
      // Only the available options will care about the shouldShow property
      useEffect(() => {
        const tempOptions = [...options];
        tempOptions.forEach(option => {
          if(availableFilterValue === ''){
            option.shouldShow = true;
          } else {
            if(!option.text.toLowerCase().startsWith(availableFilterValue.toLocaleLowerCase())){
              option.shouldShow = false;
            } else {
              option.shouldShow = true;
            }
          }
        });
        if(tempOptions.length !== 0){
          setOptions(tempOptions);
        }
      }, [availableFilterValue]);

      const row = (text, key, isAvailableColumn) => {
        return(
          <div key={`${isAvailableColumn ? 'available' : 'selected'}-button-row-${key}`} className='button-row-container' onClick={() => updateStateOfOption(text)}>
            <Icon id='add' iconClass={`${isAvailableColumn ? 'icon-add' : 'icon-trashcan'}`}/>
            <div>{text}</div>
          </div>
        )
      }

      return(
        <div className='multi-selection'>
          <label className='dropdown__floatingLabel multi-selection-label'>{componentLabel}</label>
          {showFilter && 
            <div className='-row page-row'>
              <div className='col col-xs-6 col-sm-6 col-md-6 col-lg-6'>
                <MyInput
                  name='availableTypesFilter'
                  id='availableTypesFilter'
                  autofocus={false}
                  fieldValue={availableFilterValue ? availableFilterValue : ''}
                  errorMessage={''}
                  label={filterLabel}
                  allowAutoComplete={false}
                  changed={handleFilterOnChange}
                  onBlur={handleFilterOnBlur}
                />
              </div>
            </div>
          }
          <div className={`label-container-${showFilter ? 'with-filter' : 'without-filter'}`}>
            <p className='dropdown__floatingLabel'>{availableLabel}</p>
            <p className='dropdown__floatingLabel'>{selectedLabel}</p>
          </div>
          <div className='options-container'>
            <div className='available-options'>
              {availableOptions.map((option, key) => row(option, key, true))}
            </div>

            <div className='selected-options'>
              {selectedOptions.map((option, key) => row(option, key, false))}
            </div>
          </div>
        </div>
      )
    }

Multi selection component with filter

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

There are a few places in the code where you have an array and you want to construct another array from it. Rather than forEach followed by push, it's more appropriate to use Array.prototype.map to transform one array into another. For example:

useEffect(() => {
    let tempOptions = [];
    initialOptions.forEach(option => tempOptions.push({ 'text': option, 'isAvailable': true, 'shouldShow': true }));
    setOptions(tempOptions);
}, []);

can turn into

useEffect(() => {
    const newOptions = initialOptions.map(text => ({ text, isAvailable: true, shouldShow: true }));
    setOptions(newOptions);
}, []);

As you can see above, properties that are valid identifiers don't need to be quoted (and probably shouldn't, to cut down on noise), and by planning ahead with variable names, you can sometimes use shorthand properties rather than having to list both the key and its value. In addition, you should always use const to declare variables when possible - don't use let unless you wish to warn the reader of the code that you may have to reassign the variable later.

You can use .map in the second useEffect call too, to great effect:

useEffect(() => {
    console.log('Build available and selected options');
    const optionToText = option => option.text;
    const availableOptionsText = options
        .filter(option => option.isAvailable && option.shouldShow)
        .map(optionToText);
    const selectedOptionsText = options
        .filter(option => !option.isAvailable)
        .map(optionToText);

    setAvailableOptions(availableOptionsText);
    setSelectedOptions(selectedOptionsText);
}, [options]);

In updateStateOfOption, you are mutating the isAvailable property of one of the options array objects:

const updateStateOfOption = clickedOption => {
    const tempOptions = [...options];
    tempOptions.forEach(option => {
        if (option.text === clickedOption) {
            option.isAvailable = !tempOptions.find(option => option.text === clickedOption).isAvailable
        }
    });
    setOptions(tempOptions);
}

Spreading an array only creates a shallow copy - each element in the original array is still a reference to one of the elements in the new array, so mutating an element in the new array changes an element in the old array too. Since this is React, such mutation should be avoided. Instead, use findIndex to find the index of the matching option, then create a new array by spreading the parts of the array before the option, then declaring a new (modified) object, then spreading the parts of the array after the option:

const updateStateOfOption = clickedOption => {
    const clickedIndex = options.findIndex(option => option.text === clickedOption);
    const newOptions = [
        ...options.slice(0, clickedIndex),
        { ...options[clickedIndex], isAvailable: !options[clickedIndex].isAvailable },
        ...options.slice(clickedIndex + 1),
    ];
    setOptions(newOptions);
}

You have the same issue in the next useEffect too. This time, you can change every element of the array by using .map and spreading the previous option into an object, then assigning the new shouldShow property:

useEffect(() => {
    const newOptions = options.map(option => ({
        ...option,
        shouldShow: availableFilterValue === '' || option.text.toLowerCase().startsWith(availableFilterValue.toLocaleLowerCase())
    }));
    if (newOptions.length !== 0) {
        setOptions(newOptions);
    }
}, [availableFilterValue]);

You can use || instead of the conditional operator here, if you want:

fieldValue={availableFilterValue ? availableFilterValue : ''}

can be

fieldValue={availableFilterValue || ''}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.