@omnitend/dashboard-for-laravel 0.43.0 → 0.45.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.
Files changed (40) hide show
  1. package/dist/components/base/DFormSelect.vue.d.ts +1 -1
  2. package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
  3. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  4. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  5. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  6. package/dist/components/extended/DXLoading.vue.d.ts +3 -0
  7. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  8. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  9. package/dist/composables/useAsyncOptions.d.ts +14 -1
  10. package/dist/composables/useForm.d.ts +26 -0
  11. package/dist/dashboard-for-laravel.js +13605 -13058
  12. package/dist/dashboard-for-laravel.js.map +1 -1
  13. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  14. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/style.css +1 -1
  17. package/dist/types/index.d.ts +12 -0
  18. package/dist/utils/formErrorTargets.d.ts +86 -0
  19. package/docs/public/api-reference.json +148 -8
  20. package/docs/public/docs-map.md +3 -2
  21. package/docs/public/llms.txt +3 -2
  22. package/package.json +9 -8
  23. package/resources/css/theme.scss +176 -0
  24. package/resources/js/components/base/DFormSelect.vue +5 -2
  25. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  26. package/resources/js/components/extended/DXField.vue +51 -17
  27. package/resources/js/components/extended/DXForm.vue +676 -28
  28. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  29. package/resources/js/components/extended/DXFormField.vue +11 -2
  30. package/resources/js/components/extended/DXLoading.vue +18 -11
  31. package/resources/js/components/extended/DXRepeater.vue +30 -4
  32. package/resources/js/components/extended/DXTable.vue +38 -9
  33. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  34. package/resources/js/composables/useAsyncOptions.ts +30 -5
  35. package/resources/js/composables/useForm.ts +196 -18
  36. package/resources/js/composables/useResourceEditor.ts +140 -107
  37. package/resources/js/index.ts +15 -0
  38. package/resources/js/types/index.ts +13 -0
  39. package/resources/js/utils/formErrorTargets.ts +317 -0
  40. package/scripts/mcp-server.mjs +58 -6
