그룹핑·트리·상세 행
groupBy에 컬럼 ID를 넣으면 같은 값을 가진 행이 그룹 행 아래로 모이고, 그룹 행에 값과 행 수가 표시됩니다. 컬럼에 aggFunc를 'sum'이나 'avg'로 지정하면 그룹 행마다 합계나 평균이 나옵니다. 그룹 순서는 그 컬럼의 정렬 방향을 따릅니다. 화살표를…
실행 예제그룹핑과 트리
행을 묶어 합계를 보고, 트리로 펼치고, 상세 패널을 열고, 행을 끌어 순서를 바꿉니다.
이 데모의 코드
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;
}실행 예제행 드래그와 트리
행을 다른 부모나 다른 그리드로 옮기고, 펼칠 때 자식을 불러오고, 두 그리드를 함께 스크롤합니다.
이 데모의 코드
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;
}groupBy에 컬럼 ID를 넣으면 같은 값을 가진 행이 그룹 행 아래로 모이고, 그룹 행에 값과 행 수가 표시됩니다. 컬럼에 aggFunc를 'sum'이나 'avg'로 지정하면 그룹 행마다 합계나 평균이 나옵니다. 그룹 순서는 그 컬럼의 정렬 방향을 따릅니다. 화살표를 누르거나 더블클릭하면 펼쳐지고, 키보드로는 Enter, Space, 좌우 방향키를 씁니다. groupPanel: true를 켜면 사용자가 열 머리글을 그리드 위 막대로 끌어와 직접 묶을 수 있습니다.
조직도나 폴더 목록처럼 이미 계층이 있는 데이터는 treeData와 getParentId로 나타냅니다. 필터를 걸어도 일치하는 행의 상위 행이 함께 남아 위치를 알 수 있습니다. detailRenderer는 행 아래에 펼쳐지는 패널을 만들며, 그 안에 다른 그리드를 넣을 수도 있습니다. rowDrag: true를 켜면 손잡이로 행 순서를 직접 바꿀 수 있고, 정렬·필터·그룹이 걸려 있는 동안에는 잠시 꺼집니다.
// 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,
});트리에서 treeData.setParentId를 주면 행을 다른 부모 아래로 끌어 옮길 수 있습니다. 행의 위쪽이나 아래쪽에 놓으면 형제가 되고, 가운데에 놓으면 자식이 됩니다. rowDrag에 같은 group을 준 그리드끼리는 행을 주고받을 수 있고, canDrop으로 놓을 수 없는 곳을 정합니다. 폴더처럼 자식이 서버에 있는 행은 hasChildren과 loadChildren을 주면 처음 펼칠 때 불러옵니다. 두 그리드를 함께 스크롤하려면 syncScroll()을 쓰세요.
// 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');그룹 행에는 그룹 전체를 선택하는 체크박스가 있고, 일부만 선택되면 중간 상태로 보입니다. 트리에서 groupSelectsChildren: true를 주면 부모를 체크할 때 자식도 함께 체크됩니다. 데이터가 서버에 있어도 groupBy를 쓸 수 있습니다. 그룹을 펼칠 때마다 getRows가 groupBy와 groupKeys를 받아 그 그룹의 하위 그룹이나 행을 돌려주면 됩니다.