@vuu-ui/vuu-data-editing 5.0.0-alpha.3 → 5.0.0-alpha.4

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
@@ -183,8 +183,8 @@ A cell is considered edited only when `originalValue !== editedValue && isValid
183
183
  ### New Row Flow (`addNewRow` / `addRow`)
184
184
 
185
185
  1. **Configure**: `editSession.configureNewRow(columns, requiredColumns)` defines the expected column inputs.
186
- 2. **Draft**: `editSession.setNewRowValue(column, value)` updates `#newRowState.values`.
187
- 3. **Submit**: `editSession.addNewRow()` validates required columns and delegates to `addRow()`.
186
+ 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.
187
+ 3. **Submit**: `editSession.addNewRow()` validates required columns and checks that `#newRowState.errors` is empty before delegating to `addRow()`.
188
188
  4. **`addRow(rowData)`**:
189
189
  - Calls `dataSource.addRow(rowData)` with `#rowDefaults` merged in.
190
190
  - On success, extracts the row key directly from the response map (`response.data.key`) and records it in `#addedRowKeys`.
@@ -226,8 +226,9 @@ editSession.undoRowChange(key, action);
226
226
 
227
227
  - **`end(saveChanges = false, force = false)`**:
228
228
  - Transitions lifecycle to `"ending"`.
229
- - Calls `dataSource.endEditSession(saveChanges, force)`.
229
+ - Calls `dataSource.endEditSession(saveChanges, force)` (skipped when discarding an already-unsubscribed session DataSource).
230
230
  - On success: clears all edits, resets counters, unsubscribes session DataSource, transitions to `"idle"`.