@@ -13,6 +13,19 @@ export interface ValidationErrors {
13
13
 
14
14
  export type FormError = ApiError;
15
15
 
16
+ /**
17
+ * What the most recent failed submit returned (#194): the server's message and
18
+ * a COPY of its validation errors, frozen at the moment the submit failed.
19
+ * The live `form.errors` empties as the user fixes fields; this does not, so a
20
+ * summary built from it keeps listing every message until the next submit.
21
+ */
22
+ export interface SubmitFailure {
23
+ /** A message fit to show a user (never empty, never a raw exception). */
24
+ message: string;
25
+ /** The validation errors the submit returned (`{}` when none). */
26
+ errors: ValidationErrors;
27
+ }
28
+
16
29
  export interface FormSubmitOptions<TPayload = unknown, TResponse = unknown> {
17
30
  onSuccess?: (data: TResponse) => void;
18
31
  onError?: (error: FormError) => void;
@@ -39,6 +52,19 @@ export interface FormState<TData extends Record<string, any>> {
39
52
  */
40
53
  wasSuccessful: boolean;
41
54
  shouldShowMessage: boolean;
55
+ /**
56
+ * What the most recent submit failed with, or `null`. Set when a submit
57
+ * fails (validation or otherwise; an aborted submit does not count),
58
+ * cleared when a submit starts and when one succeeds. Untouched by
59
+ * `clearError`, `clearErrors`, `setErrors` and field edits.
60
+ */
61
+ submitFailure: SubmitFailure | null;
62
+ /**
63
+ * How many submits have failed, counting every failure (including a
64
+ * `preserveErrors` resubmit that returned the same errors), so a watcher
65
+ * can react to "a submit just failed" even when nothing else changed.
66
+ */
67
+ failedSubmitCount: number;
42
68
  }
43
69
 
44
70
  export interface UseFormReturn<TData extends Record<string, any>>
@@ -143,6 +169,59 @@ function objectToFormData(payload: Record<string, unknown>): FormData {
143
169
  return fd;
144
170
  }
145
171
 
172
+ const GENERIC_FAILURE_MESSAGE = "An error occurred";
173
+
174
+ /** Copy an errors map, keeping only non-empty string messages. */
175
+ function copyValidationErrors(errors: unknown): ValidationErrors {
176
+ const copy: ValidationErrors = {};
177
+ if (errors === null || typeof errors !== "object") return copy;
178
+ for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
179
+ const messages = (Array.isArray(value) ? value : [value]).filter(
180
+ (message): message is string =>
181
+ typeof message === "string" && message !== "",
182
+ );
183
+ if (messages.length > 0) copy[key] = messages;
184
+ }
185
+ return copy;
186
+ }
187
+
188
+ export function isAbortError(error: unknown): boolean {
189
+ return (
190
+ error !== null &&
191
+ typeof error === "object" &&
192
+ (error as { name?: unknown }).name === "AbortError"
193
+ );
194
+ }
195
+
196
+ /**
197
+ * The `submitFailure` for a rejected submit, or `null` for an abort (the
198
+ * caller cancelled it; nothing failed). Only an `ApiError` from the client
199
+ * (an HTTP failure: it carries a numeric `status`, and the client has already
200
+ * replaced 401/403/404/419/500 bodies with user-facing text) supplies the
201
+ * message. Anything else (a network `TypeError`, an exception thrown from
202
+ * `onSuccess`) gets the generic message, so a summary never shows
203
+ * "Failed to fetch" or a stack-trace line.
204
+ */
205
+ function submitFailureFrom(error: unknown): SubmitFailure | null {
206
+ if (isAbortError(error)) return null;
207
+ const isApiError =
208
+ error !== null &&
209
+ typeof error === "object" &&
210
+ typeof (error as ApiError).status === "number";
211
+ if (isApiError) {
212
+ const apiError = error as ApiError;
213
+ const message =
214
+ typeof apiError.message === "string" && apiError.message.trim() !== ""
215
+ ? apiError.message
216
+ : GENERIC_FAILURE_MESSAGE;
217
+ return { message, errors: copyValidationErrors(apiError.errors) };
218
+ }
219
+ return {
220
+ message: GENERIC_FAILURE_MESSAGE,
221
+ errors: copyValidationErrors(errorsFromLaravel(error).errors),
222
+ };
223
+ }
224
+
146
225
  function errorsFromLaravel(error: any): {
147
226
  errors: ValidationErrors;
148
227
  message: string;
@@ -172,6 +251,64 @@ export function useForm<TData extends Record<string, any>>(
172
251
  let inFlightCount = 0;
173
252
  let successTimer: ReturnType<typeof setTimeout> | null = null;
174
253
 
254
+ // Which submit's outcome the form records (#194 review). Each submit takes
255
+ // a generation number when it starts. Its outcome (success, failure,
256
+ // errors, message, `submitFailure`, `resetOnSuccess`) is recorded only if
257
+ // no NEWER submit is still pending or has already recorded: the newest
258
+ // submit's outcome wins, in either completion order. An older success can
259
+ // therefore never clear a newer failure, and an older failure never lands
260
+ // over a newer success. Callbacks and the returned promise are
261
+ // unaffected: every submit still calls its own onSuccess/onError and
262
+ // resolves/rejects.
263
+ //
264
+ // An ABORTED submit is treated as if it had never started: it records
265
+ // nothing (not even the abort's own message), and an older outcome that
266
+ // was held back only because the aborted one was pending is then
267
+ // recorded. Held-back outcomes wait in `heldOutcomes` (newest wins; a
268
+ // newer recorded outcome drops them). A submit whose preparation
269
+ // (`transform`, `onBefore`) throws never takes a generation at all.
270
+ let latestSubmitGeneration = 0;
271
+ let lastRecordedGeneration = 0;
272
+ const pendingGenerations = new Set<number>();
273
+ const heldOutcomes = new Map<number, () => void>();
274
+ const mayRecord = (generation: number): boolean => {
275
+ if (generation < lastRecordedGeneration) return false;
276
+ for (const pending of pendingGenerations) {
277
+ if (pending > generation) return false;
278
+ }
279
+ return true;
280
+ };
281
+ /** Record now if this generation may, else hold it back for later. */
282
+ const recordOrHold = (generation: number, record: () => void): boolean => {
283
+ if (mayRecord(generation)) {
284
+ lastRecordedGeneration = generation;
285
+ heldOutcomes.clear();
286
+ record();
287
+ return true;
288
+ }
289
+ if (generation >= lastRecordedGeneration) heldOutcomes.set(generation, record);
290
+ return false;
291
+ };
292
+ /**
293
+ * After a submit leaves the pending set: record the NEWEST held-back
294
+ * outcome if nothing newer is pending any more (an abort unblocked it),
295
+ * and drop every older one. Older held outcomes never surface over a
296
+ * newer settled one.
297
+ */
298
+ const releaseHeldOutcomes = (): void => {
299
+ if (heldOutcomes.size === 0) return;
300
+ const newest = Math.max(...heldOutcomes.keys());
301
+ if (newest < lastRecordedGeneration) {
302
+ heldOutcomes.clear();
303
+ return;
304
+ }
305
+ if (!mayRecord(newest)) return;
306
+ const record = heldOutcomes.get(newest)!;
307
+ lastRecordedGeneration = newest;
308
+ heldOutcomes.clear();
309
+ record();
310
+ };
311
+
175
312
  const state = reactive<FormState<TData>>({
176
313
  data: deepClone(snapshot) as TData,
177
314
  errors: {},
@@ -181,6 +318,8 @@ export function useForm<TData extends Record<string, any>>(
181
318
  recentlySuccessful: false,
182
319
  wasSuccessful: false,
183
320
  shouldShowMessage: false,
321
+ submitFailure: null,
322
+ failedSubmitCount: 0,
184
323
  });
185
324
 
186
325
  const hasErrors = computed(() =>
@@ -238,7 +377,9 @@ export function useForm<TData extends Record<string, any>>(
238
377
  }
239
378
  clearErrors();
240
379
  state.touched = {};
241
- state.processing = false;
380
+ // Not `false` outright: with another submit still in flight the form
381
+ // is still processing, and the counter will clear it when that ends.
382
+ state.processing = inFlightCount > 0;
242
383
  };
243
384
 
244
385
  // v-model for a single field
@@ -269,11 +410,15 @@ export function useForm<TData extends Record<string, any>>(
269
410
  url: string,
270
411
  options: FormSubmitOptions<TData, TResponse> = {},
271
412
  ): Promise<TResponse> => {
272
- inFlightCount += 1;
273
- state.processing = true;
274
- state.wasSuccessful = false;
275
- if (!options.preserveErrors) clearErrors();
276
-
413
+ // Preparation (`transform`, `onBefore`, building the body) runs BEFORE
414
+ // the submit takes a generation or touches form state. If it throws,
415
+ // the submit rejects with that error and is as if it never started:
416
+ // no request, no callbacks, no recorded failure, and the form keeps
417
+ // its previous outcome. (Before #194's review a throw here also
418
+ // rejected without calling onError/onFinish or recording a failure,
419
+ // but it had already cleared the errors and summary, and it left
420
+ // `processing` stuck at true because the in-flight count was never
421
+ // decremented.)
277
422
  const payloadRaw = (
278
423
  options.transform
279
424
  ? // Give `transform` a COPY of the form data (#150), so a transform
@@ -315,6 +460,14 @@ export function useForm<TData extends Record<string, any>>(
315
460
  }
316
461
  }
317
462
 
463
+ inFlightCount += 1;
464
+ const generation = ++latestSubmitGeneration;
465
+ pendingGenerations.add(generation);
466
+ state.processing = true;
467
+ state.wasSuccessful = false;
468
+ state.submitFailure = null;
469
+ if (!options.preserveErrors) clearErrors();
470
+
318
471
  try {
319
472
  const { data } =
320
473
  sendMethod === "get"
@@ -334,27 +487,52 @@ export function useForm<TData extends Record<string, any>>(
334
487
  signal: options?.signal,
335
488
  });
336
489
 
337
- state.recentlySuccessful = true;
338
- state.wasSuccessful = true;
339
- if (successTimer !== null) clearTimeout(successTimer);
340
- successTimer = setTimeout(() => {
341
- state.recentlySuccessful = false;
342
- successTimer = null;
343
- }, 1500);
490
+ const records = recordOrHold(generation, () => {
491
+ state.recentlySuccessful = true;
492
+ state.wasSuccessful = true;
493
+ state.submitFailure = null;
494
+ if (successTimer !== null) clearTimeout(successTimer);
495
+ successTimer = setTimeout(() => {
496
+ state.recentlySuccessful = false;
497
+ successTimer = null;
498
+ }, 1500);
499
+ });
344
500
 
345
501
  options.onSuccess?.(data);
346
- if (options.resetOnSuccess) {
502
+ // Only a success recorded when it lands resets: an older one would
503
+ // wipe the data and errors the newer submit is answering for, and
504
+ // a held-back success recorded later (after an abort) does not
505
+ // reset either, since the user may have typed since.
506
+ if (options.resetOnSuccess && records) {
347
507
  reset();
348
508
  }
349
509
  return data;
350
510
  } catch (err) {
351
- state.wasSuccessful = false;
352
- const { errors, message } = errorsFromLaravel(err);
353
- setErrors(errors);
354
- setMessage(message);
511
+ // Decided again here (not reused from the try) so a throwing
512
+ // onSuccess is recorded, or held, as its failure: a held-back
513
+ // success for this generation is replaced.
514
+ //
515
+ // An abort records nothing: it is a cancellation, not an outcome.
516
+ // It leaves the pending set below, which may release an older
517
+ // held-back outcome.
518
+ if (!isAbortError(err)) {
519
+ const { errors, message } = errorsFromLaravel(err);
520
+ const failure = submitFailureFrom(err);
521
+ recordOrHold(generation, () => {
522
+ state.wasSuccessful = false;
523
+ setErrors(errors);
524
+ setMessage(message);
525
+ if (failure) {
526
+ state.submitFailure = failure;
527
+ state.failedSubmitCount += 1;
528
+ }
529
+ });
530
+ }
355
531
  options.onError?.(err as FormError);
356
532
  throw err;
357
533
  } finally {
534
+ pendingGenerations.delete(generation);
535
+ releaseHeldOutcomes();
358
536
  inFlightCount -= 1;
359
537
  // Only clear `processing` once no submission is still in flight.
360
538
  if (inFlightCount === 0) state.processing = false;
@@ -1,6 +1,6 @@
1
1
  import { computed, ref, watch, type Ref } from "vue";
2
2
  import { api } from "../utils/api";
3
- import { useForm } from "./useForm";
3
+ import { isAbortError, useForm, type FormError } from "./useForm";
4
4
  import { useToast } from "./useToast";
5
5
  import type { FieldDefinition } from "../types";
6
6
  import {
@@ -126,6 +126,31 @@ export function useResourceEditor<T = any>(
126
126
  { flush: 'sync' },
127
127
  );
128
128
 
129
+ /*
130
+ * Each open (edit or create) starts a new SESSION with a FRESH form
131
+ * (#194 review). The modal used to reuse one `useForm` and reseed it,
132
+ * which carried the previous session's `submitFailure` (row A's failure
133
+ * summary over row B, or over create) and let a request still in flight
134
+ * from the previous session record its outcome onto the new one. A fresh
135
+ * form makes both impossible by construction: an abandoned request settles
136
+ * into the OLD form object, which nothing renders any more. The DXForm
137
+ * subtree is already remounted per open (`editFormInstanceKey`), so no
138
+ * rendered state depended on the object surviving. Resetting one shared
139
+ * form instead would have needed every piece of per-submit state (failure,
140
+ * success flags, the in-flight counter, timers) reset in step, plus a
141
+ * guard against late responses: the fresh form is one line and cannot
142
+ * miss a field added later.
143
+ *
144
+ * The generation is bumped too, so the save/delete paths can tell a
145
+ * response for THIS session from one for an abandoned session, and the
146
+ * previous session's pending action no longer blocks this one.
147
+ */
148
+ const startSession = (formData: Record<string, any>) => {
149
+ editGeneration.value++;
150
+ pendingAction.value = null;
151
+ editForm.value = useForm(formData);
152
+ };
153
+
129
154
  // Monotonic token so a slow fetch for a previously-opened row can't overwrite
130
155
  // the form after the user has since opened a different row.
131
156
  let editFetchToken = 0;
@@ -235,23 +260,16 @@ export function useResourceEditor<T = any>(
235
260
  // Reset to first tab
236
261
  activeTabIndex.value = 0;
237
262
 
238
- // Initialize form with item data. `useForm` is statically imported, so
239
- // seeding is synchronous — no interleaving between successive row opens.
240
- if (!editForm.value) {
241
- const formData: Record<string, any> = {};
242
- // Presentational fields (submit: false) lay the form out; they hold
243
- // no data and must not be POSTed just because they were declared.
244
- submittableEditFields.value.forEach(field => {
245
- formData[field.key] = seedValueFor(field, item);
246
- });
247
- editForm.value = useForm(formData);
248
- } else {
249
- // Update existing form
250
- submittableEditFields.value.forEach(field => {
251
- editForm.value.data[field.key] = seedValueFor(field, item);
252
- });
253
- editForm.value.clearErrors();
254
- }
263
+ // A fresh form seeded from the row (see `startSession`). `useForm` is
264
+ // statically imported, so seeding is synchronous — no interleaving
265
+ // between successive row opens. Presentational fields (submit: false)
266
+ // lay the form out; they hold no data and must not be POSTed just
267
+ // because they were declared.
268
+ const formData: Record<string, any> = {};
269
+ submittableEditFields.value.forEach(field => {
270
+ formData[field.key] = seedValueFor(field, item);
271
+ });
272
+ startSession(formData);
255
273
 
256
274
  // Open modal
257
275
  editFormInstanceKey.value++;
@@ -313,19 +331,12 @@ export function useResourceEditor<T = any>(
313
331
  editFetchToken++;
314
332
  editLoading.value = false;
315
333
 
316
- if (!editForm.value) {
317
- const formData: Record<string, any> = {};
318
- submittableEditFields.value.forEach(field => {
319
- formData[field.key] = resolveFieldDefault(field);
320
- });
321
- editForm.value = useForm(formData);
322
- } else {
323
- // Reset existing form to defaults
324
- submittableEditFields.value.forEach(field => {
325
- editForm.value.data[field.key] = resolveFieldDefault(field);
326
- });
327
- editForm.value.clearErrors();
328
- }
334
+ // A fresh form seeded with the field defaults (see `startSession`).
335
+ const formData: Record<string, any> = {};
336
+ submittableEditFields.value.forEach(field => {
337
+ formData[field.key] = resolveFieldDefault(field);
338
+ });
339
+ startSession(formData);
329
340
  editFormInstanceKey.value++;
330
341
  showEditModal.value = true;
331
342
  };
@@ -359,9 +370,11 @@ export function useResourceEditor<T = any>(
359
370
  // performSave so it covers the whole class, not one call site.
360
371
  if (editGeneration.value !== generation) return;
361
372
 
362
- await performSave();
373
+ await performSave(generation);
363
374
  } finally {
364
- pendingAction.value = null;
375
+ // Only this session's own pending action: a session opened since
376
+ // has already cleared it, and may have started its own.
377
+ if (editGeneration.value === generation) pendingAction.value = null;
365
378
  }
366
379
  };
367
380
 
@@ -429,11 +442,60 @@ export function useResourceEditor<T = any>(
429
442
  );
430
443
  };
431
444
 
432
- const performSave = async () => {
445
+ /**
446
+ * A failed create/edit/delete request. `useForm` hands `onError` the
447
+ * `ApiError` (`{ message, errors, status }`), not an errors map, and
448
+ * records every non-abort failure in `form.submitFailure`, which the
449
+ * modal footer's error summary shows beside Save (the server's message
450
+ * and every validation message, #194). While the session that made the
451
+ * request still has its modal open, that summary IS the report, so a
452
+ * toast would only repeat it: nothing is toasted. A failure that settles
453
+ * after its modal closed (or another row opened) has no summary left, so
454
+ * it toasts the client's message for it ("Server error. Please try again
455
+ * later.", or the server's 422 line) or `fallback` for a non-HTTP failure.
456
+ * An abort is a cancellation, never reported.
457
+ */
458
+ const reportFailure = (
459
+ error: FormError | unknown,
460
+ fallback: string,
461
+ generation: number,
462
+ ) => {
463
+ if (isAbortError(error)) return;
464
+ if (editGeneration.value === generation && showEditModal.value) return;
465
+ const status = (error as FormError | null)?.status;
466
+ // Only an HTTP failure's message is written for users; a thrown
467
+ // TypeError ("Failed to fetch") gets the fallback.
468
+ const message = typeof status === 'number' ? (error as FormError).message : '';
469
+ createToast?.({
470
+ title: 'Error',
471
+ body: typeof message === 'string' && message !== '' ? message : fallback,
472
+ variant: 'danger',
473
+ modelValue: 5000,
474
+ });
475
+ };
476
+
477
+ /*
478
+ * `generation` is the session that started this save. The request may
479
+ * settle after the user has closed the modal and opened another row (or
480
+ * create): the record was still written, so the success toast, the
481
+ * `rowCreated`/`rowUpdated` emit and the table refresh still happen, but
482
+ * only the SAME session's modal is closed. The edited row and the form
483
+ * are captured up front, so the emits name the row that was saved, not
484
+ * whichever row is open by the time the response arrives.
485
+ *
486
+ * One error emit per failure: `useForm` calls `onError` and then
487
+ * rethrows, so the `catch` emits only for a throw that never reached
488
+ * `onError` (one raised before the request was made).
489
+ */
490
+ const performSave = async (generation: number) => {
491
+ const form = editForm.value;
492
+ const isCurrentSession = () => editGeneration.value === generation;
493
+
433
494
  // Create mode: POST to createUrl
434
495
  if (isCreateMode.value && props.createUrl) {
496
+ let errorReported = false;
435
497
  try {
436
- await editForm.value.post(props.createUrl, {
498
+ await form.post(props.createUrl, {
437
499
  transform: stripNonSubmittedFields,
438
500
  onSuccess: (data: any) => {
439
501
  createToast?.({
@@ -444,49 +506,38 @@ export function useResourceEditor<T = any>(
444
506
  });
445
507
 
446
508
  emit('rowCreated', data?.data ?? data, data);
447
- showEditModal.value = false;
448
- selectedItem.value = null;
449
- isCreateMode.value = false;
509
+ if (isCurrentSession()) {
510
+ showEditModal.value = false;
511
+ selectedItem.value = null;
512
+ isCreateMode.value = false;
513
+ }
450
514
 
451
515
  refresh();
452
516
  },
453
- onError: (errors: any) => {
454
- let errorMessage = 'Failed to create. Please check the form for errors.';
455
- if (errors && typeof errors === 'object') {
456
- const firstError = Object.values(errors).flat()[0];
457
- if (typeof firstError === 'string') {
458
- errorMessage = firstError;
459
- }
460
- }
461
-
462
- createToast?.({
463
- title: 'Error',
464
- body: errorMessage,
465
- variant: 'danger',
466
- modelValue: 5000,
467
- });
468
-
469
- // DXForm switches to the first errored tab via its
470
- // own watcher on editForm.errors.
471
- emit('createError', errors);
517
+ onError: (error: FormError) => {
518
+ errorReported = true;
519
+ reportFailure(error, 'Failed to create. Please try again.', generation);
520
+ emit('createError', error);
472
521
  }
473
522
  });
474
523
  } catch (error) {
475
- emit('createError', error);
524
+ if (!errorReported) emit('createError', error);
476
525
  }
477
526
  return;
478
527
  }
479
528
 
480
529
  // Edit mode: PUT to editUrl
481
- if (!selectedItem.value) return;
530
+ const item = selectedItem.value as T | null;
531
+ if (!item) return;
482
532
 
533
+ let errorReported = false;
483
534
  try {
484
535
  // If editUrl provided, handle API call internally
485
536
  if (props.editUrl) {
486
- const itemId = (selectedItem.value as any).id;
537
+ const itemId = (item as any).id;
487
538
  const url = props.editUrl.replace(':id', itemId);
488
539
 
489
- await editForm.value.put(url, {
540
+ await form.put(url, {
490
541
  transform: stripNonSubmittedFields,
491
542
  onSuccess: (data: any) => {
492
543
  // Show success toast
@@ -497,44 +548,29 @@ export function useResourceEditor<T = any>(
497
548
  modelValue: 3000, // Auto-dismiss after 3 seconds
498
549
  });
499
550
 
500
- emit('rowUpdated', selectedItem.value as T, data);
501
- showEditModal.value = false;
502
- selectedItem.value = null;
551
+ emit('rowUpdated', item, data);
552
+ if (isCurrentSession()) {
553
+ showEditModal.value = false;
554
+ selectedItem.value = null;
555
+ }
503
556
 
504
557
  // Refresh table data to show updated values
505
558
  refresh();
506
559
  },
507
- onError: (errors: any) => {
508
- // Extract first error message for toast
509
- let errorMessage = 'Failed to update. Please check the form for errors.';
510
- if (errors && typeof errors === 'object') {
511
- const firstError = Object.values(errors).flat()[0];
512
- if (typeof firstError === 'string') {
513
- errorMessage = firstError;
514
- }
515
- }
516
-
517
- // Show error toast with specific message
518
- createToast?.({
519
- title: 'Error',
520
- body: errorMessage,
521
- variant: 'danger',
522
- modelValue: 5000, // Auto-dismiss after 5 seconds
523
- });
524
-
525
- // DXForm switches to the first errored tab via its
526
- // own watcher on editForm.errors.
527
- emit('editError', selectedItem.value as T, errors);
560
+ onError: (error: FormError) => {
561
+ errorReported = true;
562
+ reportFailure(error, 'Failed to update. Please try again.', generation);
563
+ emit('editError', item, error);
528
564
  }
529
565
  });
530
566
  } else {
531
567
  // No editUrl - just emit event for custom handling
532
- emit('rowUpdated', selectedItem.value as T, editForm.value.data);
568
+ emit('rowUpdated', item, form.data);
533
569
  showEditModal.value = false;
534
570
  selectedItem.value = null;
535
571
  }
536
572
  } catch (error) {
537
- emit('editError', selectedItem.value as T, error);
573
+ if (!errorReported) emit('editError', item, error);
538
574
  }
539
575
  };
540
576
 
@@ -566,6 +602,11 @@ export function useResourceEditor<T = any>(
566
602
 
567
603
  pendingAction.value = 'delete';
568
604
  const generation = editGeneration.value;
605
+ // Captured for the same reasons as in `performSave`: a late response
606
+ // names the row it deleted and closes only its own session's modal.
607
+ const item = selectedItem.value as T;
608
+ const form = editForm.value;
609
+ let deleteErrorReported = false;
569
610
  try {
570
611
  // Delete guard: a non-null message means this item can't be deleted —
571
612
  // show it immediately and skip the confirm and the request entirely.
@@ -583,10 +624,10 @@ export function useResourceEditor<T = any>(
583
624
  // event loop, so nothing can slip in during it.
584
625
  if (editGeneration.value !== generation) return;
585
626
 
586
- const itemId = (selectedItem.value as any).id;
627
+ const itemId = (item as any).id;
587
628
  const url = props.deleteUrl.replace(':id', itemId);
588
629
 
589
- await editForm.value.delete(url, {
630
+ await form.delete(url, {
590
631
  onSuccess: (data: any) => {
591
632
  // Show success toast
592
633
  createToast?.({
@@ -596,33 +637,25 @@ export function useResourceEditor<T = any>(
596
637
  modelValue: 3000, // Auto-dismiss after 3 seconds
597
638
  });
598
639
 
599
- emit('rowDeleted', selectedItem.value as T, data);
600
- showEditModal.value = false;
601
- selectedItem.value = null;
640
+ emit('rowDeleted', item, data);
641
+ if (editGeneration.value === generation) {
642
+ showEditModal.value = false;
643
+ selectedItem.value = null;
644
+ }
602
645
 
603
646
  // Refresh table data to remove deleted item
604
647
  refresh();
605
648
  },
606
649
  onError: (error: any) => {
607
- // Extract error message from server response
608
- const errorData = error?.response?.data ?? error?.data ?? error;
609
- const errorMessage = errorData?.message ?? 'Failed to delete. Please try again.';
610
-
611
- // Show error toast with server message
612
- createToast?.({
613
- title: 'Error',
614
- body: errorMessage,
615
- variant: 'danger',
616
- modelValue: 5000, // Auto-dismiss after 5 seconds
617
- });
618
-
619
- emit('deleteError', selectedItem.value as T, error);
650
+ reportFailure(error, 'Failed to delete. Please try again.', generation);
651
+ deleteErrorReported = true;
652
+ emit('deleteError', item, error);
620
653
  }
621
654
  });
622
655
  } catch (error) {
623
- emit('deleteError', selectedItem.value as T, error);
656
+ if (!deleteErrorReported) emit('deleteError', item, error);
624
657
  } finally {
625
- pendingAction.value = null;
658
+ if (editGeneration.value === generation) pendingAction.value = null;
626
659
  }
627
660
  };
628
661