5

I have the following array (that's actually coming from a backend service):

const flat: Item[] = [
    { id: 'a', name: 'Root 1', parentId: null },
    { id: 'b', name: 'Root 2', parentId: null },
    { id: 'c', name: 'Root 3', parentId: null },

    { id: 'a1', name: 'Item 1', parentId: 'a' },
    { id: 'a2', name: 'Item 1', parentId: 'a' },

    { id: 'b1', name: 'Item 1', parentId: 'b' },
    { id: 'b2', name: 'Item 2', parentId: 'b' },
    { id: 'b2-1', name: 'Item 2-1', parentId: 'b2' },
    { id: 'b2-2', name: 'Item 2-2', parentId: 'b2' },
    { id: 'b3', name: 'Item 3', parentId: 'b' },

    { id: 'c1', name: 'Item 1', parentId: 'c' },
    { id: 'c2', name: 'Item 2', parentId: 'c' }
];

where Item is:

interface Item {
    id: string;
    name: string;
    parentId: string;
};

In order to be compatible with a component that displays a tree (folder like) view, it needs to be transformed into:

const treeData: NestedItem[] = [
    {
        id: 'a',
        name: 'Root 1',
        root: true,
        count: 2,
        children: [
          {
            id: 'a1',
            name: 'Item 1'
          },
          {
            id: 'a2',
            name: 'Item 2'
          }
        ]
    },
    {
        id: 'b',
        name: 'Root 2',
        root: true,
        count: 5, // number of all children (direct + children of children)
        children: [
          {
            id: 'b1',
            name: 'Item 1'
          },
          {
            id: 'b2',
            name: 'Item 2',
            count: 2,
            children: [
                { id: 'b2-1', name: 'Item 2-1' },
                { id: 'b2-2', name: 'Item 2-2' },
            ]
          },
          {
            id: 'b3',
            name: 'Item 3'
          },
        ]
    },
    {
        id: 'c',
        name: 'Root 3',
        root: true,
        count: 2,
        children: [
          {
            id: 'c1',
            name: 'Item 1'
          },
          {
            id: 'c2',
            name: 'Item 2'
          }
        ]
    }
];

where NestedItem is:

interface NestedItem {
    id: string;
    name: string;
    root?: boolean;
    count?: number;
    children?: NestedItem[];
}

All I've tried so far is something like:

// Get roots first
const roots: NestedItem[] = flat
    .filter(item => !item.parentId)
    .map((item): NestedItem => {
        return { id: item.id, name: item.name, root: true }
    });

// Add "children" to those roots
const treeData = roots.map(node => {
    const children = flat
        .filter(item => item.parentId === node.id)
        .map(item => {
            return { id: item.id, name: item.name }
        });
    return {
        ...node,
        children,
        count: node.count ? node.count + children.length : children.length
    }
});

But this only gets the first level of children, of course (direct children of root nodes). It somehow needs to be recursive, but I have no idea how to accomplish that.

Cory Kleiser
  • 1,969
  • 2
  • 13
  • 26
MrCroft
  • 3,049
  • 2
  • 34
  • 50

8 Answers8

4

Making no assumptions about the order of the flattened array or how deep a nested object can go:

Array.prototype.reduce is flexible enough to get this done. If you are not familiar with Array.prototype.reduce I recommend reading this. You could accomplish this by doing the following.

I have two functions that rely on recursion here: findParent and checkLeftOvers. findParent attempts to find the objects parent and returns true or false based on whether it finds it. In my reducer I add the current value to the array of left overs if findParent returns false. If findParent returns true I call checkLeftOvers to see if any object in my array of left overs is the child of the object findParent just added.

Note: I added { id: 'b2-2-1', name: 'Item 2-2-1', parentId: 'b2-2'} to the flat array to demonstrate that this will go as deep as you'd like. I also reordered flat to demonstrate that this will work in that case as well. Hope this helps.

const flat = [
    { id: 'a2', name: 'Item 1', parentId: 'a' },
    { id: 'b2-2-1', name: 'Item 2-2-1', parentId: 'b2-2'},
    { id: 'a1', name: 'Item 1', parentId: 'a' },
    { id: 'a', name: 'Root 1', parentId: null },
    { id: 'b', name: 'Root 2', parentId: null },
    { id: 'c', name: 'Root 3', parentId: null },
    { id: 'b1', name: 'Item 1', parentId: 'b' },
    { id: 'b2', name: 'Item 2', parentId: 'b' },
    { id: 'b2-1', name: 'Item 2-1', parentId: 'b2' },
    { id: 'b2-2', name: 'Item 2-2', parentId: 'b2' },
    { id: 'b3', name: 'Item 3', parentId: 'b' },
    { id: 'c1', name: 'Item 1', parentId: 'c' },
    { id: 'c2', name: 'Item 2', parentId: 'c' }
];

function checkLeftOvers(leftOvers, possibleParent){
  for (let i = 0; i < leftOvers.length; i++) {
    if(leftOvers[i].parentId === possibleParent.id) {
      delete leftOvers[i].parentId
      possibleParent.children ? possibleParent.children.push(leftOvers[i]) : possibleParent.children = [leftOvers[i]]
      possibleParent.count = possibleParent.children.length
      const addedObj = leftOvers.splice(i, 1)
      checkLeftOvers(leftOvers, addedObj[0])
    }
  }
}

function findParent(possibleParents, possibleChild) {
  let found = false
  for (let i = 0; i < possibleParents.length; i++) {
    if(possibleParents[i].id === possibleChild.parentId) {
      found = true
      delete possibleChild.parentId
      if(possibleParents[i].children) possibleParents[i].children.push(possibleChild)
      else possibleParents[i].children = [possibleChild]
      possibleParents[i].count = possibleParents[i].children.length
      return true
    } else if (possibleParents[i].children) found = findParent(possibleParents[i].children, possibleChild)
  } 
  return found;
}
 
 const nested = flat.reduce((initial, value, index, original) => {
   if (value.parentId === null) {
     if (initial.left.length) checkLeftOvers(initial.left, value)
     delete value.parentId
     value.root = true;
     initial.nested.push(value)
   }
   else {
      let parentFound = findParent(initial.nested, value)
      if (parentFound) checkLeftOvers(initial.left, value)
      else initial.left.push(value)
   }
   return index < original.length - 1 ? initial : initial.nested
 }, {nested: [], left: []})
 
console.log(nested)
Cory Kleiser
  • 1,969
  • 2
  • 13
  • 26
  • sorry for the late acceptance, I've stepped away from this for a while. Now back on it. Thank you! "Making no assumptions" is always the best assumption :) – MrCroft Feb 21 '19 at 16:51
  • Glad I could help. Happy coding :) – Cory Kleiser Feb 21 '19 at 16:54
  • Tried this but it seems the checkLeftovers function has a bug, it seems to 'lose' a child sometimes causing a missing node in the results. Seems to be something to do with mutating the array but couldn't pinpoint the issue. But I found a more concise solution here that works: https://stackoverflow.com/a/62938989/1339081 – Harry Palmer Dec 29 '22 at 09:12
