Skip to content

Tree Data Grid

The TreeData Plugin transforms parent-child relationships data into a hierarchical tree structure and provides the ability to group rows by parentId and is optimized for the best performance, making it suitable for large datasets. It enables features such as expandable rows, and level indicators, making it ideal for applications that handle nested data.

Source code
TypeScriptts
import { defineCustomElements } from '@revolist/revogrid/loader';
import {
  ExportExcelPlugin,
  TREE_COLLAPSE_ALL_EVENT,
  TREE_EXPAND_ALL_EVENT,
} from '@revolist/revogrid-pro';
import { currentTheme, observeCurrentTheme } from '../../composables/useRandomData';
import {
  createTreeColumns,
  createTreeConfig,
  createTreeFilterConfig,
  createTreeRows,
  TREE_COLUMN_TYPES,
  TREE_DATA_GRID_CONTEXT_MENU,
  TREE_DATA_GRID_FORMATTING,
  TREE_EXPORT_CONFIG,
  TREE_PLUGINS,
  TREE_ROW_ORDER_CONFIG,
  TREE_ROW_SELECT_CONFIG,
  TREE_STICKY_CELLS_CONFIG,
  type TreeDataRow,
} from './tree.shared';
import './tree.scss';

defineCustomElements();

function createButton(label: string) {
  const button = document.createElement('button');
  button.type = 'button';
  button.className = 'tree-button';
  button.textContent = label;
  return button;
}

export function load(parentSelector: string, rows?: TreeDataRow[]) {
  const parent = document.querySelector(parentSelector);
  if (!parent) return () => undefined;

  const source = rows?.length ? rows : createTreeRows();
  let treeConfig = createTreeConfig(source);
  const container = document.createElement('section');
  container.className = 'tree-showcase';
  container.setAttribute('aria-label', 'Tree Data organization explorer');

  const toolbar = document.createElement('div');
  toolbar.className = 'tree-toolbar';
  const actions = document.createElement('div');
  actions.className = 'tree-toolbar__actions';
  const expandButton = createButton('Expand all');
  const collapseButton = createButton('Collapse all');
  const exportButton = createButton('Export to Excel');
  const stickyLabel = document.createElement('label');
  stickyLabel.className = 'tree-sticky';
  const stickyInput = document.createElement('input');
  stickyInput.type = 'checkbox';
  stickyInput.checked = true;
  stickyLabel.append(stickyInput, document.createTextNode('Sticky parents'));
  actions.append(expandButton, collapseButton, exportButton, stickyLabel);
  toolbar.append(actions);

  const grid = document.createElement('revo-grid');
  grid.className = 'tree-grid';
  const initialDarkTheme = currentTheme().isDark();
  grid.theme = initialDarkTheme ? 'darkMaterial' : 'material';
  grid.plugins = TREE_PLUGINS;
  grid.columns = createTreeColumns(source);
  grid.columnTypes = TREE_COLUMN_TYPES;
  grid.rowOrder = TREE_ROW_ORDER_CONFIG;
  grid.rowSelect = TREE_ROW_SELECT_CONFIG;
  grid.tree = treeConfig;
  grid.range = true;
  grid.readonly = true;
  grid.stickyCells = TREE_STICKY_CELLS_CONFIG;
  grid.resize = true;
  grid.filter = createTreeFilterConfig(source);
  grid.dataGridFormatting = TREE_DATA_GRID_FORMATTING;
  grid.dataGridContextMenu = TREE_DATA_GRID_CONTEXT_MENU;
  grid.stretch = true;
  grid.hideAttribution = true;

  const expandAll = () => grid.dispatchEvent(new CustomEvent(TREE_EXPAND_ALL_EVENT));
  const collapseAll = () => grid.dispatchEvent(new CustomEvent(TREE_COLLAPSE_ALL_EVENT));
  const toggleSticky = () => {
    treeConfig = createTreeConfig(source, {
      stickyParents: stickyInput.checked,
    });
    grid.tree = treeConfig;
    grid.columns = createTreeColumns(source, stickyInput.checked);
  };
  const exportToExcel = async () => {
    exportButton.disabled = true;
    exportButton.textContent = 'Exporting…';
    try {
      const plugins = await grid.getPlugins();
      const exportPlugin = plugins.find((plugin) => plugin instanceof ExportExcelPlugin) as ExportExcelPlugin | undefined;
      await exportPlugin?.export(TREE_EXPORT_CONFIG);
    } finally {
      exportButton.disabled = false;
      exportButton.textContent = 'Export to Excel';
    }
  };

  expandButton.addEventListener('click', expandAll);
  collapseButton.addEventListener('click', collapseAll);
  exportButton.addEventListener('click', exportToExcel);
  stickyInput.addEventListener('change', toggleSticky);
  container.append(toolbar, grid);
  parent.appendChild(container);
  grid.source = source;
  const disconnectTheme = observeCurrentTheme((isDark) => {
    grid.theme = isDark ? 'darkMaterial' : 'material';
  });

  return () => {
    disconnectTheme();
    expandButton.removeEventListener('click', expandAll);
    collapseButton.removeEventListener('click', collapseAll);
    exportButton.removeEventListener('click', exportToExcel);
    stickyInput.removeEventListener('change', toggleSticky);
    container.remove();
  };
}
Vuevue
<template>
  <section class="tree-showcase" aria-label="Tree Data organization explorer">
    <div class="tree-toolbar">
      <div class="tree-toolbar__actions">
        <button class="tree-button" type="button" @click="expandAll">Expand all</button>
        <button class="tree-button" type="button" @click="collapseAll">Collapse all</button>
        <button class="tree-button" type="button" :disabled="exporting" @click="exportToExcel">
          {{ exporting ? 'Exporting…' : 'Export to Excel' }}
        </button>
        <label class="tree-sticky">
          <input v-model="stickyParents" type="checkbox" />
          Sticky parents
        </label>
      </div>
    </div>
    <RevoGrid
      ref="gridRef"
      class="tree-grid"
      :theme="darkTheme ? 'darkMaterial' : 'material'"
      :plugins="plugins"
      :columns="columns"
      :source="rows"
      :column-types="columnTypes"
      :row-order.prop="TREE_ROW_ORDER_CONFIG"
      :row-select.prop="TREE_ROW_SELECT_CONFIG"
      :tree.prop="treeConfig"
      :sticky-cells.prop="TREE_STICKY_CELLS_CONFIG"
      :range="true"
      :readonly="true"
      :resize="true"
      :filter="filterConfig"
      :data-grid-formatting.prop="TREE_DATA_GRID_FORMATTING"
      :data-grid-context-menu.prop="TREE_DATA_GRID_CONTEXT_MENU"
      :stretch="true"
      hide-attribution
    />
  </section>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import RevoGrid from '@revolist/vue3-datagrid';
