편집 이벤트와 트랜잭션 (Editing Events)

editor 요청에서 onChangeValue 검증·보정, 다중 컬럼 commit, onChangeData 통지까지의 이벤트 흐름을 설명합니다.

#onChangeValue#commit#onChangeData#transaction#validation
검토일: 2026-08-21
GitHub
import * as React from 'react';
import { AXDataGrid, type AXDGColumn } from '@axboot/datagrid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import {
  applyEditingDataChange,
  cloneEditingOrders,
  type EditingOrder,
  withEditingCellClasses,
} from './editing/shared';
import './EditingEventsExample.css';

export default function EditingEventsExample() {
  const [data, setData] = React.useState(cloneEditingOrders);
  const [events, setEvents] = React.useState<string[]>(['편집을 시작하면 이벤트가 여기에 기록됩니다.']);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const eventLogRef = React.useRef<HTMLOListElement>(null);
  const { width, height } = useContainerSize(containerRef);

  const appendEvent = React.useCallback((message: string) => {
    setEvents(current => [...current, message].slice(-20));
  }, []);

  React.useEffect(() => {
    const eventLog = eventLogRef.current;
    if (!eventLog) return;
    eventLog.scrollTo({ top: eventLog.scrollHeight });
  }, [events]);

  const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: '주문 코드', width: 145, editable: false },
      {
        key: 'quantity',
        label: '수량',
        width: 110,
        align: 'right',
        editable: true,
        editor: {
          type: 'text',
          inputProps: { inputMode: 'numeric' },
          parseValue: text => {
            const value = Number(text);
            if (!Number.isFinite(value) || value < 0) throw new Error('수량은 0 이상의 숫자여야 합니다.');
            return value;
          },
        },
        onChangeValue: async ({ changes, nextValues, commit }) => {
          appendEvent(`onChangeValue: 수량 ${nextValues.quantity}, 합계 재계산`);
          await commit([...changes, { key: 'amount', value: nextValues.quantity * nextValues.unitPrice }]);
        },
      },
      {
        key: 'unitPrice',
        label: '단가',
        width: 130,
        align: 'right',
        editable: true,
        itemRender: ({ value }) => <>{Number(value).toLocaleString()}원</>,
        editor: {
          type: 'text',
          inputProps: { inputMode: 'numeric' },
          formatValue: value => String(value ?? ''),
          parseValue: text => {
            const value = Number(text);
            if (!Number.isFinite(value) || value < 0) throw new Error('단가는 0 이상의 숫자여야 합니다.');
            return value;
          },
        },
        onChangeValue: async ({ changes, nextValues, commit }) => {
          appendEvent(`onChangeValue: 단가 ${nextValues.unitPrice}, 합계 재계산`);
          await commit([...changes, { key: 'amount', value: nextValues.quantity * nextValues.unitPrice }]);
        },
      },
      {
        key: 'amount',
        label: '합계 · 자동 변경',
        width: 170,
        align: 'right',
        editable: false,
        itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
      },
    ]),
    [appendEvent],
  );

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
        <p className='m-0'>
          수량이나 단가를 바꾸면 <code>onChangeValue</code>가 제안 값을 검증하고 합계를 추가한 뒤 한 번의{' '}
          <code>commit(changes[])</code>으로 저장합니다.
        </p>
        <div className='editing-events-terminal'>
          <div className='editing-events-terminal-header' aria-hidden='true'>
            <span>EVENT LOG</span>
            <span>{events.length} entries</span>
          </div>
          <ol
            ref={eventLogRef}
            className='editing-events-log'
            role='log'
            aria-live='polite'
            aria-relevant='additions'
          >
            {events.map((event, index) => <li key={`${event}-${index}`}>{event}</li>)}
          </ol>
        </div>
      </div>
      <DataGridContainer ref={containerRef} style={{ height: 340 }}>
        <AXDataGrid<EditingOrder>
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='id'
          editable
          variant='vertical-bordered'
          editTrigger='click'
          onChangeData={(sourceIndex, columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
            appendEvent(`onChangeData: source ${sourceIndex}, column ${columnIndex ?? 'multi'}, ${meta?.changes.length ?? 0}개 변경`);
          }}
        />
      </DataGridContainer>
    </div>
  );
}

