Skip to content

Tree data ​

Rows with child rows are displayed as a tree. The children are read from the property children (configurable with childrenField).

Basic tree ​

Use tree data when your data already is hierarchical, e.g. files and folders. Here the children are in the property files, the expanded rows are bound to the application state and changed with expandAll() / collapseAll().

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="grid?.expandAll()">Expand all</button>
      <button @click="grid?.collapseAll()">Collapse all</button>
      <span>Expanded: {{ JSON.stringify(expanded) }}</span>
    </div>
    <Datagrid
      ref="grid"
      :columns="columns"
      :rows="rows"
      :options="options"
      v-model:expandedItems="expanded"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions, type ItemUid } from "@datagrid/vue-ui";

type FileNode = {
  path: string;
  name: string;
  size: number;
  modified: string;
  files?: Array<FileNode>;
};

const rows: Array<FileNode> = [
  {
    path: "/src", name: "src", size: 0, modified: "2024-03-01",
    files: [
      { path: "/src/main.ts", name: "main.ts", size: 1200, modified: "2024-03-01" },
      { path: "/src/App.vue", name: "App.vue", size: 5400, modified: "2024-02-20" },
      {
        path: "/src/components", name: "components", size: 0, modified: "2024-02-28",
        files: [
          { path: "/src/components/Header.vue", name: "Header.vue", size: 2100, modified: "2024-02-11" },
          { path: "/src/components/Footer.vue", name: "Footer.vue", size: 900, modified: "2024-01-30" },
        ],
      },
    ],
  },
  {
    path: "/public", name: "public", size: 0, modified: "2024-01-10",
    files: [{ path: "/public/favicon.ico", name: "favicon.ico", size: 4300, modified: "2023-12-01" }],
  },
  { path: "/package.json", name: "package.json", size: 800, modified: "2024-03-02" },
];

const grid = ref<InstanceType<typeof Datagrid>>();
const expanded = ref<Array<ItemUid>>(["/src"]);

const columns: Array<ColumnConfig<FileNode>> = [
  { field: "name", text: "Name", width: 260, filterable: true, sortable: true },
  {
    field: "size",
    text: "Size",
    type: "number",
    sortable: true,
    valueFormatter: ({ value }) => (value ? `${(value / 1024).toFixed(1)} KB` : ""),
  },
  { field: "modified", text: "Modified", type: "date", sortable: true, flex: 1 },
];

const options: GridOptions<FileNode> = {
  // property of the data objects which contains the child rows (default: "children")
  childrenField: "files",
  // stable ids are recommended for tree data (e.g. for the expanded state)
  rowId: (data) => data.path,
};
</script>
tsx
import { useRef, useState } from "react";
import {
  Datagrid,
  type ColumnConfig,
  type DatagridHandle,
  type GridOptions,
  type ItemUid,
} from "@datagrid/react-ui";

type FileNode = {
  path: string;
  name: string;
  size: number;
  modified: string;
  files?: Array<FileNode>;
};

const rows: Array<FileNode> = [
  {
    path: "/src", name: "src", size: 0, modified: "2024-03-01",
    files: [
      { path: "/src/main.ts", name: "main.ts", size: 1200, modified: "2024-03-01" },
      { path: "/src/App.vue", name: "App.vue", size: 5400, modified: "2024-02-20" },
      {
        path: "/src/components", name: "components", size: 0, modified: "2024-02-28",
        files: [
          { path: "/src/components/Header.vue", name: "Header.vue", size: 2100, modified: "2024-02-11" },
          { path: "/src/components/Footer.vue", name: "Footer.vue", size: 900, modified: "2024-01-30" },
        ],
      },
    ],
  },
  {
    path: "/public", name: "public", size: 0, modified: "2024-01-10",
    files: [{ path: "/public/favicon.ico", name: "favicon.ico", size: 4300, modified: "2023-12-01" }],
  },
  { path: "/package.json", name: "package.json", size: 800, modified: "2024-03-02" },
];

const columns: Array<ColumnConfig<FileNode>> = [
  { field: "name", text: "Name", width: 260, filterable: true, sortable: true },
  {
    field: "size",
    text: "Size",
    type: "number",
    sortable: true,
    valueFormatter: ({ value }) => (value ? `${(value / 1024).toFixed(1)} KB` : ""),
  },
  { field: "modified", text: "Modified", type: "date", sortable: true, flex: 1 },
];