import {
  ExportExcelPlugin,
  TREE_COLLAPSE_ALL_EVENT,
  TREE_EXPAND_ALL_EVENT,
} from '@revolist/revogrid-pro';
import { currentTheme, observeCurrentTheme } from '../../composables/useRandomData';
import {
  createTreeColumns,
  createTreeConfig,
  createTreeFilterConfig,
  createTreeRows,
  TREE_COLUMN_TYPES,
  TREE_DATA_GRID_CONTEXT_MENU,
  TREE_DATA_GRID_FORMATTING,
  TREE_EXPORT_CONFIG,
  TREE_PLUGINS,
  TREE_ROW_ORDER_CONFIG,
  TREE_ROW_SELECT_CONFIG,
  TREE_STICKY_CELLS_CONFIG,
} from './tree.shared';
import './tree.scss';

const gridRef = ref<{ $el: HTMLRevoGridElement } | HTMLRevoGridElement | null>(null);
const rows = ref(createTreeRows());
const stickyParents = ref(true);
const columns = computed(() => createTreeColumns(rows.value, stickyParents.value));
const filterConfig = computed(() => createTreeFilterConfig(rows.value));
const plugins = [...TREE_PLUGINS];
const columnTypes = TREE_COLUMN_TYPES;
const exporting = ref(false);
const darkTheme = ref(typeof window !== 'undefined' && currentTheme().isDark());
const treeConfig = computed(() => createTreeConfig(rows.value, {
  stickyParents: stickyParents.value,
}));
let disconnectTheme: (() => void) | undefined;

onMounted(() => {
  disconnectTheme = observeCurrentTheme((isDark) => {
    darkTheme.value = isDark;
  });
});