2

You could a standard approach for a tree which takes a single loop and stores the relation between child and parent and between parent and child.

For having root properties you need an additional check.

Then take an iterative and recursive approach for getting count.

var data = [{ id: 'a', name: 'Root 1', parentId: null }, { id: 'b', name: 'Root 2', parentId: null }, { id: 'c', name: 'Root 3', parentId: null }, { id: 'a1', name: 'Item 1', parentId: 'a' }, { id: 'a2', name: 'Item 1', parentId: 'a' }, { id: 'b1', name: 'Item 1', parentId: 'b' }, { id: 'b2', name: 'Item 2', parentId: 'b' }, { id: 'b3', name: 'Item 3', parentId: 'b' }, { id: 'c1', name: 'Item 1', parentId: 'c' }, { id: 'c2', name: 'Item 2', parentId: 'c' }, { id: 'b2-1', name: 'Item 2-1', parentId: 'b2' }, { id: 'b2-2', name: 'Item 2-2', parentId: 'b2' },],
    tree = function (data, root) {

        function setCount(object) {
            return object.children
                ? (object.count = object.children.reduce((s, o) => s + 1 + setCount(o), 0))
                : 0;
        }                

        var t = {};
        data.forEach(o => {
            Object.assign(t[o.id] = t[o.id] || {}, o);
            t[o.parentId] = t[o.parentId] || {};
            t[o.parentId].children = t[o.parentId].children || [];
            t[o.parentId].children.push(t[o.id]);
            if (o.parentId === root) t[o.id].root = true;          // extra
        });

        setCount(t[root]);                                         // extra
        return t[root].children;
    }(data, null);

console.log(tree);
.as-console-wrapper { max-height: 100% !important; top: 0; }
Nina Scholz
  • 376,160
  • 25
  • 347
  • 392
1

Assuming that the flat items array is always sorted like in your case (parents nodes are sorted before children nodes). The code below should do the work.

First, I build the tree without the count properties using reduce on the array to build a map to keeping a track of every node and linking parents to children:

type NestedItemMap = { [nodeId: string]: NestedItem };

