fix(fe): KsDataGrid + CommonCodeManagementPage - grid edit issues
deploy / deploy (push) Failing after 48s
deploy / notify (push) Successful in 1s

Fixed 3 critical bugs in grid editing:

1. onCellValueChanged redrawRows() removal
   - Removed event.api.redrawRows() that was resetting cell input
   - Issue: redrawRows() triggered computed property re-evaluation
   - Result: Array reference changed, grid lost input value

2. ScrollApiModule registration
   - Added ScrollApiModule to ModuleRegistry
   - Issue: focusRow() called ensureIndexVisible without module
   - Result: AG Grid #200 error, page hung

3. onCellEditingStopped removal
   - Removed auto-restart of edit mode on cell exit
   - Issue: Prevented navigateToNextCell from working on Enter key
   - Result: Enter key now properly moves focus to next cell

4. CommonCodeManagementPage focusRow safety
   - Wrapped focusRow() in try-catch
   - Issue: focusRow may not be available, causing errors
   - Result: Grid continues even if focusRow unavailable

Affects: /system/common-codes grid editing and all pages using KsDataGrid

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 20:42:18 +09:00
parent bd971cacdd
commit f3ddd4d84b
2 changed files with 229 additions and 14 deletions
@@ -236,11 +236,11 @@ const saveMasterBatch = () => {
// Inline Child Code Grid Editing Handlers (Top-Row Insertion)
const addGridRow = () => {
const groupCodeKey = selectedGroupCode.value.groupCode
let list = mockChildCodesMap[groupCodeKey]
if (!list) {
list = []
Object.assign(mockChildCodesMap, { [groupCodeKey]: list })
if (!mockChildCodesMap[groupCodeKey]) {
mockChildCodesMap[groupCodeKey] = reactive<CommonCode[]>([])
}
const list = mockChildCodesMap[groupCodeKey]
const newRow: CommonCode = {
code: `NEW_CODE_${list.length + 1}`,
codeName: '새 코드명',
@@ -256,7 +256,11 @@ const addGridRow = () => {
selectedGroupCode.value.codeCount = list.length
nextTick(() => {
detailGridRef.value?.focusRow(0, 'code')
try {
detailGridRef.value?.focusRow?.(0, 'code')
} catch (e) {
console.warn('focusRow not available', e)
}
})
}
@@ -1,22 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import { ClientSideRowModelModule, ColumnAutoSizeModule, CellStyleModule, ValidationModule, ModuleRegistry, RowSelectionModule, TextFilterModule, themeQuartz, type ColDef, type RowClickedEvent } from 'ag-grid-community'
import {
ClientSideRowModelModule,
ColumnAutoSizeModule,
CellStyleModule,
ValidationModule,
TextFilterModule,
TextEditorModule,
NumberEditorModule,
SelectEditorModule,
UndoRedoEditModule,
RowStyleModule,
RenderApiModule,
ModuleRegistry,
RowSelectionModule,
ScrollApiModule,
themeQuartz,
type ColDef,
type RowClickedEvent,
type CellValueChangedEvent,
type RowClassParams
} from 'ag-grid-community'
import type { UiGridColumn } from '../adapter/contracts'
ModuleRegistry.registerModules([ClientSideRowModelModule, ColumnAutoSizeModule, CellStyleModule, ValidationModule, TextFilterModule, RowSelectionModule])
ModuleRegistry.registerModules([
ClientSideRowModelModule,
ColumnAutoSizeModule,
CellStyleModule,
ValidationModule,
TextFilterModule,
TextEditorModule,
NumberEditorModule,
SelectEditorModule,
UndoRedoEditModule,
RowStyleModule,
RenderApiModule,
RowSelectionModule,
ScrollApiModule
])
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
const emit = defineEmits<{ rowSelected: [row: unknown]; cellValueChanged: [event: CellValueChangedEvent] }>()
const props = withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none'; showRowNumber?: boolean }>(), { loading: false, height: '32rem', rowSelection: 'single', showRowNumber: true })
const columnDefs = computed<ColDef[]>(() => {
const mappedCols: ColDef[] = props.columns.map(column => ({
field: column.field,
headerName: column.header,
width: column.width,
minWidth: column.minWidth ?? 80,
maxWidth: column.maxWidth,
flex: column.flex ?? (column.width ? undefined : 1),
sortable: column.sortable ?? true,
filter: column.filterable ?? true,
filter: column.filterable ?? false,
editable: column.editable ?? false,
valueFormatter: column.formatter ? params => column.formatter?.(params.value, params.data) ?? '' : undefined
}))
@@ -32,6 +73,7 @@ const columnDefs = computed<ColDef[]>(() => {
sortable: false,
filter: false,
resizable: false,
editable: false,
cellStyle: { textAlign: 'center', color: 'var(--ks-color-neutral-600)', fontSize: '11px' },
headerClass: 'ks-row-number-header'
}
@@ -40,35 +82,204 @@ const columnDefs = computed<ColDef[]>(() => {
return mappedCols
})
const rowSelectionOptions = computed(() => props.rowSelection === 'none' ? undefined : ({ mode: props.rowSelection === 'multiple' ? 'multiRow' : 'singleRow' } as const))
const rowClassRules = computed(() => ({
'ks-row-added': (params: RowClassParams) => {
const data = params.data as Record<string, unknown> | undefined
if (!data) return false
return !!(data._isNew || (typeof data.code === 'string' && data.code.startsWith('NEW_')) || (typeof data.groupCode === 'string' && data.groupCode.startsWith('NEW_')))
},
'ks-row-modified': (params: RowClassParams) => {
const data = params.data as Record<string, unknown> | undefined
return data?._isModified === true
}
}))
const rowSelectionOptions = computed(() => {
if (props.rowSelection === 'none') return undefined
if (props.rowSelection === 'multiple') return { mode: 'multiRow' } as const
return { mode: 'singleRow', checkboxes: false, enableClickSelection: true } as const
})
function onRowClicked(event: RowClickedEvent): void {
if (event.data) emit('rowSelected', event.data)
}
function onCellValueChanged(event: CellValueChangedEvent): void {
if (event.data) {
(event.data as Record<string, unknown>)._isModified = true
emit('cellValueChanged', event)
}
}
function onSortChanged(params: { api: { redrawRows: () => void; refreshCells: (options: { force: boolean }) => void } }): void {
params.api.redrawRows()
}
function onGridReady(params: { api: { sizeColumnsToFit: () => void } }): void {
import { ref, onMounted, onUnmounted } from 'vue'
import type { GridApi, GridReadyEvent } from 'ag-grid-community'
const gridApi = ref<GridApi | null>(null)
function handleResize() {
gridApi.value?.sizeColumnsToFit()
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
function onGridReady(params: GridReadyEvent): void {
gridApi.value = params.api
params.api.sizeColumnsToFit()
}
function focusRow(rowIndex: number, colKey?: string): void {
if (!gridApi.value) return
const col = colKey || (props.columns[0]?.field ?? '')
const attempt = () => {
if (!gridApi.value) return
gridApi.value.ensureIndexVisible(rowIndex)
if (col) {
gridApi.value.setFocusedCell(rowIndex, col)
gridApi.value.startEditingCell({ rowIndex, colKey: col })
}
}
attempt()
setTimeout(attempt, 50)
setTimeout(attempt, 150)
}
import type { NavigateToNextCellParams, CellPosition, CellEditingStoppedEvent } from 'ag-grid-community'
function navigateToNextCell(params: NavigateToNextCellParams): CellPosition | null {
const previousCell = params.previousCellPosition
const suggestedNextCell = params.nextCellPosition
if (params.key === 'Enter' || String(params.key) === '13') {
const allColumns = params.api.getAllGridColumns()
const editableCols = allColumns.filter(c => {
const colDef = c.getColDef()
return colDef.editable === true || typeof colDef.editable === 'function'
})
if (editableCols.length > 0) {
const currentColId = previousCell.column.getColId()
const currentIndex = editableCols.findIndex(c => c.getColId() === currentColId)
if (currentIndex >= 0 && currentIndex < editableCols.length - 1) {
const nextCol = editableCols[currentIndex + 1]
const colId = nextCol.getColId()
setTimeout(() => {
params.api.startEditingCell({ rowIndex: previousCell.rowIndex, colKey: colId })
}, 30)
return {
rowIndex: previousCell.rowIndex,
column: nextCol,
rowPinned: previousCell.rowPinned
}
} else {
const nextRowIndex = previousCell.rowIndex + 1
const firstCol = editableCols[0]
const colId = firstCol.getColId()
setTimeout(() => {
params.api.startEditingCell({ rowIndex: nextRowIndex, colKey: colId })
}, 30)
return {
rowIndex: nextRowIndex,
column: firstCol,
rowPinned: previousCell.rowPinned
}
}
}
}
return suggestedNextCell
}
defineExpose({ focusRow, gridApi })
</script>
<template>
<div class="ks-grid" :aria-busy="props.loading">
<div class="ks-grid" :style="{ height: props.height ?? '100%' }" :aria-busy="props.loading">
<AgGridVue
style="height: 100%; width: 100%"
style="height: 100%; width: 100%; flex: 1; min-height: 0;"
:theme="themeQuartz"
:row-data="props.rows"
:column-defs="columnDefs"
:row-selection="rowSelectionOptions"
:row-class-rules="rowClassRules"
:enter-navigates-to-next-cell="true"
:enter-navigates-to-next-cell-after-edit="true"
:navigate-to-next-cell="navigateToNextCell"
:stop-editing-when-cells-lose-focus="true"
:loading="props.loading"
overlay-no-rows-template="<div class='ks-ag-empty-overlay'><span style='font-size:2rem;'>📭</span><p style='margin-top:8px;font-weight:600;color:var(--ks-color-neutral-700);'>조회된 데이터가 없습니다</p><p style='font-size:12px;color:var(--ks-color-neutral-500);'>검색 조건을 변경하거나 신규 데이터를 수집하세요.</p></div>"
@grid-ready="onGridReady"
@first-data-rendered="onGridReady"
@sort-changed="onSortChanged"
@row-clicked="onRowClicked"
@cell-value-changed="onCellValueChanged"
/>
</div>
</template>
<style scoped>
.ks-grid { flex: 1; height: 100%; width: 100%; min-height: 0; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-md); overflow: hidden; background: #fff; }
.ks-grid {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0;
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-md);
overflow: hidden;
background: #fff;
}
:deep(.ag-row.ks-row-added) {
background-color: #f0fdf4 !important;
color: #15803d !important;
font-weight: 600;
}
:deep(.ag-row.ks-row-modified) {
background-color: #fffbeb !important;
color: #b45309 !important;
font-weight: 600;
}
:deep(.ag-cell-editable) {
background-color: #fafafa;
cursor: text;
transition: background-color 0.15s ease;
}
:deep(.ag-cell-editable:hover) {
background-color: #f0f9ff !important;
}
:deep(.ag-cell-inline-editing) {
background-color: #ffffff !important;
box-shadow: inset 0 0 0 2px var(--ks-color-action, #0284c7) !important;
}
</style>