javascripthtmloptgroup

How to segment an string/array in <optgroup> and <option> divided by a specific character?


I need to divide my array into groups and options in a <select>. I don't know how to do it dynamically, since my array contains strings divided by :, which means it can have the same groups with options and groups with groups inside it.

My actual problem is how to get the groups and options by the string.

I have an array that's returning this specific data:

const data = [
    { id: 1, raw_name: "vehicle:car:ford"},
    { id: 2, raw_name: "vehicle:motorbike:honda"},
    { id: 3, raw_name: "none"},
    { id: 4, raw_name: "vehicle:car:fiat"},
    { id: 5, raw_name: "vehicle:car:bmw"},
    { id: 6, raw_name: "vehicle:motorbike:suzuki"},
    { id: 7, raw_name: "vehicle:plane:jets:gulfstream"},
    { id: 8, raw_name: "vehicle:bike"}
  ];

I've tried to split raw_name by : and do a for loop to create the group and the options with the result, but unfortunately it does not organize by itself.

My code:

<select id="bankselect">

</select>

<script>
    const select = document.getElementById("bankselect");
    let optGroup;
    data.forEach((element => {
        for (let index = 0; index < element.opt.length; index++) {
            if (index + 1 === element.opt.length) {
                const opt = document.createElement("option");
                opt.value = element.opt[index];
                opt.innerText = element.opt[index];
                optGroup.append(opt);
            } else {
                optGroup = document.createElement("optgroup");
                optGroup.label = element.opt[index];
            }
            select.append(optGroup);
        }
    }))
</script>

My result:

enter image description here

My expected final outcome would be this:

<select id="bankselect">
    <option>none</option>
    <optgroup label="vehicle">
      <option>bike</option>
      <optgroup label="car">
        <option>ford</option>
        <option>fiat</option>
        <option>bmw</option>
      </optgroup>
      <optgroup label="motorbike">
        <option>honda</option>
        <option>suzuki</option>
      </optgroup>
      <optgroup label="plane">
        <optgroup label="jets">
          <option>gulfstream</option>
         </optgroup>
      </optgroup>
    </optgroup>
  </select>

enter image description here


Solution

  • First, turn that flat data into nested data, and then use the nested data to recursively build your <select> content:

    const data = [
      { id: 1, raw_name: "vehicle:car:ford"},
      { id: 2, raw_name: "vehicle:motorbike:honda"},
      { id: 3, raw_name: "none"},
      { id: 4, raw_name: "vehicle:car:fiat"},
      { id: 5, raw_name: "vehicle:car:bmw"},
      { id: 6, raw_name: "vehicle:motorbike:suzuki"},
      { id: 7, raw_name: "vehicle:plane:jets:gulfstream"},
      { id: 8, raw_name: "vehicle:bike"}
    ];
    
    // First, give that data a sensible ordering
    data.sort((a,b) => a.raw_name < b.raw_name ? -1 : 1);
    
    // Then convert it to a tree form
    const nested = convertFlatToNested(data);
    
    // And then convert that tree to a <select>
    const select = createPulldownMenu(nested);
    
    // And put that <select> on the page
    document.body.appendChild(select);
    
    /**
     * Convert the flat data to a nested tree form,
     * by splitting the name on : and inserting the
     * resulting "path" as an object property chain.
     */
    function convertFlatToNested(data) {
      const top = {};
      data.forEach(({ id, raw_name })=> {
        let level = top, term, terms = raw_name.split(`:`);
        while (terms.length) {
          term = terms.shift();
          level[term] ??= {};
          level = level[term];
        }
      });
      return top;
    }
    
    /**
     * Create a select element, and fill it with options.
     */
    function createPulldownMenu(data) {
      const select = document.createElement(`select`);
      return addOptions(select, data);
    }
    
    /**
     * Take a nested data object, and convert any
     * key that maps to an empty object to an <option>,
     * and any key that maps to an object with values
     * to an <optgroup>, then recursively filling that
     * optgroup.
     */
    function addOptions(parent, data) {
      Object.entries(data).forEach(([key, value]) => {
        if (Object.keys(value).length === 0) {
          const child = document.createElement(`option`);
          child.name = child.value = child.textContent = key;
          parent.appendChild(child);
        } else {
          const child = document.createElement(`optgroup`);
          child.label = key;
          parent.appendChild(child);
          addOptions(child, value);
        }
      });
      return parent;
    }

    This ends up generating the following (bad!) nesting:

    <select>
      <option value="none">none</option>
      <optgroup label="vehicle">
        <option value="bike">bike</option>
        <optgroup label="car">
          <option value="bmw">bmw</option>
          <option value="fiat">fiat</option>
          <option value="ford">ford</option>
        </optgroup>
        <optgroup label="motorbike">
          <option value="honda">honda</option>
          <option value="suzuki">suzuki</option>
        </optgroup>
        <optgroup label="plane">
          <optgroup label="jets">
            <option value="gulfstream">gulfstream</option>
          </optgroup>
        </optgroup>
      </optgroup>
    </select>
    

    Because bear in mind: you can't nest <optgroup> elements. That is to say, you "can", but it's invalid HTML and while browsers will do something reasonably sensible with that invalid HTML, it's still invalid HTML and you should reconsider how to show this data, because you're basically using the wrong UI element for this data.