const options: GridOptions<FileNode> = {
  // property of the data objects which contains the child rows (default: "children")
  childrenField: "files",
  // stable ids are recommended for tree data (e.g. for the expanded state)
  rowId: (data) => data.path,
};

export default function TreeData() {
  const grid = useRef<DatagridHandle>(null);
  const [expanded, setExpanded] = useState<Array<ItemUid>>(["/src"]);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={() => grid.current?.expandAll()}>Expand all</button>
        <button onClick={() => grid.current?.collapseAll()}>Collapse all</button>
        <span>Expanded: {JSON.stringify(expanded)}</span>
      </div>
      <Datagrid
        ref={grid}
        columns={columns}
        rows={rows}
        options={options}
        expandedItems={expanded}
        onExpandedItemsChange={setExpanded}
      />
    </div>
  );
}

Behavior ​

  • The expand icon and the indentation are shown in the first column (ignoring the checkbox and row number columns).
  • Rows are collapsed by default. Use v-model:expandedItems, api.expandAll(), api.collapseAll() or api.setExpandedKeys().
  • Sorting sorts the children within their parent.
  • Filters are applied to all levels: parents of matching rows stay visible.
  • Enter (in the tree column) and Alt + ←/→ collapse and expand the focused row.
  • rowExpandable: (row) => boolean prevents expanding specific rows.

Without rowId, child rows get ids like "0-1" (second child of the first row). Use a rowId if the data changes.

Values calculated from the children ​

aggFunc is only used for row grouping and the total row, not for tree data. Values of a parent that depend on its children are calculated with a valueGetter, e.g. the size of a folder is the sum of all files inside. The calculated value is sorted like any other value – "Largest first" sorts the folders by their total size and the files within each folder.

The rowId function receives the parent row, so ids can be built from the path ("src/components").

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="sort = [{ column: 'size', asc: false }]">Largest first</button>
      <button @click="sort = [{ column: 'name', asc: true }]">By name</button>
    </div>
    <Datagrid :columns="columns" :rows="rows" :options="options" v-model:sortedColumns="sort" :expandedItems="expanded" />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
import { createFileTree, type FileNode } from "./data";

const rows = createFileTree();
const sort = ref([{ column: "size", asc: false }]);
const expanded = ["src", "src/components", "public"];

// folders have no size of their own: sum up the sizes of all files inside
function totalSize(node: FileNode): number {
  return node.children ? node.children.reduce((sum, child) => sum + totalSize(child), 0) : node.size;
}

const columns: Array<ColumnConfig<FileNode>> = [
  { field: "name", text: "Name", width: 240 },
  {
    id: "size",
    text: "Size",
    type: "number",
    valueGetter: ({ data }) => totalSize(data),
    valueFormatter: ({ value }) => `${(value / 1024).toFixed(1)} KB`,
  },
  { field: "type", text: "Type", valueFormatter: ({ value }) => (value === "folder" ? "Folder" : value) },
  { field: "modified", text: "Modified", type: "date", flex: 1 },
];

const options: GridOptions<FileNode> = {
  defaultColumn: { sortable: true, width: 130 },
  // ids from the path, e.g. "src/components"
  rowId: (data, _index, parent) => (parent ? `${parent.uid}/${data.name}` : data.name),
};
</script>
tsx
import { useState } from "react";
import { Datagrid, type ColumnConfig, type GridOptions, type SortModel } from "@datagrid/react-ui";
import { createFileTree, type FileNode } from "../data";

const rows = createFileTree();
const expanded = ["src", "src/components", "public"];

// folders have no size of their own: sum up the sizes of all files inside
function totalSize(node: FileNode): number {
  return node.children ? node.children.reduce((sum, child) => sum + totalSize(child), 0) : node.size;
}

const columns: Array<ColumnConfig<FileNode>> = [
  { field: "name", text: "Name", width: 240 },
  {
    id: "size",
    text: "Size",
    type: "number",
    valueGetter: ({ data }) => totalSize(data),
    valueFormatter: ({ value }) => `${(value / 1024).toFixed(1)} KB`,
  },
  { field: "type", text: "Type", valueFormatter: ({ value }) => (value === "folder" ? "Folder" : value) },
  { field: "modified", text: "Modified", type: "date", flex: 1 },
];

