2/12/2020

Office UI Fabric React Grouped DetailsList - Display Tree-like Hierarchy

This blog post describes how to implement tree-like hierarchy using Office UI Fabric React (OUIFR) Grouped DetailsList component.
We'll be working with a hierarchy similar to the one displayed on the image below:

TL;DR

Office UI Fabric React (OUIFR) DetailsList ignores (doesn't render) items for the group if it (the group) has subgroups. As a result for a tree-like hierarchy - some of the items could be lost.
Jump to solution.

The Problem

We need to display a hierarchical grid, or grouped grid where each node (group) can have child nodes as well as leaf items.
For the hierarchy from the picture above we want to see something like this:

So, we have a root level node code that contains leaf item index.ts and two subgroups with their own items: components and webparts.
As we're developing SharePoint Framework solution (of course - this blog is about O365 and SharePoint =)) it would be great to use OUIFR DetailsList component to display grouped grid with a header.

Initial Implementation

Initial implementation for our component will be pretty simple.
We need to define our leaf item interface:
export interface IItem {
  key: string;
  title: string;
}
And our node interface:
export interface INode extends IItem {
  items?: IItem[];
  children?: INode[];
}
Using these two interfaces we can define our hierarchy like that:
private readonly _nodes: INode[] = [{
    key: 'code',
    title: 'code',
    items: [{
      key: 'index',
      title: 'index.ts'
    }],
    children: [{
      key: 'components',
      title: 'components',
      items: [{
        key: 'component',
        title: 'Component.tsx'
      }]
    }, {
      key: 'webparts',
      title: 'webparts',
      items: [{
        key: 'scss',
        title: 'WebPart.module.scss'
      }, {
        key: 'ts',
        title: 'WebPart.ts'
      }]
  }]
}];
Now, we need to process our hierarchy to construct flat array of IItem items and array of IGroup groups. This arrays will be used by DetailsList component to display grouped list.
Let's assume that the hierarchy is passed in the props of our component:
export interface IOuifrGroupedDetailsListProps {
  nodes: INode[];
}
Then, we can used the methods below to process the hierarchy and save results to the state:
/**
 * Gets flat items array and groups array based on the hierarchy from the props
 */
private _getItemsAndGroups = (props: IOuifrGroupedDetailsListProps): void => {
  const nodes = props.nodes;
  const items: IItem[] = [];
  const groups: IGroup[] = [];

  // processing all the nodes recursively
  this._processNodes(nodes, groups, items, 0);

  // setting the state
  this.setState({
    groups: groups,
    items: items
  });
}

/**
 * Recursively process hierarchy's nodes to build groups and add items to the flat array
 */
private _processNodes = (nodeItems: INode[] | undefined, groups: IGroup[], items: IItem[], level: number): void => {
  // end of recursion
  if (!nodeItems || !nodeItems.length) {
    return;
  }

  // processing current level of the tree
  nodeItems.forEach(nodeItem => {
    const newGroup: IGroup = {
      key: nodeItem.key,
      name: nodeItem.title,
      startIndex: items.length,
      count: 0,
      children: [],
      level: level, // level is incremented on each call of the recursion
      data: nodeItem // storing initial INode instance in the group's data
    };

    groups.push(newGroup);
    if (nodeItem.items && nodeItem.items.length) {

      // adding items to the flat array
      items.push(...nodeItem.items);
    }

    // processing child nodes
    this._processNodes(nodeItem.children, newGroup.children!, items, level + 1);

    // current group count is a sum of group's leaf items and leaf items in all child nodes
    newGroup.count = items.length - newGroup.startIndex;
  });
}
And now we can render the DetailsList using values from the state:
public render(): React.ReactElement<IOuifrGroupedDetailsListProps> {
  const {
    items,
    groups
  } = this.state;

  return (
    <div className={styles.ouifrGroupedDetailsList}>
      <DetailsList
          columns={this._columns}
          items={items || []}
          groups={groups}
        />
    </div>
  );
}
If we look at the rendered result, we'll see such a list:

The problem here is we're missing index.ts leaf item in the code group. So, it's kinda data loss situation from user perspective.
The reason for that, as mentioned above, is DetailsList component ignores group's items if there are subgroups.

The Solution

The solution here consists of two parts.
First, we'll add "fake" subgroup and place all the missing leaf items in it. For our sample we'll add fake subgroup to code group and move index.ts into it:
private _processNodes = (nodeItems: INode[] | undefined, groups: IGroup[], items: IItem[], level: number): void => {
  // ...

  // processing current level of the tree
  nodeItems.forEach(nodeItem => {
    // ...
    groups.push(newGroup);
    if (nodeItem.items && nodeItem.items.length) {
      // adding fake group with no data
      if (nodeItem.children && nodeItem.children.length) {
        newGroup.children!.push({
          key: `${nodeItem.key}-fake`,
          name: '',
          startIndex: items.length,
          count: nodeItem.items.length,
          level: level
        });
      }

      // adding items to the flat array
      items.push(...nodeItem.items);
    }

    // ...
  });
}
The fake group doesn't have data as there is no actual INode item related to it.
Now our component looks like that:

The index.ts item is there, but, as expected, it is displayed as a child of fake group.

The second step of the solution is to override rendering of group's header and hide the header for any fake group.
As we know, fake groups don't have data assigned, so we can use it to determine if the group is fake.
And we can override group's header rendering using groupProps property of the DetailsList:
public render(): React.ReactElement<IOuifrGroupedDetailsListProps> {
  // ...
  return (
    <div className={ styles.ouifrGroupedDetailsList }>
      <DetailsList
          /* ... */
          groupProps={{
            onRenderHeader: this._onRenderGroupHeader,
            isAllGroupsCollapsed: groups ? groups.filter(gr => !gr.isCollapsed).length === 0 : true,
            collapseAllVisibility: CollapseAllVisibility.visible
          }}
      />
    </div>
  );
}

private _onRenderGroupHeader = (props: IDetailsGroupDividerProps, _defaultRender?: IRenderFunction<IDetailsGroupDividerProps>): JSX.Element => {
  // for fake groups - return empty element
  if (!props.group!.data) {
    return <></>;
  }

  // default rendering for "real" groups
  return _defaultRender(props);
}
After these changes we'll see the result we actually want. Moreover, all events, including collapsing/expanding will still work as expected!

You can find full sample code for this post here.

That's all for today!
Have fun!

No comments:

Post a Comment