onUnmounted(() => {
  disconnectTheme?.();
});

function getGrid() {
  const current = gridRef.value;
  return current && '$el' in current ? current.$el : current ?? undefined;
}

function expandAll() {
  getGrid()?.dispatchEvent(new CustomEvent(TREE_EXPAND_ALL_EVENT));
}

function collapseAll() {
  getGrid()?.dispatchEvent(new CustomEvent(TREE_COLLAPSE_ALL_EVENT));
}

async function exportToExcel() {
  const grid = getGrid();
  if (!grid) return;
  exporting.value = true;
  try {
    const gridPlugins = await grid.getPlugins();
    const exportPlugin = gridPlugins.find((plugin) => plugin instanceof ExportExcelPlugin) as ExportExcelPlugin | undefined;
    await exportPlugin?.export(TREE_EXPORT_CONFIG);
  } finally {
    exporting.value = false;
  }
}
</script>
Reacttsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import {
  ExportExcelPlugin,
  TREE_COLLAPSE_ALL_EVENT,
  TREE_EXPAND_ALL_EVENT,
} from '@revolist/revogrid-pro';
import { currentTheme, observeCurrentTheme } from '../../composables/useRandomData';
import {
  createTreeColumns,
  createTreeConfig,
  createTreeFilterConfig,
  createTreeRows,
  TREE_COLUMN_TYPES,
  TREE_DATA_GRID_CONTEXT_MENU,
  TREE_DATA_GRID_FORMATTING,
  TREE_EXPORT_CONFIG,
  TREE_PLUGINS,
  TREE_ROW_ORDER_CONFIG,
  TREE_ROW_SELECT_CONFIG,
  TREE_STICKY_CELLS_CONFIG,
  type TreeDataRow,
} from './tree.shared';
import './tree.scss';

export default function TreeData({ rows }: { rows?: TreeDataRow[] }) {
  const gridRef = useRef<HTMLRevoGridElement>(null);
  const source = useMemo(() => rows?.length ? rows : createTreeRows(), [rows]);
  const [stickyParents, setStickyParents] = useState(true);
  const columns = useMemo(() => createTreeColumns(source, stickyParents), [source, stickyParents]);
  const filterConfig = useMemo(() => createTreeFilterConfig(source), [source]);
  const dataGridFormatting = useMemo(() => TREE_DATA_GRID_FORMATTING, []);
  const dataGridContextMenu = useMemo(() => TREE_DATA_GRID_CONTEXT_MENU, []);
  const plugins = useMemo(() => [...TREE_PLUGINS], []);
  const columnTypes = useMemo(() => ({ ...TREE_COLUMN_TYPES }), []);
  const rowOrder = useMemo(() => TREE_ROW_ORDER_CONFIG, []);
  const rowSelect = useMemo(() => TREE_ROW_SELECT_CONFIG, []);
  const [exporting, setExporting] = useState(false);
  const [darkTheme, setDarkTheme] = useState(() => currentTheme().isDark());
  const tree = useMemo(() => createTreeConfig(source, {
    stickyParents,
  }), [source, stickyParents]);
  const pluginProps = useMemo(() => ({
    rowOrder,
    rowSelect,
    stickyCells: TREE_STICKY_CELLS_CONFIG,
    tree,
  }) as any, [rowOrder, rowSelect, tree]);
  useEffect(() => {
    const disconnectTheme = observeCurrentTheme(setDarkTheme);
    return () => {
      disconnectTheme();
    };
  }, []);

  const expandAll = () => gridRef.current?.dispatchEvent(new CustomEvent(TREE_EXPAND_ALL_EVENT));
  const collapseAll = () => gridRef.current?.dispatchEvent(new CustomEvent(TREE_COLLAPSE_ALL_EVENT));
  const exportToExcel = async () => {
    if (!gridRef.current) return;
    setExporting(true);
    try {
      const gridPlugins = await gridRef.current.getPlugins();
      const exportPlugin = gridPlugins.find((plugin) => plugin instanceof ExportExcelPlugin) as ExportExcelPlugin | undefined;
      await exportPlugin?.export(TREE_EXPORT_CONFIG);
    } finally {
      setExporting(false);
    }
  };

  return (
    <section className="tree-showcase" aria-label="Tree Data organization explorer">
      <div className="tree-toolbar">
        <div className="tree-toolbar__actions">
          <button className="tree-button" type="button" onClick={expandAll}>Expand all</button>
          <button className="tree-button" type="button" onClick={collapseAll}>Collapse all</button>
          <button className="tree-button" type="button" disabled={exporting} onClick={exportToExcel}>
            {exporting ? 'Exporting…' : 'Export to Excel'}
          </button>
          <label className="tree-sticky">
            <input type="checkbox" checked={stickyParents} onChange={(event) => setStickyParents(event.currentTarget.checked)} />
            Sticky parents
          </label>
        </div>
      </div>
      <RevoGrid
        ref={gridRef}
        className="tree-grid"
        theme={darkTheme ? 'darkMaterial' : 'material'}
        plugins={plugins}
        columns={columns}
        source={source}
        columnTypes={columnTypes}
        {...pluginProps}
        range={true}
        readonly={true}
        resize={true}
        filter={filterConfig}
        dataGridFormatting={dataGridFormatting}
        dataGridContextMenu={dataGridContextMenu}
        stretch={true}
        hideAttribution={true}
      />
    </section>
  );
}
Angularts
import { Component, ElementRef, NO_ERRORS_SCHEMA, type OnDestroy, ViewChild, ViewEncapsulation } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import {
  ExportExcelPlugin,
  TREE_COLLAPSE_ALL_EVENT,
  TREE_EXPAND_ALL_EVENT,
} from '@revolist/revogrid-pro';
import { currentTheme, observeCurrentTheme } from '../../composables/useRandomData';
import {
  createTreeColumns,
  createTreeConfig,
  createTreeFilterConfig,
  createTreeRows,
  TREE_COLUMN_TYPES,
  TREE_DATA_GRID_CONTEXT_MENU,
  TREE_DATA_GRID_FORMATTING,
  TREE_EXPORT_CONFIG,
  TREE_PLUGINS,
  TREE_ROW_ORDER_CONFIG,
  TREE_ROW_SELECT_CONFIG,
  TREE_STICKY_CELLS_CONFIG,
} from './tree.shared';

