fix(fe): KsDataGrid + CommonCodeManagementPage - grid edit issues
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:
@@ -236,11 +236,11 @@ const saveMasterBatch = () => {
|
|||||||
// Inline Child Code Grid Editing Handlers (Top-Row Insertion)
|
// Inline Child Code Grid Editing Handlers (Top-Row Insertion)
|
||||||
const addGridRow = () => {
|
const addGridRow = () => {
|
||||||
const groupCodeKey = selectedGroupCode.value.groupCode
|
const groupCodeKey = selectedGroupCode.value.groupCode
|
||||||
let list = mockChildCodesMap[groupCodeKey]
|
if (!mockChildCodesMap[groupCodeKey]) {
|
||||||
if (!list) {
|
mockChildCodesMap[groupCodeKey] = reactive<CommonCode[]>([])
|
||||||
list = []
|
|
||||||
Object.assign(mockChildCodesMap, { [groupCodeKey]: list })
|
|
||||||
}
|
}
|
||||||
|
const list = mockChildCodesMap[groupCodeKey]
|
||||||
|
|
||||||
const newRow: CommonCode = {
|
const newRow: CommonCode = {
|
||||||
code: `NEW_CODE_${list.length + 1}`,
|
code: `NEW_CODE_${list.length + 1}`,
|
||||||
codeName: '새 코드명',
|
codeName: '새 코드명',
|
||||||
@@ -256,7 +256,11 @@ const addGridRow = () => {
|
|||||||
selectedGroupCode.value.codeCount = list.length
|
selectedGroupCode.value.codeCount = list.length
|
||||||
|
|
||||||
nextTick(() => {
|
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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
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'
|
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 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 columnDefs = computed<ColDef[]>(() => {
|
||||||
const mappedCols: ColDef[] = props.columns.map(column => ({
|
const mappedCols: ColDef[] = props.columns.map(column => ({
|
||||||
field: column.field,
|
field: column.field,
|
||||||
headerName: column.header,
|
headerName: column.header,
|
||||||
width: column.width,
|
width: column.width,
|
||||||
minWidth: column.minWidth ?? 80,
|
minWidth: column.minWidth ?? 80,
|
||||||
|
maxWidth: column.maxWidth,
|
||||||
flex: column.flex ?? (column.width ? undefined : 1),
|
flex: column.flex ?? (column.width ? undefined : 1),
|
||||||
|
|
||||||
sortable: column.sortable ?? true,
|
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
|
valueFormatter: column.formatter ? params => column.formatter?.(params.value, params.data) ?? '' : undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -32,6 +73,7 @@ const columnDefs = computed<ColDef[]>(() => {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
filter: false,
|
filter: false,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
|
editable: false,
|
||||||
cellStyle: { textAlign: 'center', color: 'var(--ks-color-neutral-600)', fontSize: '11px' },
|
cellStyle: { textAlign: 'center', color: 'var(--ks-color-neutral-600)', fontSize: '11px' },
|
||||||
headerClass: 'ks-row-number-header'
|
headerClass: 'ks-row-number-header'
|
||||||
}
|
}
|
||||||
@@ -40,35 +82,204 @@ const columnDefs = computed<ColDef[]>(() => {
|
|||||||
|
|
||||||
return mappedCols
|
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 {
|
function onRowClicked(event: RowClickedEvent): void {
|
||||||
if (event.data) emit('rowSelected', event.data)
|
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 {
|
function onSortChanged(params: { api: { redrawRows: () => void; refreshCells: (options: { force: boolean }) => void } }): void {
|
||||||
params.api.redrawRows()
|
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()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ks-grid" :aria-busy="props.loading">
|
<div class="ks-grid" :style="{ height: props.height ?? '100%' }" :aria-busy="props.loading">
|
||||||
<AgGridVue
|
<AgGridVue
|
||||||
style="height: 100%; width: 100%"
|
style="height: 100%; width: 100%; flex: 1; min-height: 0;"
|
||||||
:theme="themeQuartz"
|
:theme="themeQuartz"
|
||||||
:row-data="props.rows"
|
:row-data="props.rows"
|
||||||
:column-defs="columnDefs"
|
:column-defs="columnDefs"
|
||||||
:row-selection="rowSelectionOptions"
|
: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"
|
: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>"
|
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"
|
@grid-ready="onGridReady"
|
||||||
@first-data-rendered="onGridReady"
|
@first-data-rendered="onGridReady"
|
||||||
@sort-changed="onSortChanged"
|
@sort-changed="onSortChanged"
|
||||||
@row-clicked="onRowClicked"
|
@row-clicked="onRowClicked"
|
||||||
|
@cell-value-changed="onCellValueChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user