let nestedItemMap: NestedItemMap = flat
    .reduce((nestedItemMap: NestedItemMap, item: Item): NestedItemMap => {

        // Create the nested item
        nestedItemMap[item.id] = {
            id: item.id,
            name: item.name
        }

        if(item.parentId == null){
            // No parent id, it's a root node
            nestedItemMap[item.id].root = true;
        }
        else{
            // Child node
            let parentItem: NestedItem = nestedItemMap[item.parentId];

            if(parentItem.children == undefined){
                // First child, create the children array
                parentItem.children = [];
                parentItem.count = 0;

            }

            // Add the child node in it's parent children
            parentItem.children.push(
                nestedItemMap[item.id]
            );
            parentItem.count++;
        }

        return nestedItemMap;
    }, {});

The fact that the parents node always come first when reducing the array ensures that the parent node is available in the nestedItemMap when building the children.

Here we have the trees, but without the count properties:

let roots: NestedItem[] = Object.keys(nestedItemMap)
    .map((key: string): NestedItem => nestedItemMap[key])
    .filter((item: NestedItem): boolean => item.root);

To have the count properties filled, I would personally prefer performing a post-order depth-first search on the trees. But in your case, thanks to the node id namings (sorted, the parents nodes ids come first). You can compute them using:

let roots: NestedItem[] = Object.keys(nestedItemMap)
    .map((key: string): NestedItem => nestedItemMap[key])
    .reverse()
    .map((item: NestedItem): NestedItem => {
        if(item.children != undefined){
            item.count = item.children
                .map((child: NestedItem): number => {
                    return 1 + (child.count != undefined ? child.count : 0);
                })
                .reduce((a, b) => a + b, 0);
        }

        return item;
    })
    .filter((item: NestedItem): boolean => item.root)
    .reverse();

I just reverse the array to get all children first (like in a post-order DFS), and compute the count value. The last reverse is here just to be sorted like in your question :).

Rauks
  • 313
  • 3
  • 7
1

maybe this can help you, input is flat obj

nestData = (data, parentId = '') => {
return data.reduce((result, fromData) => {
  const obj = Object.assign({}, fromData);

  if (parentId === fromData.parent_id) {
    const children = this.nestData(data, fromData.id);
    if (children.length) {
      obj.children = children;
    } else {
      obj.userData = [];
    }
    result.push(obj);
  }
  return result;
}, []);

};

  • This is presumably taken from code for a different, but related, task. It would need to be cleaned up to solve this problem. Stand-alone, you would need to remove `this` from the recursive call to `nestData`. You would need to change `parent_id` to `parentId` and since `userData` has nothing to do with this question, the `else` block can be removed. The biggest miss, though, is that it doesn't include the `count` or `root` properties requested. `root` is easy, but `count` would take some work. A related approach is in the last update to [another answer](https://stackoverflow.com/a/21343433). – Scott Sauyet Aug 27 '20 at 14:08
0

If you have this much information in advance, you can build the tree backwards a lot easier. Since you know the shape of the input so well and their relationships are clearly defined you can easily separate this into multiple arrays and build this from the bottom up:

function buildTree(arr: Item[]): NestedItem[] {
  /* first split the input into separate arrays based on their nested level */
  const roots = arr.filter(r => /^\w{1}$/.test(r.id));
  const levelOne = arr.filter(r => /^\w{1}\d{1}$/.test(r.id));
  const levelTwo = arr.filter(r => /^\w{1}\d{1}-\d{1}$/.test(r.id));

  /* then create the bottom most level based on their relationship to their parent*/
  const nested = levelOne.map(item => {
    const children = levelTwo.filter(c => c.parentId === item.id);
    if (children) {
      return {
        ...item,
        count: children.length,
        children
      };
    } else return item;
  });

  /* and finally do the same with the root items and return the result */
  return roots.map(item => {
    const children = nested.filter(c => c.parentId === item.id);
    if (children) {
      return {
        ...item,
        count: children.length,
        children,
        root: true
      };
    } else return { ...item, root: true };
  });
}

This might not be the most performant solution, and it would need some tweaking depending on the expected shape of the input, but it is a clean and readable solution.

Robbie Milejczak
  • 5,664
  • 3
  • 32
  • 65
0

Another approach might look like this:

const countKids = (nodes) => 
  nodes.length + nodes.map(({children = []}) => countKids(children)).reduce((a, b) => a + b, 0)

const makeForest = (id, xs) => 
  xs .filter (({parentId}) => parentId == id)
     .map (({id, parentId, ...rest}) => {
       const kids = makeForest (id, xs)
       return {id, ...rest, ...(kids .length ? {count: countKids (kids), children: kids} : {})}
     })