@Component({
  selector: 'tree-data-grid',
  standalone: true,
  imports: [RevoGrid],
  schemas: [NO_ERRORS_SCHEMA],
  encapsulation: ViewEncapsulation.None,
  styleUrls: ['./tree.scss'],
  template: `
    <section class="tree-showcase" aria-label="Tree Data organization explorer">
      <div class="tree-toolbar">
        <div class="tree-toolbar__actions">
          <button class="tree-button" type="button" (click)="expandAll()">Expand all</button>
          <button class="tree-button" type="button" (click)="collapseAll()">Collapse all</button>
          <button class="tree-button" type="button" [disabled]="exporting" (click)="exportToExcel()">
            {{ exporting ? 'Exporting…' : 'Export to Excel' }}
          </button>
          <label class="tree-sticky">
            <input type="checkbox" [checked]="stickyParents" (change)="setStickyParents($event)" />
            Sticky parents
          </label>
        </div>
      </div>
      <revo-grid
        #grid
        class="tree-grid"
        [theme]="theme"
        [plugins]="plugins"
        [columns]="columns"
        [source]="rows"
        [columnTypes]="columnTypes"
        [rowOrder]="rowOrder"
        [rowSelect]="rowSelect"
        [tree]="treeConfig"
        [stickyCells]="stickyCells"
        [range]="true"
        [readonly]="true"
        [resize]="true"
        [filter]="filterConfig"
        [dataGridFormatting]="dataGridFormatting"
        [dataGridContextMenu]="dataGridContextMenu"
        [stretch]="true"
        [hideAttribution]="true"
      ></revo-grid>
    </section>
  `,
})
export class TreeDataGridComponent implements OnDestroy {
  @ViewChild('grid', { read: ElementRef }) gridElement?: ElementRef<HTMLRevoGridElement>;