231
+ - **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.
231
232
  - **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)`.
232
233
 
233
234
  ---
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.0.0-alpha.3",
2
+ "version": "5.0.0-alpha.4",
3
3
  "description": "Shared data editing APIs and React components for VUU UI",
4
4
  "main": "./src/index.js",
5
5
  "author": "heswell",
@@ -12,14 +12,14 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@salt-ds/core": "1.74.0",
15
- "@vuu-ui/core": "5.0.0-alpha.3",
16
- "@vuu-ui/vuu-ui-controls": "5.0.0-alpha.3",
17
- "@vuu-ui/vuu-utils": "5.0.0-alpha.3"
15
+ "@vuu-ui/core": "5.0.0-alpha.4",
16
+ "@vuu-ui/vuu-ui-controls": "5.0.0-alpha.4",
17
+ "@vuu-ui/vuu-utils": "5.0.0-alpha.4"
18
18
  },
19
19
  "devDependencies": {
20
- "@vuu-ui/vuu-data-types": "5.0.0-alpha.3",
21
- "@vuu-ui/vuu-protocol-types": "5.0.0-alpha.3",
22
- "@vuu-ui/vuu-table-types": "5.0.0-alpha.3"
20
+ "@vuu-ui/vuu-data-types": "5.0.0-alpha.4",
21
+ "@vuu-ui/vuu-protocol-types": "5.0.0-alpha.4",
22
+ "@vuu-ui/vuu-table-types": "5.0.0-alpha.4"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": "^19.2.3"
@@ -123,6 +123,8 @@ class EditSession extends EventEmitter {
123
123
  return this.#newRowState.columns.at(-1) === columnName;
124
124
  }
125
125
  isNewRowComplete() {
126
+ const { errors, rowErrorColumn } = this.#newRowState;
127
+ if (Object.keys(errors).some((column)=>column !== rowErrorColumn)) return false;
126
128
  const requiredColumns = this.#newRowState.requiredColumns ?? this.#newRowState.columns;
127
129
  return requiredColumns.every((column)=>{
128
130
  const value = this.#newRowState.values[column];
@@ -151,13 +153,45 @@ class EditSession extends EventEmitter {
151
153
  };
152
154
  delete errors[column];
153
155
  if (void 0 !== rowErrorColumn) delete errors[rowErrorColumn];
156
+ const values = {
157
+ ...newRowState.values
158
+ };
159
+ if (void 0 === value || "string" == typeof value && "" === value.trim()) delete values[column];
160
+ else values[column] = value;
154
161
  this.#setNewRowState({
155
162
  ...newRowState,
156
163
  errors,
157
- values: {
158
- ...newRowState.values,
159
- [column]: value
160
- }
164
+ values
165
+ });
166
+ }
167
+ setNewRowError(column, errorMessage = "Invalid value") {
168
+ const { rowErrorColumn, ...newRowState } = this.#newRowState;
169
+ const errors = {
170
+ ...newRowState.errors
171
+ };
172
+ if (void 0 !== rowErrorColumn) delete errors[rowErrorColumn];
173
+ const values = {
174
+ ...newRowState.values
175
+ };
176
+ delete values[column];
177
+ this.#setNewRowState({
178
+ ...newRowState,
179
+ errors: {
180
+ ...errors,
181
+ [column]: errorMessage
182
+ },
183
+ values
184
+ });
185
+ }
186
+ clearNewRowError(column) {
187
+ if (!(column in this.#newRowState.errors)) return;
188
+ const errors = {
189
+ ...this.#newRowState.errors
190
+ };
191
+ delete errors[column];
192
+ this.#setNewRowState({
193
+ ...this.#newRowState,
194
+ errors
161
195
  });
162
196
  }
163
197
  clearNewRow() {
@@ -392,6 +426,9 @@ class EditSession extends EventEmitter {
392
426
  this.#lifecycle = lifecycle;
393
427
  this.emit("lifecycle", lifecycle);
394
428
  }
429
+ #handleSessionUnsubscribed = ()=>{
430
+ if (this.canCancel) this.end(false);
431
+ };
395
432
  #enqueue(operation) {
396
433
  const result = this.#transitionQueue.then(operation);
397
434
  this.#transitionQueue = result.then(()=>void 0, ()=>void 0);
@@ -416,6 +453,7 @@ class EditSession extends EventEmitter {
416
453
  const sessionDataSource = "beginEditSession" === this.#editSessionApi ? await sourceDataSource?.beginEditSession?.(toEditSessionMode(copyOption)) : await sourceDataSource?.createSessionDataSource?.(copyOption, sessionType);
417
454
  if (!sessionDataSource) throw new Error(`[EditSession] datasource does not support ${this.#editSessionApi}`);
418
455
  this.#sessionDataSource = sessionDataSource;
456
+ sessionDataSource.on?.("unsubscribed", this.#handleSessionUnsubscribed);
419
457
  this.#setStale(false);
420
458
  this.#setLifecycle({
421
459
  status: "active",
@@ -477,6 +515,7 @@ class EditSession extends EventEmitter {
477
515
  const response = await this.dataSource?.endEditSession?.(saveChanges, force);
478
516
  if (isRpcError(response)) throw new Error(response.errorMessage);
479
517
  }
518
+ sessionDataSource.removeListener?.("unsubscribed", this.#handleSessionUnsubscribed);
480
519
  this.#clearEdits();
481
520
  this.#sessionDataSource = void 0;
482
521
  this.#setLifecycle({
@@ -53,15 +53,25 @@ const TypedEditField = ({ clientSideEditValidationCheck, dataRow: dataRowProp, d
53
53
  formSetValue
54
54
  ]);
55
55
  const onEdit = useCallback(async (editState, editPhase)=>{
56
- const { isValid = true, previousValue = "", value } = editState;
56
+ const { editType, isValid = true, previousValue = "", value } = editState;
57
57
  if ("commit" === editPhase && tableEditSession) {
58
58
  if (editSession?.isNewRow(dataRow.key)) {
59
+ if ("cancel" === editType) {
60
+ editSession.clearNewRowError(name);
61
+ return {
62
+ data: void 0,
63
+ type: "SUCCESS_RESULT"
64
+ };
65
+ }
59
66
  const isEmptyValue = "string" == typeof value && "" === value.trim();
60
- if (!isValid && !isEmptyValue) return {
61
- errorMessage: "Invalid value",
62
- type: "ERROR_RESULT"
63
- };
64
- setNewRowValue(name, value);
67
+ if (!isValid && !isEmptyValue) {
68
+ editSession.setNewRowError(name);
69
+ return {
70
+ errorMessage: "Invalid value",
71
+ type: "ERROR_RESULT"
72
+ };
73
+ }
74
+ setNewRowValue(name, isEmptyValue ? void 0 : value);
65
75
  if (!deferNewRow && (editSession.isNewRowFinalColumn(name) || editSession.isNewRowComplete())) return editSession.addNewRow();
66
76
  return {
67
77
  data: void 0,
@@ -112,6 +122,7 @@ const TypedEditField = ({ clientSideEditValidationCheck, dataRow: dataRowProp, d
112
122
  clientSideEditValidationCheck,
113
123
  label,
114
124
  name,
125
+ required,
115
126
  serverDataType: "checkbox" === type && "string" === serverDataType ? "boolean" : serverDataType
116
127
  };
117
128
  const { commit, inputProps, onChange, onDropdownSelectionChange, onKeyDown, value, warningMessage: fieldMessage } = 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
@@ -101,10 +101,20 @@ const useEditableTable = ({ columns, dataSource: dataSourceProp, deleteMode = "s
101
101
  editSession.reconcileWithSessionSchema();
102
102
  setSubscribedSessionDataSource(sessionDataSource);
103
103
  };
104
+ const handleUnsubscribed = ()=>{
105
+ if (editSession.canCancel) editSession.end().then(()=>{
106
+ setSelectionCount(0);
107
+ onCancelRef.current();
108
+ });
109
+ };
104
110
  setSubscribedSessionDataSource(void 0);
105
111
  sessionDataSource.on("subscribed", handleSubscribed);
112
+ sessionDataSource.on("unsubscribed", handleUnsubscribed);
106
113
  if ("subscribed" === sessionDataSource.status && sessionDataSource.tableSchema) handleSubscribed();
107
- return ()=>sessionDataSource.removeListener("subscribed", handleSubscribed);
114
+ return ()=>{
115
+ sessionDataSource.removeListener("subscribed", handleSubscribed);
116
+ sessionDataSource.removeListener("unsubscribed", handleUnsubscribed);
117
+ };
108
118
  }, [
109
119
  editSession,
110
120
  sessionDataSource
@@ -84,7 +84,9 @@ export declare class EditSession extends EventEmitter<EditSessionEvents> impleme
84
84
  isNewRowFinalColumn(columnName: string): boolean;
85
85
  isNewRowComplete(): boolean;
86
86
  configureNewRow(columns: readonly string[], requiredColumns?: readonly string[]): void;
87
- setNewRowValue(column: string, value: VuuRowDataItemType): void;
87
+ setNewRowValue(column: string, value: VuuRowDataItemType | undefined): void;
88
+ setNewRowError(column: string, errorMessage?: string): void;
89
+ clearNewRowError(column: string): void;
88
90
  /** Discards the new-row draft, leaving the rest of the session untouched. */
89
91
  clearNewRow(): void;
90
92
  addNewRow(): 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;
@@ -22,7 +22,7 @@ export declare const EditFormContext: import("react").Context<{
22
22
  mode: import("..").EditFormMode;
23
23
  rowAdded: boolean;
24
24
  saving: boolean;
25
- setValue: (name: string, value: import("@vuu-ui/vuu-protocol-types").VuuRowDataItemType) => void;
25
+ setValue: (name: string, value: import("@vuu-ui/vuu-protocol-types").VuuRowDataItemType | undefined) => void;
26
26
  submit: (force?: boolean) => Promise<boolean>;
27
27
  } | undefined>;
28
28
  /**
@@ -54,6 +54,6 @@ export declare const useEditFormContext: () => {
54
54
  mode: import("..").EditFormMode;
55
55
  rowAdded: boolean;
56
56
  saving: boolean;
57
- setValue: (name: string, value: import("@vuu-ui/vuu-protocol-types").VuuRowDataItemType) => void;
57
+ setValue: (name: string, value: import("@vuu-ui/vuu-protocol-types").VuuRowDataItemType | undefined) => void;
58
58
  submit: (force?: boolean) => Promise<boolean>;
59
59
  } | undefined;
@@ -121,7 +121,7 @@ export declare const useEditForm: ({ columns, dataRow, dataSource, deleteMode, e
121
121
  /** Create mode: the row was added, saving the session failed. */
122
122
  rowAdded: boolean;
123
123
  saving: boolean;
124
- setValue: (name: string, value: VuuRowDataItemType) => void;
124
+ setValue: (name: string, value: VuuRowDataItemType | undefined) => void;
125
125
  submit: (force?: boolean) => Promise<boolean>;
126
126
  };
127
127
  export type EditFormHookResult = ReturnType<typeof useEditForm>;