const options: GridOptions<FileNode> = {
  defaultColumn: { sortable: true, width: 130 },
  // ids from the path, e.g. "src/components"
  rowId: (data, _index, parent) => (parent ? `${parent.uid}/${data.name}` : data.name),
};

export default function TreeDataFileSize() {
  const [sort, setSort] = useState<SortModel>([{ column: "size", asc: false }]);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={() => setSort([{ column: "size", asc: false }])}>Largest first</button>
        <button onClick={() => setSort([{ column: "name", asc: true }])}>By name</button>
      </div>
      <Datagrid columns={columns} rows={rows} options={options} sortedColumns={sort} onSortedColumnsChange={setSort} expandedItems={expanded} />
    </div>
  );
}

Expanding levels ​

An org chart has several levels (CEO → heads of department → team leads → team members), the children are in the property reports (childrenField: "reports"). The expanded rows are just a list of row ids, so "expand to level" is a small function that collects the ids of the upper levels and assigns them to the bound expandedItems.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      Show:
      <button @click="expanded = expandToLevel(rows, 1)">Heads of department</button>
      <button @click="expanded = expandToLevel(rows, 2)">Team leads</button>
      <button @click="expanded = expandToLevel(rows, 3)">Everyone</button>
    </div>
    <Datagrid
      :columns="columns"
      :rows="rows"
      :options="options"
      v-model:expandedItems="expanded"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions, type ItemUid } from "@datagrid/vue-ui";
import { createOrgChart, type OrgMember } from "./data";

const rows = createOrgChart();

// ids of all members above the given level (level 1 = only the CEO is expanded)
function expandToLevel(members: Array<OrgMember>, level: number): Array<ItemUid> {
  if (level <= 0) return [];
  return members.flatMap((m) => (m.reports ? [m.id, ...expandToLevel(m.reports, level - 1)] : []));
}

// number of all direct and indirect reports
function teamSize(member: OrgMember): number {
  return (member.reports ?? []).reduce((sum, report) => sum + 1 + teamSize(report), 0);
}

const expanded = ref(expandToLevel(rows, 2));

const columns: Array<ColumnConfig<OrgMember>> = [
  { field: "name", text: "Name", width: 210 },
  { field: "title", text: "Title", width: 190 },
  {
    id: "team",
    text: "Team size",
    type: "number",
    valueGetter: ({ data }) => teamSize(data),
    valueFormatter: ({ value }) => (value ? String(value) : ""),
  },
  {
    field: "salary",
    text: "Salary",
    type: "number",
    flex: 1,
    valueFormatter: formatters.currency("EUR", { maximumFractionDigits: 0 }),
  },
];

const options: GridOptions<OrgMember> = {
  // the child rows are in the property "reports"
  childrenField: "reports",
  rowId: (data) => data.id,
  defaultColumn: { width: 115 },
};
</script>
tsx
import { useState } from "react";
import { Datagrid, formatters, type ColumnConfig, type GridOptions, type ItemUid } from "@datagrid/react-ui";
import { createOrgChart, type OrgMember } from "../data";

const rows = createOrgChart();

// ids of all members above the given level (level 1 = only the CEO is expanded)
function expandToLevel(members: Array<OrgMember>, level: number): Array<ItemUid> {
  if (level <= 0) return [];
  return members.flatMap((m) => (m.reports ? [m.id, ...expandToLevel(m.reports, level - 1)] : []));
}

// number of all direct and indirect reports
function teamSize(member: OrgMember): number {
  return (member.reports ?? []).reduce((sum, report) => sum + 1 + teamSize(report), 0);
}

const columns: Array<ColumnConfig<OrgMember>> = [
  { field: "name", text: "Name", width: 210 },
  { field: "title", text: "Title", width: 190 },
  {
    id: "team",
    text: "Team size",
    type: "number",
    valueGetter: ({ data }) => teamSize(data),
    valueFormatter: ({ value }) => (value ? String(value) : ""),
  },
  {
    field: "salary",
    text: "Salary",
    type: "number",
    flex: 1,
    valueFormatter: formatters.currency("EUR", { maximumFractionDigits: 0 }),
  },
];