  theme: HTMLRevoGridElement['theme'] = currentTheme().isDark() ? 'darkMaterial' : 'material';
  private readonly disconnectTheme = observeCurrentTheme((isDark) => {
    this.theme = isDark ? 'darkMaterial' : 'material';
  });
  readonly rows = createTreeRows();
  readonly filterConfig = createTreeFilterConfig(this.rows);
  readonly dataGridFormatting = TREE_DATA_GRID_FORMATTING;
  readonly dataGridContextMenu = TREE_DATA_GRID_CONTEXT_MENU;
  columns = createTreeColumns(this.rows);
  readonly plugins = TREE_PLUGINS;
  readonly columnTypes = TREE_COLUMN_TYPES;
  readonly rowOrder = TREE_ROW_ORDER_CONFIG;
  readonly rowSelect = TREE_ROW_SELECT_CONFIG;
  readonly stickyCells = TREE_STICKY_CELLS_CONFIG;
  stickyParents = true;
  exporting = false;
  treeConfig = createTreeConfig(this.rows);

  ngOnDestroy() {
    this.disconnectTheme();
  }

  expandAll() {
    this.gridElement?.nativeElement.dispatchEvent(new CustomEvent(TREE_EXPAND_ALL_EVENT));
  }

  collapseAll() {
    this.gridElement?.nativeElement.dispatchEvent(new CustomEvent(TREE_COLLAPSE_ALL_EVENT));
  }

  setStickyParents(event: Event) {
    this.stickyParents = (event.target as HTMLInputElement).checked;
    this.treeConfig = createTreeConfig(this.rows, {
      stickyParents: this.stickyParents,
    });
    this.columns = createTreeColumns(this.rows, this.stickyParents);
  }

  async exportToExcel() {
    const grid = this.gridElement?.nativeElement;
    if (!grid) return;
    this.exporting = true;
    try {
      const gridPlugins = await grid.getPlugins();
      const exportPlugin = gridPlugins.find((plugin) => plugin instanceof ExportExcelPlugin) as ExportExcelPlugin | undefined;
      await exportPlugin?.export(TREE_EXPORT_CONFIG);
    } finally {
      this.exporting = false;
    }
  }
}
Shared config and datats
import type { ColumnFilterConfig, ColumnRegular, ColumnType } from '@revolist/revogrid';
import {
  AdvanceFilterPlugin,
  avatarWithTextRenderer,
  ColumnStretchPlugin,
  DataGridFormattingPlugin,
  DimensionAnimationPlugin,
  ExportExcelPlugin,
  type ExportExcelEvent,
  RowOddPlugin,
  RowOrderPlugin,
  RowSelectPlugin,
  type StickyCellsConfig,
  StickyCellsPlugin,
  TreeDataPlugin,
} from '@revolist/revogrid-pro';
import { createTreeExcelExportOptions } from './tree.excel';

export type TreeDataRow = {
  id: string;
  parentId: string | null;
  avatar: string;
  fullName: string;
  team: string;
  role: string;
  status: 'On track' | 'At risk' | 'Blocked' | 'Planned';
  salary: number;
};

export const TREE_PLUGINS = [
  TreeDataPlugin,
  DimensionAnimationPlugin,
  RowOrderPlugin,
  AdvanceFilterPlugin,
  DataGridFormattingPlugin,
  ExportExcelPlugin,
  RowSelectPlugin,
  RowOddPlugin,
  ColumnStretchPlugin,
  StickyCellsPlugin,
];

export const TREE_ROW_ORDER_CONFIG = {
  prop: 'fullName',
  preview: 'compact',
} as const;

export const TREE_ROW_SELECT_CONFIG = {
  rowOrder: true,
};

export const TREE_DATA_GRID_FORMATTING = {
  rowKeyProp: 'id',
} as const;

export const TREE_DATA_GRID_CONTEXT_MENU = {
  formatting: {},
} as const;

export const TREE_STICKY_CELLS_CONFIG: StickyCellsConfig = {
  maxRows: 1,
};

export const TREE_EXPORT_CONFIG: ExportExcelEvent = {
  sheetName: 'Tree Data',
  workbookName: 'tree-data.xlsx',
};

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

export const TREE_COLUMN_TYPES: Record<string, ColumnType> = {
  currency: {
    cellTemplate: (_h, { value }) => currencyFormatter.format(Number(value ?? 0)),
  },
};

