편집 이벤트와 트랜잭션 (Editing Events)
editor 요청에서 onChangeValue 검증·보정, 다중 컬럼 commit, onChangeData 통지까지의 이벤트 흐름을 설명합니다.
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>
);
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* AXDataGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .axdg-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}.editing-events-terminal {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 112px;
margin-top: 10px;
overflow: hidden;
border: 1px solid #263449;
border-radius: var(--site-radius-sm, 8px);
background: var(--site-code-bg, #111827);
color: #d7e1ef;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%);
}
.editing-events-terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 30px;
padding: 0 12px;
border-bottom: 1px solid #263449;
color: #8fa3bd;
font-family: var(--site-font-mono, monospace);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
}
.editing-events-log {
min-height: 0;
margin: 0;
padding: 8px 12px 10px 32px;
overflow-x: auto;
overflow-y: scroll;
overscroll-behavior: contain;
scrollbar-gutter: stable;
color: #c8d4e3;
font-family: var(--site-font-mono, monospace);
font-size: 12px;
line-height: 1.6;
white-space: nowrap;
}
.editing-events-log li::marker {
color: #60a5fa;
}
.editing-events-log::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.editing-events-log::-webkit-scrollbar-track {
background: #111827;
}
.editing-events-log::-webkit-scrollbar-thumb {
border: 2px solid #111827;
border-radius: 999px;
background: #475569;
}import type { AXDGChangeDataMeta, AXDGColumn, AXDGDataItem } from '@axboot/datagrid';
import './editingExamples.css';
export interface EditingOrder {
id: string;
orderCode: string;
customerCode: string;
customerName: string;
customerGrade: '일반' | '우수' | 'VIP';
status: '접수' | '진행' | '완료';
deliveryDate: string;
quantity: number;
unitPrice: number;
amount: number;
note: string;
mergeGroup: string;
}
export const editingOrders: AXDGDataItem<EditingOrder>[] = [
{
values: {
id: 'ORDER-001',
orderCode: 'ORD-2601',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '접수',
deliveryDate: '2026-08-25',
quantity: 2,
unitPrice: 12000,
amount: 24000,
note: '오전 배송',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-002',
orderCode: 'ORD-2602',
customerCode: 'C001',
customerName: '서울상사',
customerGrade: 'VIP',
status: '진행',
deliveryDate: '2026-08-26',
quantity: 3,
unitPrice: 18000,
amount: 54000,
note: '담당자 확인',
mergeGroup: 'A',
},
},
{
values: {
id: 'ORDER-003',
orderCode: 'ORD-2603',
customerCode: 'C002',
customerName: '한빛물산',
customerGrade: '우수',
status: '완료',
deliveryDate: '2026-08-28',
quantity: 1,
unitPrice: 32000,
amount: 32000,
note: '',
mergeGroup: 'B',
},
},
{
values: {
id: 'ORDER-004',
orderCode: 'ORD-2604',
customerCode: 'C003',
customerName: 'Northwind',
customerGrade: '일반',
status: '접수',
deliveryDate: '2026-09-01',
quantity: 5,
unitPrice: 9000,
amount: 45000,
note: '영문 송장',
mergeGroup: 'C',
},
},
];
export const cloneEditingOrders = () =>
editingOrders.map(item => ({
...item,
values: { ...item.values },
editedColumnIds: item.editedColumnIds ? [...item.editedColumnIds] : undefined,
changedKeys: item.changedKeys ? [...item.changedKeys] : undefined,
}));
export const applyEditingDataChange = <T,>(
current: AXDGDataItem<T>[],
sourceIndex: number,
values: T,
meta?: AXDGChangeDataMeta<T>,
): AXDGDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: AXDGColumn<T>[]): AXDGColumn<T>[] =>
columns.map(column => ({
...column,
className: [
column.className,
column.editable === false ? 'editing-example-cell-readonly' : 'editing-example-cell-editable',
]
.filter(Boolean)
.join(' '),
}));.editing-example-cell-editable {
--editing-example-bg: #ffffff;
--editing-example-hover-bg: #dbeafe;
}
.editing-example-cell-readonly {
--editing-example-color: #525252;
--editing-example-bg: #f5f5f5;
--editing-example-hover-bg: #e5e5e5;
}
.axdg-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.axdg-body-table tr.axdg-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.axdg-cell-selected, .axdg-cell-edited, .axdg-cell-value-changed, .axdg-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}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: 병합 전파 대상 전체와 각 행의nextValuescommit: 최종 목록 저장.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 경쟁에서는 최초로 완료된 최종 동작만 반영됩니다.