@vuu-ui/vuu-data-editing 4.0.0-alpha.3 → 4.0.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -1
- package/package.json +9 -8
- package/src/DataEditingProvider.js +5 -3
- package/src/DirectEditSession.js +35 -0
- package/src/EditButtons.js +6 -12
- package/src/EditModeProvider.js +8 -3
- package/src/EditSession.js +44 -12
- package/src/TableEditSession.js +0 -0
- package/src/edit-field/EditField.css.js +13 -0
- package/src/edit-field/EditField.js +38 -8
- package/src/edit-field/useEditField.js +27 -12
- package/src/edit-utils.js +1 -1
- package/src/index.js +8 -5
- package/src/lookup-values/useLookupValues.js +78 -0
- package/src/useEditState.js +15 -0
- package/src/useEditable.js +6 -5
- package/src/useEditableTable.js +2 -0
- package/types/DataEditingProvider.d.ts +15 -3
- package/types/DirectEditSession.d.ts +22 -0
- package/types/EditButtons.d.ts +1 -1
- package/types/EditModeProvider.d.ts +3 -2
- package/types/EditSession.d.ts +7 -1
- package/types/TableEditSession.d.ts +28 -0
- package/types/UndoCellRenderer.d.ts +1 -1
- package/types/edit-field/EditField.d.ts +11 -2
- package/types/edit-field/useEditField.d.ts +4 -2
- package/types/edit-utils.d.ts +1 -1
- package/types/index.d.ts +16 -12
- package/types/lookup-values/useLookupValues.d.ts +19 -0
- package/types/useCellEdited.d.ts +2 -2
- package/types/useEditable.d.ts +2 -2
- package/types/useEditableTable.d.ts +2 -0
package/README.md
CHANGED
|
@@ -2,4 +2,367 @@
|
|
|
2
2
|
|
|
3
3
|
Shared data editing APIs, state management, and React components for VUU UI.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This package coordinates edit-session state with editable data sources (both in-memory and remote Vuu session tables) and provides React hooks and UI controls for table editing.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Overview & Architecture](#overview--architecture)
|
|
12
|
+
- [EditSession](#editsession)
|
|
13
|
+
- [Lifecycle State Machine](#lifecycle-state-machine)
|
|
14
|
+
- [Transition Queue](#transition-queue)
|
|
15
|
+
- [EditState & Counters](#editstate--counters)
|
|
16
|
+
- [Internal Edit Tracking](#internal-edit-tracking)
|
|
17
|
+
- [Editing Operations & Flows](#editing-operations--flows)
|
|
18
|
+
- [Cell Editing (`commit` / `cancel`)](#cell-editing-commit--cancel)
|
|
19
|
+
- [New Row Flow (`addNewRow` / `addRow`)](#new-row-flow-addnewrow--addrow)
|
|
20
|
+
- [Row Deletions (`deleteSelectedRows`)](#row-deletions-deleteselectedrows)
|
|
21
|
+
- [Undo Flow (`undoRowChange`)](#undo-flow-undorowchange)
|
|
22
|
+
- [Session Completion (`end`) & Stale Handling](#session-completion-end--stale-handling)
|
|
23
|
+
- [Direct Editing (`DirectEditSession`)](#direct-editing-directeditsession)
|
|
24
|
+
- [React Hooks & Context](#react-hooks--context)
|
|
25
|
+
- [`useEditableTable`](#useeditabletable)
|
|
26
|
+
- [`DataEditingProvider` & `useEditSession`](#dataeditingprovider--useeditsession)
|
|
27
|
+
- [`useCellEdited`](#usecellEdited)
|
|
28
|
+
- [`useEditMode` & `EditModeProvider`](#useeditmode--editmodeprovider)
|
|
29
|
+
- [UI Components & Helpers](#ui-components--helpers)
|
|
30
|
+
- [`EditButtons`](#editbuttons)
|
|
31
|
+
- [`UndoCellRenderer`](#undocellrenderer)
|
|
32
|
+
- [`editActionRowClassNameGenerator`](#editactionrowclassnamegenerator)
|
|
33
|
+
- [`isRowSelectable` / `isEditRowReadOnly`](#isrowselectable--iseditrowreadonly)
|
|
34
|
+
- [Standard Integration Pattern](#standard-integration-pattern)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Overview & Architecture
|
|
39
|
+
|
|
40
|
+
VUU editing operates primarily through **staged edit sessions**:
|
|
41
|
+
1. When entering edit mode, a server-side (or mock) **session table** is created as a sandbox copy of the source table.
|
|
42
|
+
2. User edits (cell edits, row insertions, soft deletes) are applied to the session table and tracked locally by `EditSession`.
|
|
43
|
+
3. When saved, changes are committed from the session table to the source table. When cancelled, the session table is discarded without affecting the source table.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Source DataSource ──begin()──> Session Table Created ──> Live Session DataSource
|
|
47
|
+
│ │
|
|
48
|
+
│ Edits & Deletes
|
|
49
|
+
│ │
|
|
50
|
+
└──────────────end(save=true/false)──────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For backends that do not use session tables and accept cell edits directly against the live table, `DirectEditSession` is used instead.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## EditSession
|
|
58
|
+
|
|
59
|
+
`EditSession` is the core stateful orchestrator class extending `EventEmitter`. It tracks:
|
|
60
|
+
- Local cell edits per row and column
|
|
61
|
+
- Added row keys
|
|
62
|
+
- Edit, invalid, delete, and add counts
|
|
63
|
+
- Edit session lifecycle state
|
|
64
|
+
|
|
65
|
+
### Lifecycle State Machine
|
|
66
|
+
|
|
67
|
+
`EditSession.lifecycle` is a discriminated union on `status`:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
type EditLifecycle =
|
|
71
|
+
| { status: "idle" }
|
|
72
|
+
| { status: "starting" }
|
|
73
|
+
| { status: "active"; sessionDataSource: DataSource }
|
|
74
|
+
| { status: "ending"; sessionDataSource: DataSource }
|
|
75
|
+
| { status: "error"; operation: "begin" | "end"; error: Error; sessionDataSource?: DataSource };
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
idle ──begin()──> starting ──> active ──end()──> ending ──> idle
|
|
80
|
+
│ │
|
|
81
|
+
└── error { op: "begin" } └── error { op: "end" }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `inEditMode`: Getter returning `true` when status is `"active"`, `"ending"`, or `error { operation: "end" }`.
|
|
85
|
+
- `dataSource`: Getter returning `#sessionDataSource` when active/ending, falling back to `#sourceTableDataSource`.
|
|
86
|
+
|
|
87
|
+
### Transition Queue
|
|
88
|
+
|
|
89
|
+
All `begin()` and `end()` calls are serialized through `#transitionQueue` (a chained Promise). This prevents overlapping transitions, race conditions, and corrupted lifecycle states when rapid toggles occur.
|
|
90
|
+
|
|
91
|
+
### EditState & Counters
|
|
92
|
+
|
|
93
|
+
`editSession.editState` is a derived property reflecting the overall dirtiness and validity of the session:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
type EditState = "clean" | "dirty" | "invalid" | "stale";
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Priority order (highest to lowest):
|
|
100
|
+
1. **`"invalid"`**: When `invalidCount > 0` (validation failure in at least one cell).
|
|
101
|
+
2. **`"stale"`**: When a save was rejected by the server due to concurrent updates (`StaleUpdateError`).
|
|
102
|
+
3. **`"dirty"`**: When `editCount > 0 || deleteCount > 0 || addCount > 0`.
|
|
103
|
+
4. **`"clean"`**: When no edits, deletes, or added rows exist.
|
|
104
|
+
|
|
105
|
+
Driven by four independent counters:
|
|
106
|
+
- `editCount`: Number of valid cell edits differing from their original values.
|
|
107
|
+
- `invalidCount`: Number of cell edits with validation errors.
|
|
108
|
+
- `deleteCount`: Number of rows marked for deletion.
|
|
109
|
+
- `addCount`: Number of newly inserted rows currently in the session.
|
|
110
|
+
|
|
111
|
+
All counter setters enforce non-negative values via `Math.max(0, val)`.
|
|
112
|
+
|
|
113
|
+
### Internal Edit Tracking
|
|
114
|
+
|
|
115
|
+
`EditSession` manages four primary collections:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
#rowEdits = new Map<string, RowEditDetails>(); // rowKey => { cellEdits: Map<column, CellEdit> }
|
|
119
|
+
#addedRowKeys = new Set<string>(); // Set of added row keys
|
|
120
|
+
#cellCommitRevisions = new Map<string, Map<string, number>>(); // (key, col) => revision
|
|
121
|
+
#transitionQueue = Promise.resolve(); // Serialized transition queue
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`CellEdit` holds:
|
|
125
|
+
```typescript
|
|
126
|
+
type CellEdit = {
|
|
127
|
+
originalValue: EditSessionValue;
|
|
128
|
+
editedValue: EditSessionValue;
|
|
129
|
+
isValid: boolean;
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
A cell is considered edited only when `originalValue !== editedValue && isValid === true`. If edited back to the original value, the entry is automatically pruned.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Editing Operations & Flows
|
|
137
|
+
|
|
138
|
+
### Cell Editing (`commit` / `cancel`)
|
|
139
|
+
|
|
140
|
+
- **`commit(key, columnName, originalValue, typedValue, isValid, options)`**:
|
|
141
|
+
- Increments cell revision to guard against out-of-order RPC responses.
|
|
142
|
+
- Updates `#rowEdits`, adjusting `editCount` and `invalidCount`.
|
|
143
|
+
- Dispatches `editCell` RPC to the active DataSource if `syncToDataSource !== false`.
|
|
144
|
+
- Emits `"cellEditChanged"` and `"editState"`.
|
|
145
|
+
- **`cancel(key, columnName, restoredValue)`**:
|
|
146
|
+
- Discards an in-progress invalid edit, restoring the cell to its original valid state. Uncommitted typed values are discarded client-side without sending an RPC.
|
|
147
|
+
|
|
148
|
+
### New Row Flow (`addNewRow` / `addRow`)
|
|
149
|
+
|
|
150
|
+
1. **Configure**: `editSession.configureNewRow(columns, requiredColumns)` defines the expected column inputs.
|
|
151
|
+
2. **Draft**: `editSession.setNewRowValue(column, value)` updates `#newRowState.values`.
|
|
152
|
+
3. **Submit**: `editSession.addNewRow()` validates required columns and delegates to `addRow()`.
|
|
153
|
+
4. **`addRow(rowData)`**:
|
|
154
|
+
- Calls `dataSource.addRow(rowData)` with `#rowDefaults` merged in.
|
|
155
|
+
- On success, extracts the row key directly from the response map (`response.data.key`) and records it in `#addedRowKeys`.
|
|
156
|
+
- Increments `addCount`.
|
|
157
|
+
- Resets new-row form draft and increments `draftRevision`.
|
|
158
|
+
|
|
159
|
+
### Row Deletions (`deleteSelectedRows`)
|
|
160
|
+
|
|
161
|
+
- Captures `selectedRowsCount` before deleting.
|
|
162
|
+
- Exits early if `selectedRowsCount === 0`.
|
|
163
|
+
- Calls `dataSource.deleteSelectedRows(deleteMode)`.
|
|
164
|
+
- On success:
|
|
165
|
+
- Increments `deleteCount` by `selectedRowsCount`.
|
|
166
|
+
- **Clears selection**: Dispatches `dataSource.select({ type: "DESELECT_ALL" })` so deleted rows are immediately deselected, preventing accidental double-deletion and disabling the Delete button.
|
|
167
|
+
|
|
168
|
+
### Undo Flow (`undoRowChange`)
|
|
169
|
+
|
|
170
|
+
Invoked when the user clicks the Undo button on a row (`UndoCellRenderer`):
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
editSession.undoRowChange(key, action);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
1. **Reverts cell edits**: Clears recorded cell edits for `key`, restores previous values, and adjusts `editCount` / `invalidCount`.
|
|
177
|
+
2. **Reverts deletions**: If `action === "deleteRow"`, decrements `deleteCount`.
|
|
178
|
+
3. **Reverts insertions**: If the row was an inserted row:
|
|
179
|
+
- `action === "addRow"` OR
|
|
180
|
+
- Row key exists in private `#addedRowKeys`
|
|
181
|
+
- Decrements `addCount` and removes `key` from `#addedRowKeys`.
|
|
182
|
+
|
|
183
|
+
> **Robust Add-Delete-Undo**: Even if a row was added, then soft-deleted (`action` became `"deleteRow"`), and the server purges the row on undo without returning any special flag, `EditSession` identifies the row via `#addedRowKeys` and decrements **both** `deleteCount` and `addCount`, returning `editState` cleanly to `"clean"`.
|
|
184
|
+
|
|
185
|
+
### Session Completion (`end`) & Stale Handling
|
|
186
|
+
|
|
187
|
+
- **`end(saveChanges = false, force = false)`**:
|
|
188
|
+
- Transitions lifecycle to `"ending"`.
|
|
189
|
+
- Calls `dataSource.endEditSession(saveChanges, force)`.
|
|
190
|
+
- On success: clears all edits, resets counters, unsubscribes session DataSource, transitions to `"idle"`.
|
|
191
|
+
- **Stale Updates (`StaleUpdateError`)**: If the server rejects the commit due to concurrent edits, lifecycle transitions to `error { operation: "end" }` and `editState` becomes `"stale"`. The user can force-save with `end(true, true)`.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Direct Editing (`DirectEditSession`)
|
|
196
|
+
|
|
197
|
+
Implements `TableEditSession` for tables that support in-place cell editing without a session table or lifecycle state machine:
|
|
198
|
+
- Does not create session tables or track `editCount`.
|
|
199
|
+
- Each `commit()` forwards directly to `dataSource.editCell()`.
|
|
200
|
+
- `inEditMode` is always `false`.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## React Hooks & Context
|
|
205
|
+
|
|
206
|
+
### `useEditableTable`
|
|
207
|
+
|
|
208
|
+
The primary orchestrator hook for table components:
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
const {
|
|
212
|
+
canCancel, // boolean: safe to show/enable Cancel button
|
|
213
|
+
canSave, // boolean: active, dirty/stale, invalidCount === 0
|
|
214
|
+
dataSource, // Active DataSource (session DS in edit mode, else source DS)
|
|
215
|
+
sourceDataSource, // Always the source DataSource
|
|
216
|
+
sessionDataSource, // Session DataSource (when active)
|
|
217
|
+
editSession, // EditSession instance
|
|
218
|
+
lifecycle, // Current EditLifecycle
|
|
219
|
+
hasSelection, // boolean: whether rows are selected (controls Delete button)
|
|
220
|
+
isEditMode, // boolean: current edit mode flag
|
|
221
|
+
isEditSessionReady, // boolean: gates rendering the editable Table
|
|
222
|
+
isRowSelectable, // (dataRow) => boolean: blocks selection of soft-deleted rows
|
|
223
|
+
rowClassNameGenerators, // [vuu-edit-actions] in edit mode, else undefined
|
|
224
|
+
onCancel, // async handler: ends session and calls props.onCancel
|
|
225
|
+
onDelete, // async handler: deletes selected rows
|
|
226
|
+
onSave, // async handler: ends session (with optional force) and calls props.onSave
|
|
227
|
+
onUndoRowChange, // (key, action) => void
|
|
228
|
+
} = useEditableTable(props);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### `DataEditingProvider` & `useEditSession`
|
|
232
|
+
|
|
233
|
+
Distributes `EditSession` through React Context to descendant cells and controls:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
<DataEditingProvider editSession={editSession}>
|
|
237
|
+
{/* Table and EditButtons subtree */}
|
|
238
|
+
</DataEditingProvider>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `useEditSession()`: Returns `EditSession | undefined`.
|
|
242
|
+
- `useEditSession(true)`: Returns `EditSession`, throws if unmounted.
|
|
243
|
+
- `useTableEditSession()`: Returns generic `TableEditSession` (works with both `EditSession` and `DirectEditSession`).
|
|
244
|
+
|
|
245
|
+
### `useCellEdited`
|
|
246
|
+
|
|
247
|
+
Uses `useSyncExternalStore` for zero-lag cell edit detection. Returns `true` if `(rowKey, columnName)` has an uncommitted edit:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const isEdited = useCellEdited(editSession, rowKey, columnName);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### `useEditMode` & `EditModeProvider`
|
|
254
|
+
|
|
255
|
+
Optional context for coordinating a boolean edit/view mode toggle across independent toolbar and table components:
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
const { isEditMode, setEditMode } = useEditMode();
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## UI Components & Helpers
|
|
264
|
+
|
|
265
|
+
### `EditButtons`
|
|
266
|
+
|
|
267
|
+
Pre-built action buttons wired to `EditSession`:
|
|
268
|
+
- **Delete**: Enabled only when `hasSelection === true`.
|
|
269
|
+
- **Save**: Enabled when `canSave === true`. Displays `"Save (force)"` when `editState === "stale"`.
|
|
270
|
+
- **Cancel**: Enabled when `canCancel === true`.
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
<EditButtons
|
|
274
|
+
canCancel={canCancel}
|
|
275
|
+
canSave={canSave}
|
|
276
|
+
editSession={editSession}
|
|
277
|
+
hasSelection={hasSelection}
|
|
278
|
+
onCancel={onCancel}
|
|
279
|
+
onDelete={onDelete}
|
|
280
|
+
onSave={onSave}
|
|
281
|
+
/>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### `UndoCellRenderer`
|
|
285
|
+
|
|
286
|
+
Registered as `"vuu.undo-cell"`. Renders an Undo button in the row's action column:
|
|
287
|
+
- Dispatches `editSession.undoRowChange(key, action)` on click.
|
|
288
|
+
- Tooltip dynamically reflects action: `"Undo insert row"`, `"Undo delete row"`, or `"Undo row edits"`.
|
|
289
|
+
|
|
290
|
+
### `editActionRowClassNameGenerator`
|
|
291
|
+
|
|
292
|
+
Registered as `"vuu-edit-actions"`. Applies row CSS classes based on `dataRow.vuuAction`:
|
|
293
|
+
- `"addRow"` → `.vuuTableRow-inserted`
|
|
294
|
+
- `"deleteRow"` → `.vuuTableRow-deleted`
|
|
295
|
+
|
|
296
|
+
### `isRowSelectable` / `isEditRowReadOnly`
|
|
297
|
+
|
|
298
|
+
- `isEditRowReadOnly(dataRow)`: Returns `true` if `dataRow.vuuAction === "deleteRow"`.
|
|
299
|
+
- `isRowSelectable`: Exported from `useEditableTable` as `(dataRow) => !isEditRowReadOnly(dataRow)`. Pass to `<Table isRowSelectable={isRowSelectable} />` to prevent users from selecting soft-deleted rows.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Standard Integration Pattern
|
|
304
|
+
|
|
305
|
+
```tsx
|
|
306
|
+
import React, { useCallback, useState } from "react";
|
|
307
|
+
import { Table } from "@vuu-ui/vuu-table";
|
|
308
|
+
import {
|
|
309
|
+
DataEditingProvider,
|
|
310
|
+
EditButtons,
|
|
311
|
+
useEditableTable,
|
|
312
|
+
} from "@vuu-ui/vuu-data-editing";
|
|
313
|
+
|
|
314
|
+
export const EditableTableFeature = ({ sourceDataSource, tableConfig }) => {
|
|
315
|
+
const [isEditMode, setIsEditMode] = useState(false);
|
|
316
|
+
|
|
317
|
+
const {
|
|
318
|
+
canCancel,
|
|
319
|
+
canSave,
|
|
320
|
+
dataSource,
|
|
321
|
+
editSession,
|
|
322
|
+
hasSelection,
|
|
323
|
+
isEditSessionReady,
|
|
324
|
+
isRowSelectable,
|
|
325
|
+
onCancel,
|
|
326
|
+
onDelete,
|
|
327
|
+
onSave,
|
|
328
|
+
rowClassNameGenerators,
|
|
329
|
+
} = useEditableTable({
|
|
330
|
+
dataSource: sourceDataSource,
|
|
331
|
+
isEditMode,
|
|
332
|
+
onCancel: () => setIsEditMode(false),
|
|
333
|
+
onSave: () => setIsEditMode(false),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<DataEditingProvider editSession={editSession}>
|
|
338
|
+
<div className="table-container">
|
|
339
|
+
{(!isEditMode || isEditSessionReady) && (
|
|
340
|
+
<Table
|
|
341
|
+
config={tableConfig}
|
|
342
|
+
dataSource={dataSource}
|
|
343
|
+
isRowSelectable={isRowSelectable}
|
|
344
|
+
rowClassNameGenerators={rowClassNameGenerators}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div className="toolbar">
|
|
350
|
+
{!isEditMode ? (
|
|
351
|
+
<button onClick={() => setIsEditMode(true)}>Edit</button>
|
|
352
|
+
) : (
|
|
353
|
+
<EditButtons
|
|
354
|
+
canCancel={canCancel}
|
|
355
|
+
canSave={canSave}
|
|
356
|
+
editSession={editSession}
|
|
357
|
+
hasSelection={hasSelection}
|
|
358
|
+
onCancel={onCancel}
|
|
359
|
+
onDelete={onDelete}
|
|
360
|
+
onSave={onSave}
|
|
361
|
+
/>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
</DataEditingProvider>
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
```
|
|
368
|
+
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "4.0.0-alpha.
|
|
2
|
+
"version": "4.0.0-alpha.31",
|
|
3
3
|
"description": "Shared data editing APIs and React components for VUU UI",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"author": "heswell",
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
"type-defs": "node ../../scripts/build-type-defs.mjs"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@salt-ds/core": "1.
|
|
15
|
-
"@vuu-ui/vuu-ui-controls": "4.0.0-alpha.
|
|
16
|
-
"@vuu-ui/vuu-utils": "4.0.0-alpha.
|
|
14
|
+
"@salt-ds/core": "1.72.0",
|
|
15
|
+
"@vuu-ui/vuu-ui-controls": "4.0.0-alpha.31",
|
|
16
|
+
"@vuu-ui/vuu-utils": "4.0.0-alpha.31"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@vuu-ui/vuu-data-types": "4.0.0-alpha.
|
|
20
|
-
"@vuu-ui/vuu-protocol-types": "4.0.0-alpha.
|
|
21
|
-
"@vuu-ui/vuu-table-types": "4.0.0-alpha.
|
|
19
|
+
"@vuu-ui/vuu-data-types": "4.0.0-alpha.31",
|
|
20
|
+
"@vuu-ui/vuu-protocol-types": "4.0.0-alpha.31",
|
|
21
|
+
"@vuu-ui/vuu-table-types": "4.0.0-alpha.31"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"react": "^19.2.3"
|
|
@@ -31,8 +31,9 @@
|
|
|
31
31
|
],
|
|
32
32
|
"exports": {
|
|
33
33
|
".": {
|
|
34
|
+
"types": "./types/index.d.ts",
|
|
34
35
|
"import": "./src/index.js",
|
|
35
|
-
"
|
|
36
|
+
"default": "./src/index.js"
|
|
36
37
|
}
|
|
37
38
|
},
|
|
38
39
|
"module": "./src/index.js",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
|
+
import { EditSession } from "./EditSession.js";
|
|
3
4
|
const DataEditingContext = /*#__PURE__*/ createContext(void 0);
|
|
4
5
|
const DataEditingProvider = ({ children, editSession })=>/*#__PURE__*/ jsx(DataEditingContext.Provider, {
|
|
5
6
|
value: editSession,
|
|
@@ -7,7 +8,8 @@ const DataEditingProvider = ({ children, editSession })=>/*#__PURE__*/ jsx(DataE
|
|
|
7
8
|
});
|
|
8
9
|
function useEditSession(throwIfUnavailable = false) {
|
|
9
10
|
const editSession = useContext(DataEditingContext);
|
|
10
|
-
if (
|
|
11
|
-
if (throwIfUnavailable) throw Error("[useEditSession] no DataEditingContext in scope. You need to enclose editable component(s) with DataEditingProvider");
|
|
11
|
+
if (editSession instanceof EditSession) return editSession;
|
|
12
|
+
if (throwIfUnavailable) throw Error(void 0 === editSession ? "[useEditSession] no DataEditingContext in scope. You need to enclose editable component(s) with DataEditingProvider" : "[useEditSession] DataEditingProvider does not provide an EditSession");
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
const useTableEditSession = ()=>useContext(DataEditingContext);
|
|
15
|
+
export { DataEditingProvider, useEditSession, useTableEditSession };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EventEmitter } from "@vuu-ui/vuu-utils";
|
|
2
|
+
class DirectEditSession extends EventEmitter {
|
|
3
|
+
#dataSource;
|
|
4
|
+
constructor({ dataSource }){
|
|
5
|
+
super();
|
|
6
|
+
this.#dataSource = dataSource;
|
|
7
|
+
}
|
|
8
|
+
get dataSource() {
|
|
9
|
+
return this.#dataSource;
|
|
10
|
+
}
|
|
11
|
+
get inEditMode() {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
isCellEdited(_key, _columnName) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
async cancel(_key, _columnName, _restoredValue) {
|
|
18
|
+
return {
|
|
19
|
+
data: void 0,
|
|
20
|
+
type: "SUCCESS_RESULT"
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async commit(key, columnName, _originalValue, typedValue, isValid) {
|
|
24
|
+
if (!isValid) return {
|
|
25
|
+
errorMessage: "Invalid value",
|
|
26
|
+
type: "ERROR_RESULT"
|
|
27
|
+
};
|
|
28
|
+
const editCell = this.#dataSource.editCell;
|
|
29
|
+
if (void 0 === editCell) throw Error("[DirectEditSession] datasource does not support editCell");
|
|
30
|
+
const response = await editCell.call(this.#dataSource, key, columnName, typedValue);
|
|
31
|
+
if (void 0 === response) throw Error("[DirectEditSession] datasource returned no response from editCell");
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export { DirectEditSession };
|
package/src/EditButtons.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button } from "@salt-ds/core";
|
|
3
|
-
import { useCallback
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useEditState } from "./useEditState.js";
|
|
4
5
|
const EditButtons = ({ canCancel, canSave: _canSave, confirmCancel, confirmSave, editSession, hasSelection = false, onCancel, onDelete, onSave, saveLabel = "Save" })=>{
|
|
5
|
-
const
|
|
6
|
+
const editState = useEditState(editSession);
|
|
7
|
+
const isDirty = editSession?.isDirty ?? false;
|
|
6
8
|
const handleSave = useCallback(async ()=>{
|
|
7
9
|
if (confirmSave) {
|
|
8
10
|
const confirmed = await confirmSave();
|
|
@@ -15,24 +17,16 @@ const EditButtons = ({ canCancel, canSave: _canSave, confirmCancel, confirmSave,
|
|
|
15
17
|
onSave
|
|
16
18
|
]);
|
|
17
19
|
const handleCancel = useCallback(async ()=>{
|
|
18
|
-
if (confirmCancel) {
|
|
20
|
+
if (confirmCancel && isDirty) {
|
|
19
21
|
const confirmed = await confirmCancel();
|
|
20
22
|
if (!confirmed) return;
|
|
21
23
|
}
|
|
22
24
|
onCancel?.();
|
|
23
25
|
}, [
|
|
24
26
|
confirmCancel,
|
|
27
|
+
isDirty,
|
|
25
28
|
onCancel
|
|
26
29
|
]);
|
|
27
|
-
useEffect(()=>{
|
|
28
|
-
if (editSession) {
|
|
29
|
-
setEditState(editSession.editState);
|
|
30
|
-
editSession.on("editState", setEditState);
|
|
31
|
-
return ()=>editSession.removeListener("editState", setEditState);
|
|
32
|
-
}
|
|
33
|
-
}, [
|
|
34
|
-
editSession
|
|
35
|
-
]);
|
|
36
30
|
return /*#__PURE__*/ jsxs(Fragment, {
|
|
37
31
|
children: [
|
|
38
32
|
onDelete && /*#__PURE__*/ jsx(Button, {
|
package/src/EditModeProvider.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useState } from "react";
|
|
2
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
3
3
|
const EditModeContext = /*#__PURE__*/ createContext({
|
|
4
4
|
isEditMode: false,
|
|
5
5
|
setEditMode: ()=>"EditModeProvider in place"
|
|
6
6
|
});
|
|
7
|
-
const EditModeProvider = ({ children })=>{
|
|
8
|
-
const [isEditMode, setEditMode] = useState(
|
|
7
|
+
const EditModeProvider = ({ children, isEditMode: isEditModeProp = false })=>{
|
|
8
|
+
const [isEditMode, setEditMode] = useState(isEditModeProp);
|
|
9
|
+
useEffect(()=>{
|
|
10
|
+
setEditMode(isEditModeProp);
|
|
11
|
+
}, [
|
|
12
|
+
isEditModeProp
|
|
13
|
+
]);
|
|
9
14
|
return /*#__PURE__*/ jsx(EditModeContext.Provider, {
|
|
10
15
|
value: {
|
|
11
16
|
isEditMode,
|
package/src/EditSession.js
CHANGED
|
@@ -16,6 +16,7 @@ class SupersededEditError extends Error {
|
|
|
16
16
|
class EditSession extends EventEmitter {
|
|
17
17
|
static newRowKey = "__vuu_new_row__";
|
|
18
18
|
#rowEdits = new Map();
|
|
19
|
+
#addedRowKeys = new Set();
|
|
19
20
|
#editCount = 0;
|
|
20
21
|
#deleteCount = 0;
|
|
21
22
|
#addCount = 0;
|
|
@@ -66,6 +67,9 @@ class EditSession extends EventEmitter {
|
|
|
66
67
|
if (this.#isStale) return "stale";
|
|
67
68
|
return 0 === this.#editCount && 0 === this.#deleteCount && 0 === this.#addCount ? "clean" : "dirty";
|
|
68
69
|
}
|
|
70
|
+
get isDirty() {
|
|
71
|
+
return "clean" !== this.editState;
|
|
72
|
+
}
|
|
69
73
|
get canCancel() {
|
|
70
74
|
return "active" === this.lifecycle.status || "error" === this.lifecycle.status && "end" === this.lifecycle.operation;
|
|
71
75
|
}
|
|
@@ -85,19 +89,21 @@ class EditSession extends EventEmitter {
|
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
#setDeleteCount(val) {
|
|
88
|
-
|
|
92
|
+
const newCount = Math.max(0, val);
|
|
93
|
+
if (newCount !== this.#deleteCount) {
|
|
89
94
|
const oldState = this.editState;
|
|
90
95
|
const oldCount = this.#deleteCount;
|
|
91
|
-
this.#deleteCount =
|
|
92
|
-
this.#emitEditStateChange(oldState, 0 === oldCount || 0 ===
|
|
96
|
+
this.#deleteCount = newCount;
|
|
97
|
+
this.#emitEditStateChange(oldState, 0 === oldCount || 0 === newCount);
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
#setAddCount(val) {
|
|
96
|
-
|
|
101
|
+
const newCount = Math.max(0, val);
|
|
102
|
+
if (newCount !== this.#addCount) {
|
|
97
103
|
const oldState = this.editState;
|
|
98
104
|
const oldCount = this.#addCount;
|
|
99
|
-
this.#addCount =
|
|
100
|
-
this.#emitEditStateChange(oldState, 0 === oldCount || 0 ===
|
|
105
|
+
this.#addCount = newCount;
|
|
106
|
+
this.#emitEditStateChange(oldState, 0 === oldCount || 0 === newCount);
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
#setStale(isStale) {
|
|
@@ -231,10 +237,17 @@ class EditSession extends EventEmitter {
|
|
|
231
237
|
const deleteSelectedRows = this.dataSource?.deleteSelectedRows;
|
|
232
238
|
if (void 0 === deleteSelectedRows) throw Error("[EditSession] datasource does not support deleting rows");
|
|
233
239
|
const selectedRowsCount = this.dataSource?.selectedRowsCount ?? 0;
|
|
240
|
+
if (0 === selectedRowsCount) return {
|
|
241
|
+
data: void 0,
|
|
242
|
+
type: "SUCCESS_RESULT"
|
|
243
|
+
};
|
|
234
244
|
const response = await deleteSelectedRows.call(this.dataSource, this.#deleteMode);
|
|
235
245
|
if (void 0 === response) throw Error("[EditSession] datasource returned no response when deleting rows");
|
|
236
246
|
if (isRpcError(response)) return response;
|
|
237
247
|
if (selectedRowsCount > 0) this.#setDeleteCount(this.#deleteCount + selectedRowsCount);
|
|
248
|
+
await this.dataSource?.select?.({
|
|
249
|
+
type: "DESELECT_ALL"
|
|
250
|
+
});
|
|
238
251
|
return response;
|
|
239
252
|
}
|
|
240
253
|
async addRow(rowData = {}) {
|
|
@@ -245,7 +258,11 @@ class EditSession extends EventEmitter {
|
|
|
245
258
|
...rowData
|
|
246
259
|
});
|
|
247
260
|
if (void 0 === response) throw Error("[EditSession] datasource returned no response when adding row");
|
|
248
|
-
if (!isRpcError(response))
|
|
261
|
+
if (!isRpcError(response)) {
|
|
262
|
+
const key = response.data?.key;
|
|
263
|
+
if (null != key) this.#addedRowKeys.add(String(key));
|
|
264
|
+
this.#setAddCount(this.#addCount + 1);
|
|
265
|
+
}
|
|
249
266
|
return response;
|
|
250
267
|
}
|
|
251
268
|
hasRowChanges(key) {
|
|
@@ -284,9 +301,12 @@ class EditSession extends EventEmitter {
|
|
|
284
301
|
this.#invalidCount -= removedInvalidCount;
|
|
285
302
|
for (const columnName of changedColumns)this.emit("cellEditChanged", key, columnName);
|
|
286
303
|
}
|
|
287
|
-
if ("deleteRow" === action) this.#deleteCount
|
|
288
|
-
const wasInsertedRow = "addRow" === action ||
|
|
289
|
-
if (wasInsertedRow)
|
|
304
|
+
if ("deleteRow" === action) this.#deleteCount = Math.max(0, this.#deleteCount - 1);
|
|
305
|
+
const wasInsertedRow = "addRow" === action || this.#addedRowKeys.has(key);
|
|
306
|
+
if (wasInsertedRow) {
|
|
307
|
+
this.#addedRowKeys.delete(key);
|
|
308
|
+
this.#addCount = Math.max(0, this.#addCount - 1);
|
|
309
|
+
}
|
|
290
310
|
this.#emitEditStateChange(oldState);
|
|
291
311
|
return response;
|
|
292
312
|
}
|
|
@@ -301,6 +321,7 @@ class EditSession extends EventEmitter {
|
|
|
301
321
|
columnName
|
|
302
322
|
]));
|
|
303
323
|
this.#rowEdits.clear();
|
|
324
|
+
this.#addedRowKeys.clear();
|
|
304
325
|
this.#cellCommitRevisions.clear();
|
|
305
326
|
this.#editCount = 0;
|
|
306
327
|
this.#deleteCount = 0;
|
|
@@ -399,8 +420,10 @@ class EditSession extends EventEmitter {
|
|
|
399
420
|
sessionDataSource
|
|
400
421
|
});
|
|
401
422
|
try {
|
|
402
|
-
|
|
403
|
-
|
|
423
|
+
if ("unsubscribed" !== sessionDataSource.status || saveChanges) {
|
|
424
|
+
const response = await this.dataSource?.endEditSession?.(saveChanges, force);
|
|
425
|
+
if (isRpcError(response)) throw new Error(response.errorMessage);
|
|
426
|
+
}
|
|
404
427
|
this.#clearEdits();
|
|
405
428
|
this.#sessionDataSource = void 0;
|
|
406
429
|
this.#setLifecycle({
|
|
@@ -475,6 +498,15 @@ class EditSession extends EventEmitter {
|
|
|
475
498
|
#isLatestCellCommit(key, columnName, revision) {
|
|
476
499
|
return this.#cellCommitRevisions.get(key)?.get(columnName) === revision;
|
|
477
500
|
}
|
|
501
|
+
async cancel(key, columnName, restoredValue) {
|
|
502
|
+
const cellEdits = this.#rowEdits.get(key)?.cellEdits;
|
|
503
|
+
const cellEdit = cellEdits?.get(columnName);
|
|
504
|
+
if (this.inEditMode && cellEdits && cellEdit && !cellEdit.isValid) this.#storeCellEdit(key, cellEdits, columnName, cellEdit.originalValue, restoredValue, true);
|
|
505
|
+
return {
|
|
506
|
+
data: void 0,
|
|
507
|
+
type: "SUCCESS_RESULT"
|
|
508
|
+
};
|
|
509
|
+
}
|
|
478
510
|
async commit(key, columnName, originalValue, typedValue, isValid, { dataSourceValue, syncToDataSource = true } = {}) {
|
|
479
511
|
if ("active" !== this.#lifecycle.status && !("error" === this.#lifecycle.status && "end" === this.#lifecycle.operation)) throw new Error("No edit session in progress");
|
|
480
512
|
const revision = this.#setCellCommitRevision(key, columnName);
|
|
File without changes
|