@vuu-ui/vuu-data-editing 4.1.1 → 4.2.0
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 +4 -3
- package/package.json +6 -6
- package/src/EditSession.js +37 -4
- package/src/edit-field/EditField.js +17 -6
- package/src/edit-field/useEditField.js +65 -52
- package/src/useEditableTable.js +11 -1
- package/types/EditSession.d.ts +3 -1
- package/types/edit-field/useEditField.d.ts +1 -1
package/README.md
CHANGED
|
@@ -148,8 +148,8 @@ A cell is considered edited only when `originalValue !== editedValue && isValid
|
|
|
148
148
|
### New Row Flow (`addNewRow` / `addRow`)
|
|
149
149
|
|
|
150
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
|
|
151
|
+
2. **Draft**: `editSession.setNewRowValue(column, value)` updates `#newRowState.values` (or removes the column entry if cleared to `undefined`/`""`) and clears any recorded error for `column`. `editSession.setNewRowError(column, errorMessage)` records a validation error in `#newRowState.errors` and `editSession.clearNewRowError(column)` clears it on cancel.
|
|
152
|
+
3. **Submit**: `editSession.addNewRow()` validates required columns and checks that `#newRowState.errors` is empty before delegating to `addRow()`.
|
|
153
153
|
4. **`addRow(rowData)`**:
|
|
154
154
|
- Calls `dataSource.addRow(rowData)` with `#rowDefaults` merged in.
|
|
155
155
|
- On success, extracts the row key directly from the response map (`response.data.key`) and records it in `#addedRowKeys`.
|
|
@@ -190,8 +190,9 @@ editSession.undoRowChange(key, action);
|
|
|
190
190
|
|
|
191
191
|
- **`end(saveChanges = false, force = false)`**:
|
|
192
192
|
- Transitions lifecycle to `"ending"`.
|
|
193
|
-
- Calls `dataSource.endEditSession(saveChanges, force)
|
|
193
|
+
- Calls `dataSource.endEditSession(saveChanges, force)` (skipped when discarding an already-unsubscribed session DataSource).
|
|
194
194
|
- On success: clears all edits, resets counters, unsubscribes session DataSource, transitions to `"idle"`.
|
|
195
|
+
- **Session Disconnection / Unsubscribe**: When a WebSocket disconnects, `ServerProxy` drops session-table viewports and posts `subscribe-failed`. The session DataSource emits `"unsubscribed"`, `EditSession` automatically ends the session locally (`end(false)`) without issuing `endEditSession`, and `useEditableTable` invokes `onCancel` to exit edit mode back to view mode.
|
|
195
196
|
- **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)`.
|
|
196
197
|
|
|
197
198
|
---
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "4.
|
|
2
|
+
"version": "4.2.0",
|
|
3
3
|
"description": "Shared data editing APIs and React components for VUU UI",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"author": "heswell",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@salt-ds/core": "1.74.0",
|
|
15
|
-
"@vuu-ui/vuu-ui-controls": "4.
|
|
16
|
-
"@vuu-ui/vuu-utils": "4.
|
|
15
|
+
"@vuu-ui/vuu-ui-controls": "4.2.0",
|
|
16
|
+
"@vuu-ui/vuu-utils": "4.2.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@vuu-ui/vuu-data-types": "4.
|
|
20
|
-
"@vuu-ui/vuu-protocol-types": "4.
|
|
21
|
-
"@vuu-ui/vuu-table-types": "4.
|
|
19
|
+
"@vuu-ui/vuu-data-types": "4.2.0",
|
|
20
|
+
"@vuu-ui/vuu-protocol-types": "4.2.0",
|
|
21
|
+
"@vuu-ui/vuu-table-types": "4.2.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"react": "^19.2.3"
|
package/src/EditSession.js
CHANGED
|
@@ -123,6 +123,7 @@ class EditSession extends EventEmitter {
|
|
|
123
123
|
return this.#newRowState.columns.at(-1) === columnName;
|
|
124
124
|
}
|
|
125
125
|
isNewRowComplete() {
|
|
126
|
+
if (Object.keys(this.#newRowState.errors).length > 0) return false;
|
|
126
127
|
const requiredColumns = this.#newRowState.requiredColumns ?? this.#newRowState.columns;
|
|
127
128
|
return requiredColumns.every((column)=>{
|
|
128
129
|
const value = this.#newRowState.values[column];
|
|
@@ -149,13 +150,40 @@ class EditSession extends EventEmitter {
|
|
|
149
150
|
...this.#newRowState.errors
|
|
150
151
|
};
|
|
151
152
|
delete errors[column];
|
|
153
|
+
const values = {
|
|
154
|
+
...this.#newRowState.values
|
|
155
|
+
};
|
|
156
|
+
if (void 0 === value || "string" == typeof value && "" === value.trim()) delete values[column];
|
|
157
|
+
else values[column] = value;
|
|
152
158
|
this.#setNewRowState({
|
|
153
159
|
...this.#newRowState,
|
|
154
160
|
errors,
|
|
155
|
-
values
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
values
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
setNewRowError(column, errorMessage = "Invalid value") {
|
|
165
|
+
const values = {
|
|
166
|
+
...this.#newRowState.values
|
|
167
|
+
};
|
|
168
|
+
delete values[column];
|
|
169
|
+
this.#setNewRowState({
|
|
170
|
+
...this.#newRowState,
|
|
171
|
+
errors: {
|
|
172
|
+
...this.#newRowState.errors,
|
|
173
|
+
[column]: errorMessage
|
|
174
|
+
},
|
|
175
|
+
values
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
clearNewRowError(column) {
|
|
179
|
+
if (!(column in this.#newRowState.errors)) return;
|
|
180
|
+
const errors = {
|
|
181
|
+
...this.#newRowState.errors
|
|
182
|
+
};
|
|
183
|
+
delete errors[column];
|
|
184
|
+
this.#setNewRowState({
|
|
185
|
+
...this.#newRowState,
|
|
186
|
+
errors
|
|
159
187
|
});
|
|
160
188
|
}
|
|
161
189
|
async addNewRow() {
|
|
@@ -364,6 +392,9 @@ class EditSession extends EventEmitter {
|
|
|
364
392
|
this.#lifecycle = lifecycle;
|
|
365
393
|
this.emit("lifecycle", lifecycle);
|
|
366
394
|
}
|
|
395
|
+
#handleSessionUnsubscribed = ()=>{
|
|
396
|
+
if (this.canCancel) this.end(false);
|
|
397
|
+
};
|
|
367
398
|
#enqueue(operation) {
|
|
368
399
|
const result = this.#transitionQueue.then(operation);
|
|
369
400
|
this.#transitionQueue = result.then(()=>void 0, ()=>void 0);
|
|
@@ -388,6 +419,7 @@ class EditSession extends EventEmitter {
|
|
|
388
419
|
const sessionDataSource = "beginEditSession" === this.#editSessionApi ? await sourceDataSource?.beginEditSession?.(toEditSessionMode(copyOption)) : await sourceDataSource?.createSessionDataSource?.(copyOption, sessionType);
|
|
389
420
|
if (!sessionDataSource) throw new Error(`[EditSession] datasource does not support ${this.#editSessionApi}`);
|
|
390
421
|
this.#sessionDataSource = sessionDataSource;
|
|
422
|
+
sessionDataSource.on?.("unsubscribed", this.#handleSessionUnsubscribed);
|
|
391
423
|
this.#setStale(false);
|
|
392
424
|
this.#setLifecycle({
|
|
393
425
|
status: "active",
|
|
@@ -443,6 +475,7 @@ class EditSession extends EventEmitter {
|
|
|
443
475
|
});
|
|
444
476
|
try {
|
|
445
477
|
if ("unsubscribed" !== sessionDataSource.status || saveChanges) await this.dataSource?.endEditSession?.(saveChanges, force);
|
|
478
|
+
sessionDataSource.removeListener?.("unsubscribed", this.#handleSessionUnsubscribed);
|
|
446
479
|
this.#clearEdits();
|
|
447
480
|
this.#sessionDataSource = void 0;
|
|
448
481
|
this.#setLifecycle({
|
|
@@ -16,15 +16,25 @@ const EditField = ({ dataRow, deferNewRow = false, label, name, readOnly = false
|
|
|
16
16
|
...options
|
|
17
17
|
});
|
|
18
18
|
const onEdit = useCallback(async (editState, editPhase)=>{
|
|
19
|
-
const { isValid = true, previousValue = "", value } = editState;
|
|
19
|
+
const { editType, isValid = true, previousValue = "", value } = editState;
|
|
20
20
|
if ("commit" === editPhase && editSession) {
|
|
21
21
|
if (editSession.isNewRow(dataRow.key)) {
|
|
22
|
+
if ("cancel" === editType) {
|
|
23
|
+
editSession.clearNewRowError(name);
|
|
24
|
+
return {
|
|
25
|
+
data: void 0,
|
|
26
|
+
type: "SUCCESS_RESULT"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
22
29
|
const isEmptyValue = "string" == typeof value && "" === value.trim();
|
|
23
|
-
if (!isValid && !isEmptyValue)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
if (!isValid && !isEmptyValue) {
|
|
31
|
+
editSession.setNewRowError(name);
|
|
32
|
+
return {
|
|
33
|
+
errorMessage: "Invalid value",
|
|
34
|
+
type: "ERROR_RESULT"
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
editSession.setNewRowValue(name, isEmptyValue ? void 0 : value);
|
|
28
38
|
if (!deferNewRow && (editSession.isNewRowFinalColumn(name) || editSession.isNewRowComplete())) return editSession.addNewRow();
|
|
29
39
|
return {
|
|
30
40
|
data: void 0,
|
|
@@ -54,6 +64,7 @@ const EditField = ({ dataRow, deferNewRow = false, label, name, readOnly = false
|
|
|
54
64
|
const column = {
|
|
55
65
|
label,
|
|
56
66
|
name,
|
|
67
|
+
required,
|
|
57
68
|
serverDataType: "string"
|
|
58
69
|
};
|
|
59
70
|
const { inputProps, onChange, onDropdownSelectionChange, onKeyDown, value } = useEditField({
|
|
@@ -8,11 +8,13 @@ const useEditField = ({ column, value, onEdit, textType, type = "string" })=>{
|
|
|
8
8
|
});
|
|
9
9
|
const initialValueRef = useRef(value?.toString() ?? "");
|
|
10
10
|
const isDirtyRef = useRef(false);
|
|
11
|
+
const hasUnresolvedErrorRef = useRef(false);
|
|
11
12
|
useEffect(()=>{
|
|
12
13
|
const nextValue = stringValueOf(value);
|
|
13
14
|
if (initialValueRef.current !== nextValue) {
|
|
14
15
|
initialValueRef.current = nextValue;
|
|
15
16
|
isDirtyRef.current = false;
|
|
17
|
+
hasUnresolvedErrorRef.current = false;
|
|
16
18
|
setEditState((editState)=>({
|
|
17
19
|
...editState,
|
|
18
20
|
message: void 0,
|
|
@@ -23,59 +25,69 @@ const useEditField = ({ column, value, onEdit, textType, type = "string" })=>{
|
|
|
23
25
|
value
|
|
24
26
|
]);
|
|
25
27
|
const commit = useCallback(async (value = editState.value)=>{
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
28
|
+
const { current: initialValue } = initialValueRef;
|
|
29
|
+
const previousValue = getTypedValue(initialValue, type);
|
|
30
|
+
const isEmptyOptional = column?.required === false && "" === value.trim();
|
|
31
|
+
const result = isEmptyOptional ? void 0 : column.clientSideEditValidationCheck?.(value, "*");
|
|
32
|
+
if (result?.ok === false) {
|
|
33
|
+
hasUnresolvedErrorRef.current = true;
|
|
34
|
+
setEditState((state)=>({
|
|
35
|
+
...state,
|
|
36
|
+
message: result?.messages?.join(",")
|
|
37
|
+
}));
|
|
38
|
+
await onEdit?.({
|
|
39
|
+
editType: "commit",
|
|
40
|
+
isValid: false,
|
|
41
|
+
previousValue,
|
|
42
|
+
value
|
|
43
|
+
}, "commit");
|
|
44
|
+
} else try {
|
|
45
|
+
const typedValue = isEmptyOptional ? "" : getTypedValue(value, type, true);
|
|
46
|
+
const response = await onEdit?.({
|
|
47
|
+
editType: "commit",
|
|
48
|
+
isValid: true,
|
|
49
|
+
previousValue,
|
|
50
|
+
value: typedValue
|
|
51
|
+
}, "commit");
|
|
52
|
+
if (isRpcSuccess(response)) {
|
|
53
|
+
isDirtyRef.current = false;
|
|
54
|
+
hasUnresolvedErrorRef.current = false;
|
|
55
|
+
setEditState((state)=>({
|
|
56
|
+
editing: false,
|
|
57
|
+
previousValue: state.previousValue === value ? void 0 : void 0 === state.previousValue ? initialValue : state.previousValue,
|
|
58
|
+
value
|
|
59
|
+
}));
|
|
60
|
+
} else if (isRpcError(response)) {
|
|
61
|
+
hasUnresolvedErrorRef.current = true;
|
|
62
|
+
setEditState((state)=>({
|
|
63
|
+
...state,
|
|
64
|
+
message: response.errorMessage
|
|
65
|
+
}));
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
initialValueRef.current = value;
|
|
69
|
+
return true;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
if (e instanceof DataValidationError) {
|
|
72
|
+
hasUnresolvedErrorRef.current = true;
|
|
73
|
+
const { actualType, expectedType, message } = e;
|
|
74
|
+
if (column) {
|
|
75
|
+
const { name, label = name } = column;
|
|
76
|
+
const message = `${label} is a ${expectedType} value, data entered is ${actualType} `;
|
|
50
77
|
setEditState((state)=>({
|
|
51
|
-
...state,
|
|
52
|
-
message: response.errorMessage
|
|
53
|
-
}));
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
initialValueRef.current = value;
|
|
57
|
-
return true;
|
|
58
|
-
} catch (e) {
|
|
59
|
-
if (e instanceof DataValidationError) {
|
|
60
|
-
const { actualType, expectedType, message } = e;
|
|
61
|
-
if (column) {
|
|
62
|
-
const { name, label = name } = column;
|
|
63
|
-
const message = `${label} is a ${expectedType} value, data entered is ${actualType} `;
|
|
64
|
-
setEditState((state)=>({
|
|
65
|
-
...state,
|
|
66
|
-
message
|
|
67
|
-
}));
|
|
68
|
-
onEdit?.({
|
|
69
|
-
editType: "commit",
|
|
70
|
-
isValid: false,
|
|
71
|
-
previousValue,
|
|
72
|
-
value
|
|
73
|
-
}, "commit");
|
|
74
|
-
} else setEditState((state)=>({
|
|
75
78
|
...state,
|
|
76
79
|
message
|
|
77
80
|
}));
|
|
78
|
-
|
|
81
|
+
await onEdit?.({
|
|
82
|
+
editType: "commit",
|
|
83
|
+
isValid: false,
|
|
84
|
+
previousValue,
|
|
85
|
+
value
|
|
86
|
+
}, "commit");
|
|
87
|
+
} else setEditState((state)=>({
|
|
88
|
+
...state,
|
|
89
|
+
message
|
|
90
|
+
}));
|
|
79
91
|
}
|
|
80
92
|
}
|
|
81
93
|
return false;
|
|
@@ -86,9 +98,10 @@ const useEditField = ({ column, value, onEdit, textType, type = "string" })=>{
|
|
|
86
98
|
type
|
|
87
99
|
]);
|
|
88
100
|
const toggleActivation = useCallback(async (input, cancel = false)=>{
|
|
89
|
-
if (editState.editing) if (isDirtyRef.current) if (cancel) {
|
|
101
|
+
if (editState.editing) if (isDirtyRef.current || hasUnresolvedErrorRef.current) if (cancel) {
|
|
90
102
|
const { value: previousValue } = editState;
|
|
91
103
|
isDirtyRef.current = false;
|
|
104
|
+
hasUnresolvedErrorRef.current = false;
|
|
92
105
|
setEditState({
|
|
93
106
|
editing: false,
|
|
94
107
|
value: initialValueRef.current,
|
|
@@ -98,7 +111,7 @@ const useEditField = ({ column, value, onEdit, textType, type = "string" })=>{
|
|
|
98
111
|
editType: "cancel",
|
|
99
112
|
isValid: true,
|
|
100
113
|
previousValue,
|
|
101
|
-
value: getTypedValue(initialValueRef.current, type
|
|
114
|
+
value: getTypedValue(initialValueRef.current, type) ?? initialValueRef.current
|
|
102
115
|
}, "commit");
|
|
103
116
|
} else {
|
|
104
117
|
const commitSuccessful = await commit();
|
|
@@ -147,7 +160,7 @@ const useEditField = ({ column, value, onEdit, textType, type = "string" })=>{
|
|
|
147
160
|
]);
|
|
148
161
|
const handleBlur = useCallback(async ()=>{
|
|
149
162
|
if (editState.editing) {
|
|
150
|
-
if (isDirtyRef.current) await commit();
|
|
163
|
+
if (isDirtyRef.current || hasUnresolvedErrorRef.current) await commit();
|
|
151
164
|
setEditState((editState)=>({
|
|
152
165
|
...editState,
|
|
153
166
|
editing: false
|
package/src/useEditableTable.js
CHANGED
|
@@ -90,10 +90,20 @@ const useEditableTable = ({ columns, dataSource: dataSourceProp, deleteMode = "s
|
|
|
90
90
|
editSession.reconcileWithSessionSchema();
|
|
91
91
|
setSubscribedSessionDataSource(sessionDataSource);
|
|
92
92
|
};
|
|
93
|
+
const handleUnsubscribed = ()=>{
|
|
94
|
+
if (editSession.canCancel) editSession.end().then(()=>{
|
|
95
|
+
setSelectionCount(0);
|
|
96
|
+
onCancelRef.current();
|
|
97
|
+
});
|
|
98
|
+
};
|
|
93
99
|
setSubscribedSessionDataSource(void 0);
|
|
94
100
|
sessionDataSource.on("subscribed", handleSubscribed);
|
|
101
|
+
sessionDataSource.on("unsubscribed", handleUnsubscribed);
|
|
95
102
|
if ("subscribed" === sessionDataSource.status && sessionDataSource.tableSchema) handleSubscribed();
|
|
96
|
-
return ()=>
|
|
103
|
+
return ()=>{
|
|
104
|
+
sessionDataSource.removeListener("subscribed", handleSubscribed);
|
|
105
|
+
sessionDataSource.removeListener("unsubscribed", handleUnsubscribed);
|
|
106
|
+
};
|
|
97
107
|
}, [
|
|
98
108
|
editSession,
|
|
99
109
|
sessionDataSource
|
package/types/EditSession.d.ts
CHANGED
|
@@ -78,7 +78,9 @@ export declare class EditSession extends EventEmitter<EditSessionEvents> impleme
|
|
|
78
78
|
isNewRowFinalColumn(columnName: string): boolean;
|
|
79
79
|
isNewRowComplete(): boolean;
|
|
80
80
|
configureNewRow(columns: readonly string[], requiredColumns?: readonly string[]): void;
|
|
81
|
-
setNewRowValue(column: string, value: VuuRowDataItemType): void;
|
|
81
|
+
setNewRowValue(column: string, value: VuuRowDataItemType | undefined): void;
|
|
82
|
+
setNewRowError(column: string, errorMessage?: string): void;
|
|
83
|
+
clearNewRowError(column: string): void;
|
|
82
84
|
addNewRow(): Promise<RpcResult>;
|
|
83
85
|
deleteSelectedRows(): Promise<RpcResult>;
|
|
84
86
|
addRow(rowData?: Record<string, VuuRowDataItemType>): Promise<RpcResult>;
|
|
@@ -4,7 +4,7 @@ import { type ChangeEventHandler, type FocusEventHandler, type KeyboardEvent, ty
|
|
|
4
4
|
import type { LookupOption } from "../lookup-values/useLookupValues";
|
|
5
5
|
import type { TextFieldType } from "./EditField";
|
|
6
6
|
export interface EditFieldHookProps<T extends VuuRowDataItemType = VuuRowDataItemType> {
|
|
7
|
-
column: Pick<RuntimeColumnDescriptor, "clientSideEditValidationCheck" | "label" | "name">;
|
|
7
|
+
column: Pick<RuntimeColumnDescriptor, "clientSideEditValidationCheck" | "label" | "name" | "required">;
|
|
8
8
|
value?: T;
|
|
9
9
|
onEdit?: TableCellEditHandler;
|
|
10
10
|
textType?: TextFieldType;
|