text, Select, 외부 plugin, lookup 아이콘은 모두 같은 변경 트랜잭션을 사용합니다. 에디터별 저장 코드를 따로 만들지 않고 시작 컬럼의 onChangeValue에서 검증과 연관 셀 변경을 한 번 처리합니다.

이벤트 흐름

text / plugin / editorIcon

 requestCommit(changes)

 column.onChangeValue

   commit(changes)

 데이터 갱신 → onChangeData → 이동·세션 종료

onChangeValue가 없으면 제안된 변경이 자동 저장됩니다. hook을 지정했다면 반드시 commit() 또는 cancel()로 끝내야 합니다.

연관 셀을 함께 변경

{
  key: 'quantity',
  editor: { type: 'text', parseValue: Number },
  onChangeValue: async ({ changes, nextValues, commit }) => {
    if (nextValues.quantity < 0) {
      throw new Error('수량은 0 이상이어야 합니다.');
    }

    await commit([
      ...changes,
      {
        key: 'amount',
        value: nextValues.quantity * nextValues.unitPrice,
      },
    ]);
  },
}
  • changes: editor 또는 아이콘이 제안한 변경 목록
  • values: 변경 전 canonical 행 값
  • nextValues: 제안된 변경만 immutable하게 미리 적용한 값
  • rows: 병합 전파 대상 전체와 각 행의 nextValues
  • commit: 최종 목록 저장. onChangeValue를 다시 호출하지 않음
  • cancel: 제안 폐기

같은 대상이 여러 번 나타나면 마지막 값이 적용됩니다. 중첩 데이터는 { key: ['customer', 'code'], value }처럼 path 배열로 지정할 수 있습니다.

완료 통지

onChangeData={(sourceIndex, columnIndex, values, column, meta) => {
  // 여러 컬럼이 바뀌면 columnIndex와 column은 null입니다.
  console.log(meta?.source, meta?.changes);
  console.log(meta?.dataItem.status, meta?.dataItem.editedColumnIds, meta?.dataItem.changedKeys);
  console.log(meta?.transaction.sourceIndexes);
}}

onChangeData는 트랜잭션이 실제 데이터를 바꾼 행마다 한 번 호출됩니다. 기존 네 인자 callback은 그대로 사용할 수 있고, 다중 변경과 병합 범위가 필요할 때만 다섯 번째 meta를 읽습니다. meta.dataItem에는 변경된 값과 함께 행 status, 직접 편집한 컬럼의 editedColumnIds, 변경된 데이터 key의 changedKeys가 포함됩니다.

제어형 data를 사용하는 경우에는 values만 새 객체에 복사하지 말고 meta.dataItem을 저장해야 변경 셀 표시가 다음 렌더에서도 유지됩니다.

onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
  setData(current =>
    current.map((item, index) =>
      index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
    ),
  );
}}

직접 편집한 셀에는 axdg-cell-edited가 적용되고, 같은 데이터 key를 공유하는 모든 셀에는 axdg-cell-value-changed가 적용됩니다. 두 상태는 각각 --axdg-cell-edited-*, --axdg-cell-value-changed-* CSS 변수로 변경할 수 있습니다.

실패와 비동기 규칙

대상 컬럼이 없거나 모호한 경우, parseValue 또는 onChangeValue 검증이 실패한 경우 전체 변경을 취소하며 부분 저장하지 않습니다. commit Promise가 reject되면 text/plugin editor는 현재 세션을 유지합니다. 같은 세션의 commit·cancel 경쟁에서는 최초로 완료된 최종 동작만 반영됩니다.