Grouping, trees and details
Pass groupBy with one or more column ids and rows are gathered under group rows that show the value and the number of rows. Give a column an aggFunc such…
Live demoGrouping & trees
Group rows with totals, nest rows as a tree, open detail panels, drag rows into order.
Code for this demo
import { createGrid, type ColumnDef, type GridApi, type GridOptions, type Row } from '@freegridjs/core';
import { makeRows } from '../data';
import { button, separator, text, tr, type DemoContext } from '../ui';
type Mode = 'group' | 'tree' | 'detail' | 'drag';
export default function mount(ctx: DemoContext) {
const { lang } = ctx;
const h = (en: string, ko: string) => tr(lang, { en, ko });
const money = { type: 'number', format: '#,##0', width: 120, aggFunc: 'sum' } as const;
const sales: ColumnDef[] = [
{ field: 'country', header: h('Country', '국가'), width: 150 },
{ field: 'company', header: h('Company', '회사'), width: 140 },
{ field: 'name', header: h('Name', '이름'), width: 170 },
{ field: 'q1', header: h('Q1 revenue', '1분기 매출'), ...money },
{ field: 'q2', header: h('Q2 revenue', '2분기 매출'), ...money },
{ field: 'total', header: h('Total', '합계'), ...money, width: 130 },
{ field: 'id', header: h('People', '인원'), type: 'number', width: 90, aggFunc: 'count' },
];
// An org chart: departments contain teams, teams contain people.
const org: Row[] = [
{ id: 'hq', parent: null, name: h('Head office', '본사'), role: '', budget: 1200 },
{ id: 'dev', parent: 'hq', name: h('Development', '개발본부'), role: '', budget: 640 },
{ id: 'web', parent: 'dev', name: h('Web team', '웹팀'), role: '', budget: 260 },
{ id: 'p1', parent: 'web', name: '김민준', role: h('Lead', '팀장'), budget: 0 },
{ id: 'p2', parent: 'web', name: '이서연', role: h('Engineer', '개발자'), budget: 0 },
{ id: 'app', parent: 'dev', name: h('App team', '앱팀'), role: '', budget: 380 },
{ id: 'p3', parent: 'app', name: '박지호', role: h('Engineer', '개발자'), budget: 0 },
{ id: 'biz', parent: 'hq', name: h('Business', '사업본부'), role: '', budget: 560 },
{ id: 'p4', parent: 'biz', name: '최유나', role: h('Manager', '매니저'), budget: 0 },
{ id: 'p5', parent: 'biz', name: '정하준', role: h('Sales', '영업'), budget: 0 },
];
let grid: GridApi | null = null;
const show = (mode: Mode) => {
grid?.destroy();
let options: GridOptions;
if (mode === 'tree') {
options = {
columns: [
{ field: 'name', header: h('Organization', '조직'), width: 260 },
{ field: 'role', header: h('Role', '직책'), width: 140 },
{ field: 'budget', header: h('Budget (M)', '예산(백만)'), type: 'number', format: '#,##0', width: 140 },
],
data: org.map(r => ({ ...r })),
getRowId: r => r.id,
treeData: { getParentId: r => r.parent },
groupDefaultExpanded: 1,
};
} else if (mode === 'detail') {
options = {
columns: sales.map(({ aggFunc, ...c }) => c),
data: makeRows(200),
detailHeight: 180,
detailRenderer: ({ row }) => {
// a small grid inside the panel: the person's quarters
const el = document.createElement('div');
el.style.height = '158px';
createGrid(el, {
columns: [
{ field: 'quarter', header: h('Quarter', '분기'), width: 120 },
{ field: 'revenue', header: h('Revenue', '매출'), type: 'number', format: '#,##0', width: 160 },
],
data: ['q1', 'q2', 'q3', 'q4'].map((q, i) => ({ quarter: `${i + 1}Q`, revenue: row[q] })),
locale: lang,
rowHeight: 26,
});
return el;
},
};
} else if (mode === 'drag') {
options = { columns: sales.slice(0, 4).map(({ aggFunc, ...c }) => c), data: makeRows(30), rowDrag: true };
} else {
options = { columns: sales, data: makeRows(1000), groupBy: ['country'], groupPanel: true };
}
grid = createGrid(ctx.host, { locale: lang, theme: 'board', ...options });
grid.on('groupToggle', e => ctx.log(`groupToggle → ${e.key}: ${e.expanded}`));
grid.on('groupChange', e => ctx.log(`groupChange → [${e.groupBy.join(', ')}]`));
grid.on('detailToggle', e => ctx.log(`detailToggle → ${e.row.name}: ${e.expanded}`));
grid.on('rowDragEnd', e => ctx.log(`rowDragEnd → ${e.row.name}: ${e.fromIndex} → ${e.toIndex}`));
};
text(ctx, h('Drag a column header into the bar above the grid to add a grouping level.',
'열 머리글을 그리드 위 막대로 끌어오면 묶음 단계가 늘어납니다.'));
separator(ctx);
button(ctx, h('Row grouping', '행 그룹핑'), () => show('group'));
button(ctx, h('Tree data', '트리 데이터'), () => show('tree'));
button(ctx, h('Master / detail', '마스터/디테일'), () => show('detail'));
button(ctx, h('Row drag', '행 드래그'), () => show('drag'));
separator(ctx);
button(ctx, h('Expand all', '모두 펼치기'), () => grid?.expandAll());
button(ctx, h('Collapse all', '모두 접기'), () => grid?.collapseAll());
button(ctx, h('Group by country, then company', '국가 → 회사로 묶기'), () => grid?.setGroupBy(['country', 'company']));
show('group');
return grid;
}Live demoRow drag & trees
Drag rows to another parent or another grid, load tree children on expand, scroll two grids together.
Code for this demo
import { createGrid, type Row } from '@freegridjs/core';
import { separator, text, toggle, tr, type DemoContext } from '../ui';
export default function mount(ctx: DemoContext) {
const { lang } = ctx;
const h = (en: string, ko: string) => tr(lang, { en, ko });
// two grids side by side
ctx.host.style.display = 'grid';
ctx.host.style.gridTemplateColumns = 'repeat(auto-fit, minmax(300px, 1fr))';
ctx.host.style.gap = '16px';
const left = document.createElement('div');
const right = document.createElement('div');
for (const el of [left, right]) el.style.cssText = 'height: 100%; min-height: 0';
ctx.host.style.gridAutoRows = '100%';
ctx.host.append(left, right);
const tasks: Row[] = [
{ id: 1, name: h('Website launch', '웹사이트 오픈'), parent: null, folder: true },
{ id: 2, name: h('Design', '디자인'), parent: 1, folder: true },
{ id: 3, name: h('Build', '개발'), parent: 1, folder: true },
{ id: 4, name: h('Logo', '로고'), parent: 2 },
// children of this one come from the "server" on first expand
{ id: 5, name: h('Archive (loads on expand)', '보관함 (펼칠 때 불러옴)'), parent: null, folder: true, lazy: true },
];
let nextId = 100;
const columns = [{ field: 'name', header: h('Tasks', '작업'), flex: 1 }];
const tree = createGrid(left, {
columns, data: tasks, locale: lang, getRowId: r => String(r.id), groupDefaultExpanded: -1,
rowDrag: {
group: 'tasks',
// only folders take children
canDrop: ({ target, position }) => position !== 'inside' || !!target?.folder,
},
treeData: {
getParentId: r => r.parent,
setParentId: (row, parentId) => { row.parent = parentId; },
hasChildren: r => !!r.lazy,
// pretend server: answers after 600 ms
loadChildren: row => new Promise<Row[]>(resolve => setTimeout(() => resolve([
{ id: nextId++, name: h('Old banner', '예전 배너'), parent: row.id },
{ id: nextId++, name: h('2025 report', '2025 보고서'), parent: row.id },
]), 600)),
},
});
const backlog = createGrid(right, {
columns: [{ field: 'name', header: h('Backlog', '백로그'), flex: 1 }],
data: Array.from({ length: 30 }, (_, i) => ({ id: 200 + i, name: `${h('Idea', '아이디어')} ${i + 1}`, parent: null })),
locale: lang, getRowId: r => String(r.id),
rowDrag: { group: 'tasks' },
});
for (const [g, name] of [[tree, h('tasks', '작업')], [backlog, h('backlog', '백로그')]] as const) {
g.on('rowDrop', e => ctx.log(`rowDrop (${name}) → "${e.row.name}" ${e.position} ${e.target ? `"${e.target.name}"` : h('end', '끝')}${e.fromOtherGrid ? h(' from the other grid', ' (다른 그리드에서)') : ''}`));
}
tree.on('childrenLoaded', e => ctx.log(`childrenLoaded → ${e.rows.length}`));
text(ctx, h('Drag a row by its handle: onto the middle of a folder to move it inside, onto an edge to put it before or after. Drag between the two grids too.',
'손잡이로 행을 끌어 보세요. 폴더 가운데에 놓으면 그 안으로, 위·아래 가장자리에 놓으면 앞이나 뒤로 갑니다. 두 그리드 사이로도 옮길 수 있습니다.'));
separator(ctx);
let unlink: (() => void) | null = null;
toggle(ctx, h('Scroll both grids together', '두 그리드 함께 스크롤'), false, on => {
if (on) unlink = tree.syncScroll(backlog, 'vertical');
else unlink?.();
ctx.log(`syncScroll: ${on}`);
});
return tree;
}Pass groupBy with one or more column ids and rows are gathered under group rows that show the value and the number of rows. Give a column an aggFunc such as 'sum' or 'avg' and every group row shows that total. Groups follow the sort direction of their column. Open them with the arrow, a double-click, or Enter, Space and the left and right arrow keys. With groupPanel: true, people can drag a header into the bar above the grid to group by it themselves.
For data that already forms a hierarchy, such as an org chart or a folder list, use treeData with a getParentId function. Filtering keeps the parents of every match, so results stay in context. detailRenderer adds a panel under a row that can hold anything, including another grid. rowDrag: true adds a handle for putting rows in order by hand; it pauses while the rows are sorted, filtered or grouped.
// Row grouping with totals
createGrid(el, {
columns: [
{ field: 'country' },
{ field: 'name' },
{ field: 'sales', type: 'number', aggFunc: 'sum' }, // shown in each group row
],
data: rows,
groupBy: ['country'], // or api.setGroupBy(['country', 'city'])
groupPanel: true, // drag headers above the grid to group
groupDefaultExpanded: 1, // 0 = collapsed, -1 = everything open
});
// Tree data: rows point at their parent
createGrid(el, {
columns: [{ field: 'name' }, { field: 'budget', type: 'number' }],
data: orgRows,
getRowId: r => r.id,
treeData: { getParentId: r => r.parent },
});
// Master / detail and row drag
createGrid(el, {
columns, data,
detailHeight: 160,
detailRenderer: ({ row }) => `<p>Orders of ${row.name}</p>`, // or return an element
rowDrag: true,
});With treeData.setParentId, rows can be dragged to another parent: drop on the top or bottom edge of a row to make a sibling, on its middle to make a child. Grids whose rowDrag share a group exchange rows, and canDrop refuses targets. For rows whose children live on a server, hasChildren and loadChildren fetch them on first expand. syncScroll() keeps two grids scrolled together.
// drag rows to another parent, and load folders on first expand
const files = createGrid(left, {
columns, data: roots, getRowId: r => r.id,
rowDrag: { group: 'files', canDrop: ({ target, position }) => position !== 'inside' || target?.folder },
treeData: {
getParentId: r => r.parent,
setParentId: (row, parentId) => { row.parent = parentId; },
hasChildren: r => r.folder,
loadChildren: r => fetch(`/api/files?parent=${r.id}`).then(res => res.json()),
},
});
// another grid in the same group: rows can be dragged between them
const archive = createGrid(right, { columns, data: [], getRowId: r => r.id, rowDrag: { group: 'files' } });
archive.on('rowDrop', e => save(e.row));
// keep two grids scrolled together
const unlink = files.syncScroll(archive, 'vertical');Group rows have a checkbox for all of their rows and show a partial state when some are selected. In trees, groupSelectsChildren: true checks the children with their parent. groupBy also works with server data: each time a group opens, getRows receives groupBy and groupKeys and answers the groups or rows inside it.