@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 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 delegates to `addRow()`.
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.1.1",
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.1.1",
16
- "@vuu-ui/vuu-utils": "4.1.1"
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.1.1",
20
- "@vuu-ui/vuu-protocol-types": "4.1.1",
21
- "@vuu-ui/vuu-table-types": "4.1.1"
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"
@@ -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
- ...this.#newRowState.values,
157
- [column]: value
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) return {
24
- errorMessage: "Invalid value",
25
- type: "ERROR_RESULT"
26
- };
27
- editSession.setNewRowValue(name, value);
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 result = column.clientSideEditValidationCheck?.(value, "*");
27
- if (result?.ok === false) setEditState((state)=>({
28
- ...state,
29
- message: result?.messages?.join(",")
30
- }));
31
- else {
32
- const { current: initialValue } = initialValueRef;
33
- const previousValue = getTypedValue(initialValue, type);
34
- try {
35
- const typedValue = getTypedValue(value, type, true);
36
- const response = await onEdit?.({
37
- editType: "commit",
38
- isValid: true,
39
- previousValue,
40
- value: typedValue
41
- }, "commit");
42
- if (isRpcSuccess(response)) {
43
- isDirtyRef.current = false;
44
- setEditState((state)=>({
45
- editing: false,
46
- previousValue: state.previousValue === value ? void 0 : void 0 === state.previousValue ? initialValue : state.previousValue,
47
- value
48
- }));
49
- } else if (isRpcError(response)) {
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, true)
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
@@ -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 ()=>sessionDataSource.removeListener("subscribed", handleSubscribed);
103
+ return ()=>{
104
+ sessionDataSource.removeListener("subscribed", handleSubscribed);
105
+ sessionDataSource.removeListener("unsubscribed", handleUnsubscribed);
106
+ };
97
107
  }, [
98
108
  editSession,
99
109
  sessionDataSource
@@ -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;