Editing Events and Transactions
Understand the editing event flow from an editor request through onChangeValue validation and normalization, multi-column commits, and onChangeData notifications.
import * as React from 'react';
import { BGrid, type BGridColumn } from 'beautiful-grid';
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<BGridColumn<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 }}>
<BGrid<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.
*
* BGrid'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 > .bgrid-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 { BGridChangeDataMeta, BGridColumn, BGridDataItem } from 'beautiful-grid';
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: BGridDataItem<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: BGridDataItem<T>[],
sourceIndex: number,
values: T,
meta?: BGridChangeDataMeta<T>,
): BGridDataItem<T>[] =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
);
export const withEditingCellClasses = <T,>(columns: BGridColumn<T>[]): BGridColumn<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;
}
.bgrid-body-table
td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-cell-editing)) {
color: var(--editing-example-color, inherit);
background-color: var(--editing-example-bg);
}
.bgrid-body-table tr.bgrid-row-hover
> td:is(.editing-example-cell-editable, .editing-example-cell-readonly):not(:is(.bgrid-cell-selected, .bgrid-cell-edited, .bgrid-cell-value-changed, .bgrid-cell-editing)) {
background-color: var(--editing-example-hover-bg);
}Text editors, Select editors, external plugins, and lookup icons all use the same change transaction. Instead of implementing separate save logic for every editor, perform validation and related-cell updates once in the initiating column’s onChangeValue hook.
Event flow
text / plugin / editorIcon
↓
requestCommit(changes)
↓
column.onChangeValue
↓
commit(changes)
↓
data update → onChangeData → move and end session
If onChangeValue is not defined, the proposed changes are saved automatically. If you define the hook, it must finish by calling either commit() or cancel().
Update related cells in the same transaction
{
key: 'quantity',
editor: { type: 'text', parseValue: Number },
onChangeValue: async ({ changes, nextValues, commit }) => {
if (nextValues.quantity < 0) {
throw new Error('Quantity must be at least 0.');
}
await commit([
...changes,
{
key: 'amount',
value: nextValues.quantity * nextValues.unitPrice,
},
]);
},
}
changes: changes proposed by the editor or iconvalues: canonical row values before the changenextValues: immutable preview with only the proposed changes appliedrows: every row targeted by merge propagation, with each row’snextValuescommit: saves the final list without callingonChangeValueagaincancel: discards the proposal
If the same target appears more than once, the last value wins. For nested data, specify the key as a path array such as { key: ['customer', 'code'], value }.
Completion notification
onChangeData={(sourceIndex, columnIndex, values, column, meta) => {
// columnIndex and column are null when multiple columns change.
console.log(meta?.source, meta?.changes);
console.log(meta?.dataItem.status, meta?.dataItem.editedColumnIds, meta?.dataItem.changedKeys);
console.log(meta?.transaction.sourceIndexes);
}}
onChangeData is called once for every row whose data is actually changed by the transaction. The existing four-argument callback remains supported; read the fifth meta argument only when you need multi-change or merged-range details. Along with the changed values, meta.dataItem includes the row status, the editedColumnIds of directly edited columns, and the changedKeys of changed data fields.
When using controlled data, save meta.dataItem rather than copying only values into a new object. This preserves the changed-cell indicators on the next render.
onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
setData(current =>
current.map((item, index) =>
index === sourceIndex ? meta?.dataItem ?? { ...item, values } : item,
),
);
}}
Directly edited cells receive bgrid-cell-edited, while every cell that shares a changed data key receives bgrid-cell-value-changed. Customize these states with the --bgrid-cell-edited-* and --bgrid-cell-value-changed-* CSS variables, respectively.
Failure and asynchronous behavior
If a target column is missing or ambiguous, or if parseValue or onChangeValue validation fails, the entire change is canceled without a partial save. If the commit Promise rejects, text and plugin editors keep the current session open. When commit and cancel race within the same session, only the first final action to complete takes effect.