Skip to content

Commit 320ae7c

Browse files
committed
feat(createDataGrid): add cell editing with validation and dirty tracking
1 parent 01ee4ef commit 320ae7c

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { createCellEditing } from './editing'
4+
5+
describe('createCellEditing', () => {
6+
const columns = [
7+
{ key: 'name', editable: true },
8+
{ key: 'email', editable: true, validate: (v: unknown) => typeof v === 'string' && v.includes('@') || 'Invalid email' },
9+
{ key: 'id', editable: false },
10+
]
11+
12+
it('starts with no active cell', () => {
13+
const editing = createCellEditing({ columns })
14+
expect(editing.active.value).toBeNull()
15+
})
16+
17+
it('edit sets active cell', () => {
18+
const editing = createCellEditing({ columns })
19+
editing.edit(1, 'name')
20+
expect(editing.active.value).toEqual({ row: 1, column: 'name' })
21+
})
22+
23+
it('edit rejects non-editable columns', () => {
24+
const editing = createCellEditing({ columns })
25+
editing.edit(1, 'id')
26+
expect(editing.active.value).toBeNull()
27+
})
28+
29+
it('cancel clears active cell', () => {
30+
const editing = createCellEditing({ columns })
31+
editing.edit(1, 'name')
32+
editing.cancel()
33+
expect(editing.active.value).toBeNull()
34+
})
35+
36+
it('commit calls onEdit and clears active', () => {
37+
const onEdit = vi.fn()
38+
const editing = createCellEditing({ columns, onEdit })
39+
editing.edit(1, 'name')
40+
editing.commit('Alice')
41+
expect(onEdit).toHaveBeenCalledWith(1, 'name', 'Alice')
42+
expect(editing.active.value).toBeNull()
43+
})
44+
45+
it('commit rejects invalid value and sets error', () => {
46+
const onEdit = vi.fn()
47+
const editing = createCellEditing({ columns, onEdit })
48+
editing.edit(1, 'email')
49+
editing.commit('not-an-email')
50+
expect(onEdit).not.toHaveBeenCalled()
51+
expect(editing.error.value).toBe('Invalid email')
52+
expect(editing.active.value).toEqual({ row: 1, column: 'email' })
53+
})
54+
55+
it('commit accepts valid value after previous error', () => {
56+
const onEdit = vi.fn()
57+
const editing = createCellEditing({ columns, onEdit })
58+
editing.edit(1, 'email')
59+
editing.commit('not-an-email')
60+
expect(editing.error.value).toBe('Invalid email')
61+
62+
editing.commit('valid@email.com')
63+
expect(onEdit).toHaveBeenCalledWith(1, 'email', 'valid@email.com')
64+
expect(editing.error.value).toBeNull()
65+
expect(editing.active.value).toBeNull()
66+
})
67+
68+
it('tracks dirty cells', () => {
69+
const editing = createCellEditing({ columns })
70+
editing.edit(1, 'name')
71+
editing.dirty.value.get(1)?.set('name', 'pending')
72+
expect(editing.dirty.value.get(1)?.get('name')).toBe('pending')
73+
})
74+
75+
it('cancel clears error', () => {
76+
const editing = createCellEditing({ columns })
77+
editing.edit(1, 'email')
78+
editing.commit('bad')
79+
expect(editing.error.value).toBe('Invalid email')
80+
editing.cancel()
81+
expect(editing.error.value).toBeNull()
82+
})
83+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @module createDataGrid/editing
3+
*
4+
* @remarks
5+
* Cell editing state management. Tracks active cell, validation errors,
6+
* and dirty (uncommitted) edits. Does not mutate source data — commit
7+
* fires a callback for the consumer to handle.
8+
*/
9+
10+
// Utilities
11+
import { isString } from '#v0/utilities'
12+
import { ref, shallowRef } from 'vue'
13+
14+
// Types
15+
import type { ID } from '#v0/types'
16+
import type { Ref, ShallowRef } from 'vue'
17+
18+
export interface EditableColumn {
19+
readonly key: string
20+
readonly editable?: boolean | ((item: unknown) => boolean)
21+
readonly validate?: (value: unknown, item?: unknown) => boolean | string
22+
}
23+
24+
export interface CellEditingOptions {
25+
columns: readonly EditableColumn[]
26+
onEdit?: (row: ID, column: string, value: unknown) => void
27+
}
28+
29+
export interface ActiveCell {
30+
row: ID
31+
column: string
32+
}
33+
34+
export interface CellEditing {
35+
active: Readonly<ShallowRef<ActiveCell | null>>
36+
edit: (row: ID, column: string) => void
37+
commit: (value: unknown) => void
38+
cancel: () => void
39+
error: Readonly<ShallowRef<string | null>>
40+
dirty: Readonly<Ref<Map<ID, Map<string, unknown>>>>
41+
}
42+
43+
export function createCellEditing (options: CellEditingOptions): CellEditing {
44+
const { columns, onEdit } = options
45+
46+
const columnMap = new Map<string, EditableColumn>()
47+
for (const col of columns) {
48+
columnMap.set(col.key, col)
49+
}
50+
51+
const active = shallowRef<ActiveCell | null>(null)
52+
const error = shallowRef<string | null>(null)
53+
const dirty = ref(new Map<ID, Map<string, unknown>>())
54+
55+
function edit (row: ID, column: string) {
56+
const col = columnMap.get(column)
57+
if (!col || col.editable === false || col.editable === undefined) {
58+
return
59+
}
60+
error.value = null
61+
active.value = { row, column }
62+
if (!dirty.value.has(row)) {
63+
dirty.value.set(row, new Map())
64+
}
65+
}
66+
67+
function commit (value: unknown) {
68+
const cell = active.value
69+
if (!cell) return
70+
71+
const col = columnMap.get(cell.column)
72+
if (col?.validate) {
73+
const result = col.validate(value)
74+
if (isString(result)) {
75+
error.value = result
76+
return
77+
}
78+
}
79+
80+
onEdit?.(cell.row, cell.column, value)
81+
82+
// Clear dirty entry for this cell
83+
const rowDirty = dirty.value.get(cell.row)
84+
if (rowDirty) {
85+
rowDirty.delete(cell.column)
86+
if (rowDirty.size === 0) dirty.value.delete(cell.row)
87+
}
88+
89+
error.value = null
90+
active.value = null
91+
}
92+
93+
function cancel () {
94+
error.value = null
95+
active.value = null
96+
}
97+
98+
return {
99+
active,
100+
edit,
101+
commit,
102+
cancel,
103+
error,
104+
dirty,
105+
}
106+
}

0 commit comments

Comments
 (0)