Search code examples
javascriptarraysgroup-bylodash

Use lodash to group array into tree “children” structure


I am trying to create a tree using the array of json objects below. I want to set a category as a child of another category if its sub_category_1 and I want the sub_category_2 also to be a children of that sub_category_1

[
  { 
    category: 'CS',
    sub_category_1: null,
    sub_category_2: null
  }, {
    category: 'TS',
    sub_category_1: null,
    sub_category_2: null
  }, {
    category: 'CS',
    sub_category_1: 'Accuracy',
    sub_category_2: null
  }, {
    category: 'CS',
    sub_category_1: 'Accuracy',
    sub_category_2: 'Members Accuracy'
  }
]

I've attempted to chain lodash methods like groupBy and transform, but am having a hard time getting to the resulting format I require.

Here's a skeleton of what direction I was heading:

_(arr).groupBy('category').transform(function(result, obj, type) {
 return result.push({
    name: type,
    children: obj
 });
}).value();

Expected output :

[{
    category: 'CS',
        children: [
            {
                category: 'Accuracy',
                children: [
                    {
                        category: 'Members Accuracy'
                    }
                    ...
                ]
            }
            ...
        ]
    }, {
    category: 'TS'
 }]

Solution

  • If you are certain that the category descriptions will come in the correct order (main, then sub-category, then sub-sub-category) you can use vanilla JavaScript to build up the tree:

    // data constructor for a category
    const Category = (category, children = []) =>
      ({ category, children });
    
    // 1. Ensure only the `sub_category_n` values are taken and
    // 2. they are in ascending order and
    // 3. any null values are removed
    const subCategories = obj => 
      Object.entries(obj)
        .filter(([key]) => /sub_category_\d+/.test(key))
        .filter(([, value]) => value != null)
        .sort(([keyA], [keyB]) => keyA.localeCompare(keyB, {numeric: true}))
        .map(([, subCategoryName]) => subCategoryName);
    
    // take a category descriptor and turn it into full path of category names:
    // { category: "X", sub_category_1: "Y", sub_category_1: "Z" }
    // turns into ["X", "Y", "Z"]
    const toPath = ({category, ...sub}) =>
      [category, ...subCategories(sub)];
    
    // create a key from the path elements separated by Unit Separator characters
    // or return a unique symbol for no parent
    const toKey = (path) =>
      path.join("\u241F") || Symbol("no parent");
      
    const toHierarchy = arr => {
      const result = [];
      //keep track of categories that have been created
      const seen = new Map();
        
      for (const item of arr) {
        const path = toPath(item);
        //last item in the path is what we want to create
        const childName = path[path.length-1];
        
        //parent key is the path without the last item
        const parentKey = toKey(path.slice(0, -1));
        //the child key is the full path
        const childKey = toKey(path)
        
        //skip if it's seen
        if (seen.has(childKey))
          continue;
          
        const child = Category(childName);
        seen.set(childKey, child);
    
        //if there is no parent, add as a main category. Otherwise as a child
        const parentList = seen.get(parentKey)?.children ?? result;
        parentList.push(child);
      }
      
      return result;
    }
    
    
    const input = [
      { 
        category: 'CS',
        sub_category_1: null,
        sub_category_2: null
      }, {
        category: 'TS',
        sub_category_1: null,
        sub_category_2: null
      }, {
        category: 'CS',
        sub_category_1: 'Accuracy',
        sub_category_2: null
      }, {
        category: 'CS',
        sub_category_1: 'Accuracy',
        sub_category_2: 'Members Accuracy'
      }
    ];
    
    console.log(toHierarchy(input));
    .as-console-wrapper { max-height: 100% !important; }

    If the list of categories might be unordered, you can analyse the whole path and create any missing categories, not just one per object:

    const Category = (category, children = []) =>
      ({ category, children });
    
    const subCategories = obj => 
      Object.entries(obj)
        .filter(([key]) => /sub_category_\d+/.test(key))
        .filter(([, value]) => value != null)
        .sort(([keyA], [keyB]) => keyA.localeCompare(keyB, {numeric: true}))
        .map(([, subCategoryName]) => subCategoryName);
        
    const toPath = ({category, ...sub}) =>
      [category, ...subCategories(sub)];
      
    const toKey = (path, key = []) =>
      path.concat(key).join("\u241F") || Symbol("no parent");
    
    const toHierarchy = arr => {
      const result = [];
      const seen = new Map()
        
      for (const item of arr) {
        const path = toPath(item);
        for (const [index, childName] of path.entries()) {
          const parentKey = toKey(path.slice(0, index));
          const childKey = toKey(path.slice(0, index+1));
          if (!seen.has(childKey)) {
            const child = Category(childName);
            seen.set(childKey, child);
            
            const parentList = seen.get(parentKey)?.children ?? result;
            parentList.push(child);
          }
        }
      }
      
      return result;
    }
    
    
    const input = [
      {
        category: 'CS',
        sub_category_1: 'Accuracy',
        sub_category_2: null
      }, { 
        category: 'CS',
        sub_category_1: null,
        sub_category_2: null
      }, {
        category: 'TS',
        sub_category_1: null,
        sub_category_2: null
      }, {
        category: 'CS',
        sub_category_1: 'Accuracy',
        sub_category_2: 'Members Accuracy'
      }
    ];
    
    console.log(toHierarchy(input));
    .as-console-wrapper { max-height: 100% !important; }