FreeGridv1.6.0English

그룹핑·트리·상세 행

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를 받아 그 그룹의 하위 그룹이나 행을 돌려주면 됩니다.