type TreeSelectionItem = Pick<TreeDataRow, 'id' | 'avatar' | 'fullName'> & {
  value: string;
  label: string;
};
type TreeTemplateH = Parameters<NonNullable<ColumnRegular['cellTemplate']>>[0];

function renderTeamMemberFilterOption(
  h: TreeTemplateH,
  { item, value }: { item: TreeSelectionItem; value: string },
) {
  return avatarWithTextRenderer!(h, {
    value,
    model: item,
    column: {
      avatarProp: 'avatar',
      avatarLabelProp: 'fullName',
      avatarIndexProp: 'id',
      avatarSize: 20,
    },
  } as never);
}

function renderStatusFilterOption(
  h: TreeTemplateH,
  { value, label }: { value: string; label?: string },
) {
  return statusTemplate(h, label ?? value);
}

/** Keep selection-filter options visually aligned with their owning columns. */
export function createTreeFilterConfig(rows: TreeDataRow[]): ColumnFilterConfig {
  return {
    slider: {
      showRangeDisplay: true,
      formatValue: value => currencyFormatter.format(value ?? 0),
    },
    selection: {
      sortDirection: 'asc',
      getItems: {
        fullName: () => rows.map(({ id, avatar, fullName }) => ({
          value: fullName,
          label: fullName,
          id,
          avatar,
          fullName,
        })),
      },
      syncCellTemplate: {
        fullName: true,
        status: true,
      },
      // Keep explicit option renderers for the packaged demo runtime too.
      // They share the same cell templates, while `syncCellTemplate` remains
      // available to newer runtime versions.
      itemTemplate: {
        fullName: renderTeamMemberFilterOption,
        status: renderStatusFilterOption,
      },
    },
  } as unknown as ColumnFilterConfig;
}

export function createTreeRows(): TreeDataRow[] {
  return [
    { id: 'product', parentId: null, avatar: 'MC', fullName: 'Maya Chen', team: 'Product', role: 'VP Product', status: 'On track', salary: 198000 },
    { id: 'platform', parentId: 'product', avatar: 'NS', fullName: 'Noah Smith', team: 'Platform', role: 'Engineering lead', status: 'On track', salary: 176000 },
    { id: 'platform-api', parentId: 'platform', avatar: 'EG', fullName: 'Eva Green', team: 'Platform', role: 'API engineer', status: 'At risk', salary: 154000 },
    { id: 'platform-grid', parentId: 'platform', avatar: 'LB', fullName: 'Liam Brown', team: 'Platform', role: 'Grid engineer', status: 'On track', salary: 158000 },
    { id: 'platform-security', parentId: 'platform', avatar: 'GY', fullName: 'Grace Young', team: 'Platform', role: 'Security engineer', status: 'On track', salary: 157000 },
    { id: 'platform-infrastructure', parentId: 'platform', avatar: 'LK', fullName: 'Lucas King', team: 'Platform', role: 'Infrastructure engineer', status: 'Planned', salary: 156000 },
    { id: 'platform-reliability', parentId: 'platform', avatar: 'EW', fullName: 'Ella Wright', team: 'Platform', role: 'Reliability engineer', status: 'On track', salary: 159000 },
    { id: 'experience', parentId: 'product', avatar: 'OL', fullName: 'Olivia Lee', team: 'Experience', role: 'Design lead', status: 'Planned', salary: 165000 },
    { id: 'experience-design', parentId: 'experience', avatar: 'MW', fullName: 'Mia Wilson', team: 'Experience', role: 'Product designer', status: 'On track', salary: 142000 },
    { id: 'experience-research', parentId: 'experience', avatar: 'ED', fullName: 'Ethan Davis', team: 'Experience', role: 'UX researcher', status: 'Blocked', salary: 137000 },
    { id: 'experience-content', parentId: 'experience', avatar: 'LS', fullName: 'Leo Scott', team: 'Experience', role: 'Content designer', status: 'On track', salary: 139000 },
    { id: 'experience-systems', parentId: 'experience', avatar: 'ZA', fullName: 'Zoe Adams', team: 'Experience', role: 'Design systems engineer', status: 'At risk', salary: 147000 },
    { id: 'experience-accessibility', parentId: 'experience', avatar: 'AB', fullName: 'Aria Baker', team: 'Experience', role: 'Accessibility lead', status: 'On track', salary: 145000 },
    { id: 'data', parentId: null, avatar: 'AM', fullName: 'Ava Martin', team: 'Data', role: 'VP Data', status: 'On track', salary: 202000 },
    { id: 'analytics', parentId: 'data', avatar: 'JC', fullName: 'James Clark', team: 'Analytics', role: 'Analytics lead', status: 'At risk', salary: 171000 },
    { id: 'analytics-bi', parentId: 'analytics', avatar: 'SH', fullName: 'Sofia Hall', team: 'Analytics', role: 'BI engineer', status: 'On track', salary: 149000 },
    { id: 'analytics-science', parentId: 'analytics', avatar: 'AP', fullName: 'Amelia Parker', team: 'Analytics', role: 'Data scientist', status: 'Planned', salary: 162000 },
    { id: 'analytics-engineering', parentId: 'analytics', avatar: 'DE', fullName: 'Daniel Evans', team: 'Analytics', role: 'Data engineer', status: 'On track', salary: 153000 },
    { id: 'analytics-ml', parentId: 'analytics', avatar: 'CT', fullName: 'Chloe Turner', team: 'Analytics', role: 'ML engineer', status: 'At risk', salary: 164000 },
    { id: 'analytics-insights', parentId: 'analytics', avatar: 'OC', fullName: 'Oscar Collins', team: 'Analytics', role: 'Insights engineer', status: 'On track', salary: 150000 },
    { id: 'operations', parentId: 'data', avatar: 'JL', fullName: 'Jack Lewis', team: 'Operations', role: 'Operations lead', status: 'On track', salary: 151000 },
    { id: 'operations-quality', parentId: 'operations', avatar: 'CH', fullName: 'Charlotte Harris', team: 'Operations', role: 'Quality analyst', status: 'On track', salary: 126000 },
    { id: 'operations-enablement', parentId: 'operations', avatar: 'HM', fullName: 'Henry Moore', team: 'Operations', role: 'Enablement manager', status: 'At risk', salary: 133000 },
    { id: 'operations-programs', parentId: 'operations', avatar: 'LS', fullName: 'Lily Stewart', team: 'Operations', role: 'Program manager', status: 'Planned', salary: 138000 },
    { id: 'operations-release', parentId: 'operations', avatar: 'BM', fullName: 'Benjamin Morris', team: 'Operations', role: 'Release manager', status: 'On track', salary: 136000 },
    { id: 'operations-support', parentId: 'operations', avatar: 'ER', fullName: 'Emily Rogers', team: 'Operations', role: 'Support operations', status: 'On track', salary: 129000 },
  ];
}