const options: GridOptions<OrgMember> = {
  // the child rows are in the property "reports"
  childrenField: "reports",
  rowId: (data) => data.id,
  defaultColumn: { width: 115 },
};

export default function TreeDataOrgChart() {
  const [expanded, setExpanded] = useState(() => expandToLevel(rows, 2));

  return (
    <div className="demo">
      <div className="demo-toolbar">
        Show:
        <button onClick={() => setExpanded(expandToLevel(rows, 1))}>Heads of department</button>
        <button onClick={() => setExpanded(expandToLevel(rows, 2))}>Team leads</button>
        <button onClick={() => setExpanded(expandToLevel(rows, 3))}>Everyone</button>
      </div>
      <Datagrid
        columns={columns}
        rows={rows}
        options={options}
        expandedItems={expanded}
        onExpandedItemsChange={setExpanded}
      />
    </div>
  );
}

Filtering tree data ​

The quick filter and column filters search all levels. A parent stays visible if one of its children matches, so the user sees where a match is located. Collapsed parents hide the matches, so this demo expands all rows while searching (api.expandAll() after api.setGlobalFilter()) and collapses them when the search is cleared. Try "desk" or filter the price column.

Source
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <input type="search" placeholder="Search products…" :value="search" @input="onSearch" />
      <span v-if="search">Matching products: {{ found }}</span>
    </div>
    <Datagrid :columns="columns" :rows="rows" :options="options" @ready="api = $event" />
  </div>
</template>

<script setup lang="ts">
import { ref, shallowRef } from "vue";
import { Datagrid, formatters, type ColumnConfig, type Core, type GridOptions } from "@datagrid/vue-ui";
import { createProductTree, type ProductNode } from "./data";

const rows = createProductTree();
const api = shallowRef<Core>();
const search = ref("");
const found = ref(0);

function onSearch(e: Event) {
  search.value = (e.target as HTMLInputElement).value;
  const grid = api.value!;
  grid.setGlobalFilter(search.value);
  // parents of matching rows stay visible, expand them to show the matches
  if (search.value) grid.expandAll();
  else grid.collapseAll();
  // products are the rows without children
  found.value = grid.getAllVisibleRows().filter((row) => !row.data.children).length;
}

const columns: Array<ColumnConfig<ProductNode>> = [
  { field: "name", text: "Product", width: 260 },
  { field: "price", text: "Price", type: "number", valueFormatter: formatters.currency("EUR") },
  { field: "stock", text: "In stock", type: "number", flex: 1 },
];

const options: GridOptions<ProductNode> = {
  rowId: (data) => data.id,
  // column filters work on all levels too
  defaultColumn: { filterable: true, sortable: true, width: 140 },
};
</script>
tsx
import { useRef, useState } from "react";
import { Datagrid, formatters, type ColumnConfig, type DatagridHandle, type GridOptions } from "@datagrid/react-ui";
import { createProductTree, type ProductNode } from "../data";

const rows = createProductTree();

const columns: Array<ColumnConfig<ProductNode>> = [
  { field: "name", text: "Product", width: 260 },
  { field: "price", text: "Price", type: "number", valueFormatter: formatters.currency("EUR") },
  { field: "stock", text: "In stock", type: "number", flex: 1 },
];

const options: GridOptions<ProductNode> = {
  rowId: (data) => data.id,
  // column filters work on all levels too
  defaultColumn: { filterable: true, sortable: true, width: 140 },
};

export default function TreeDataFilter() {
  const grid = useRef<DatagridHandle>(null);
  const [search, setSearch] = useState("");
  const [found, setFound] = useState(0);

  function onSearch(value: string) {
    setSearch(value);
    const api = grid.current!.api;
    api.setGlobalFilter(value);
    // parents of matching rows stay visible, expand them to show the matches
    if (value) api.expandAll();
    else api.collapseAll();
    // products are the rows without children
    setFound(api.getAllVisibleRows().filter((row) => !row.data.children).length);
  }

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <input type="search" placeholder="Search products…" value={search} onChange={(e) => onSearch(e.target.value)} />
        {search && <span>Matching products: {found}</span>}
      </div>
      <Datagrid ref={grid} columns={columns} rows={rows} options={options} />
    </div>
  );
}

Released under the ISC License.