const nest = (flat) =>
  makeForest (null, flat)
    .map ((node) => ({...node, root: true}))

const flat = [{id: "a", name: "Root 1", parentId: null}, {id: "b", name: "Root 2", parentId: null}, {id: "c", name: "Root 3", parentId: null}, {id: "a1", name: "Item 1", parentId: "a"}, {id: "a2", name: "Item 1", parentId: "a"}, {id: "b1", name: "Item 1", parentId: "b"}, {id: "b2", name: "Item 2", parentId: "b"}, {id: "b2-1", name: "Item 2-1", parentId: "b2"}, {id: "b2-2", name: "Item 2-2", parentId: "b2"}, {id: "b3", name: "Item 3", parentId: "b"}, {id: "c1", name: "Item 1", parentId: "c"}, {id: "c2", name: "Item 2", parentId: "c"}]

console .log (nest (flat))
.as-console-wrapper {min-height: 100% !important; top: 0}

The main function (makeForest) finds all the children whose ids match the target (initially null) and then recursively does the same with those children's ids.

The only complexity here is in not including count or children if the children for a node is empty. If including them is not a problem, then this can be simplified.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
0
this.treeData = this.buildTreeData(
    flat.filter(f => !f.parentId), flat
);

private buildTreeData(datagroup: Item[], flat: Item[]): any[] {
    return datagroup.map((data) => {
        const items = this.buildTreeData(
            flat.filter((f) => f.parentId === data.id), flat
      );
      return {
          ...data,
          root: !data.parentId,
          count: items?.length || null
          children: items,
      };
  });
}
timdo
  • 1
0

Hi i tried the accepted answer by Cody and ran into some problems when data wasn't sorted and for nested data with level>2

  1. in this sandbox: https://codesandbox.io/s/runtime-dew-g48sk?file=/src/index.js:1875-1890 i just changed the order a bit (id=3 was moved to the end of the list), see how in the console we now get that c has only 1 child

  2. I had another problem where parents couldn't be found, because in findParent function the found var was reseted to false if the function was called recursivly with a first argument being an array longer than 1 (e.g. finding a parent for id=21 in:

    {id: 1,parentId: null, children: [
        {
          id: 10,
          parentId: 1,
          children: []
        },
        {
          id: 11,
          parentId: 1,
          children: [{
            id: 21...
          }]
        }
    ]}
    

    would fail

anyway i think the flow itself was good just needed some minor fixes and renames, so here is what's worked for me, I removed some properties that I didn't use (like counter) and added some of my own (like expanded) but it obviously shouldn't matter at all, also im using TS (but i changed all my types to any):

class NestService {
  public nestSearchResultsToTree(flatItemsPath: any[]) {
    const nested = flatItemsPath.reduce(
      (
        initial: { nested: any[]; left: any[] },
        value: any,
        index: number,
        original: any
      ) => {
        if (value.parentId === null) {
          if (initial.left.length) this.checkLeftOvers(initial.left, value);
          initial.nested.push(value);
        } else {
          const parentFound = this.findParent(initial.nested, value);
          if (parentFound) this.checkLeftOvers(initial.left, value);
          else initial.left.push(value);
        }
        return index < original.length - 1 ? initial : initial.nested;
      },
      { nested: [], left: [] }
    );
    return nested;
  }

  private checkLeftOvers(leftOvers: any[], possibleParent: any) {
    for (let i = 0; i < leftOvers.length; i++) {
      const possibleChild = leftOvers[i];
      if (possibleChild.id === possibleParent.id) continue;
      if (possibleChild.parentId === possibleParent.id) {
        possibleParent.children
          ? possibleParent.children.push(possibleChild)
          : (possibleParent.children = [possibleChild]);
        possibleParent.expanded = true;
        possibleParent.isFetched = true;
        this.checkLeftOvers(leftOvers, possibleChild);
      }
    }
  }

  private findParent(
    possibleParents: any,
    child: any,
    isAlreadyFound?: boolean
  ): boolean {
    if (isAlreadyFound) return true;
    let found = false;
    for (let i = 0; i < possibleParents.length; i++) {
      const possibleParent = possibleParents[i];
      if (possibleParent.id === child.parentId) {
        possibleParent.expanded = true;
        possibleParent.isFetched = true;
        found = true;
        if (possibleParent.children) possibleParent.children.push(child);
        else possibleParent.children = [child];
        return true;
      } else if (possibleParent.children)
        found = this.findParent(possibleParent.children, child, found);
    }
    return found;
  }
}
oriRas
  • 1
  • 1