function statusTemplate(h: Parameters<NonNullable<ColumnRegular['cellTemplate']>>[0], value: unknown) {
  const label = String(value ?? '');
  const tone = label.toLowerCase().replaceAll(' ', '-');
  return h('span', { class: `tree-status tree-status--${tone}` }, label);
}

export function createTreeColumns(
  rows: TreeDataRow[] = createTreeRows(),
  stickyParents = true,
): ColumnRegular[] {
  const parentIds = new Set(rows.flatMap(row => row.parentId === null ? [] : [row.parentId]));
  const excelExport = createTreeExcelExportOptions(rows);
  const childCell = ({ model }: { model: Record<string, unknown> }) => ({
    subRow: Boolean(model.parentId),
  });

  return [
    {
      name: 'Team member',
      prop: 'fullName',
      size: 300,
      tree: true,
      rowSelect: true,
      rowDrag: true,
      sortable: true,
      filter: ['selection'],
      avatarProp: 'avatar',
      avatarLabelProp: 'fullName',
      avatarIndexProp: 'id',
      avatarSize: 20,
      cellTemplate: avatarWithTextRenderer,
      cellProperties: childCell,
      stickyCell: ({ model }) => stickyParents && parentIds.has(String(model.id)),
      excelExport: excelExport.teamMember,
    },
    {
      name: 'Team',
      prop: 'team',
      size: 150,
      sortable: true,
      filter: ['selection'],
      cellProperties: childCell,
      excelExport: excelExport.text,
    },
    {
      name: 'Role',
      prop: 'role',
      size: 180,
      sortable: true,
      filter: ['selection'],
      cellProperties: childCell,
      excelExport: excelExport.text,
    },
    {
      name: 'Status',
      prop: 'status',
      size: 130,
      filter: ['selection'],
      cellTemplate: (h, { value }) => statusTemplate(h, value),
      cellProperties: childCell,
      excelExport: excelExport.status,
    },
    {
      name: 'Salary',
      prop: 'salary',
      size: 130,
      columnType: 'currency',
      sortable: true,
      filter: ['slider'],
      cellProperties: ({ model }) => ({
        ...childCell({ model }),
        class: { 'tree-salary': true },
      }),
      excelExport: excelExport.salary,
    },
  ];
}

type TreeConfigOptions = {
  stickyParents?: boolean;
  expandedRowIds?: Iterable<string>;
};

export function createTreeConfig(
  rows: TreeDataRow[],
  options: TreeConfigOptions = {},
) {
  const stickyParents = options.stickyParents ?? true;
  return {
    expandedRowIds: new Set(
      options.expandedRowIds ?? rows
        .filter((row) => row.parentId === null)
        .map((row) => row.id),
    ),
    stickyParents,
    animation: true,
  };
}

RevoGrid has two different ways to show hierarchical rows, and they solve different problems:

  • Key grouping uses the grid’s built-in grouping configuration, for example grid.grouping = { props: ['country', 'city'] }. The grid reads one or more column values, creates synthetic group header rows for each matching key, and places source rows under those generated groups. Use it when the hierarchy should be calculated from repeated values such as category, region, status, or date.
  • TreeDataPlugin uses explicit row relationships from your data, normally id and parentId. The plugin keeps your original rows as the tree nodes, computes tree metadata such as level, expanded state, and visibility, and trims collapsed descendants from the viewport. Use it when the hierarchy already exists in the data, such as folders, tasks and subtasks, organization charts, bill of materials, or nested records.

In short, key grouping answers “which rows share the same values?”, while the tree plugin answers “which row is the parent of this row?”. Key grouping is value-driven and creates group rows; tree data is relationship-driven and renders your existing rows as parent and child nodes.

Key Features

  • Hierarchical Data Support: Automatically organizes flat data into a tree structure based on customizable id, parentId, and level fields.
  • Expandable Rows: Expand and collapse rows dynamically to reveal or hide child rows.
  • Customizable Templates: Supports tree-specific cell templates for better customization.
  • Root Parent Support: Define custom root parent identifiers for flexibility in tree structure.

To build the hierarchical tree structure, each data item must include the following fields:

  • id: A unique identifier for each row.
  • parentId: Links the item to its parent row. Root-level rows should use the rootParentId value (default: 'root').

Without these fields, the plugin cannot establish the necessary parent-child relationships for the tree structure.

const data = [
{ id: '1', parentId: 'root', name: 'Parent 1' },
{ id: '2', parentId: '1', name: 'Child 1.1' },
{ id: '3', parentId: '1', name: 'Child 1.2' },
{ id: '4', parentId: 'root', name: 'Parent 2' },
];

The plugin supports the following options for customization:

  • idField: Defines the field representing unique row identifiers. Default: ‘id’.
  • parentIdField: Specifies the field indicating parent row IDs. Default: ‘parentId’.
  • levelField: Sets the field to store hierarchy levels. Default: ‘level’.
  • rootParentId: Defines the identifier for root-level rows. Default: ‘root’.
  • expandedRowIds: A Set of row IDs to predefine expanded rows.

You can provide these options via revogrid.additionalData?.tree to customize the plugin’s behavior:

grid.additionalData = {
tree: {
idField: 'customId',
parentIdField: 'customParentId',
levelField: 'depth',
rootParentId: null,
expandedRowIds: new Set(['row1', 'row2']), // Pre-expanded rows
},
};

By setting these options, you can adapt the plugin to different data structures and define default states for the tree.

Tree rows can animate while they are trimmed during collapse and restored during expand. Enable tree.animation; TreeDataPlugin registers DimensionAnimationPlugin automatically when it is not already present.

import { TreeDataPlugin } from '@revolist/revogrid-pro';
grid.plugins = [TreeDataPlugin];
grid.tree = {
animation: true,
};
grid.dimensionAnimation = {
duration: 180,
};

When tree.animation is not enabled, tree collapse and expand use the existing immediate trim behavior.