@omnitend/dashboard-for-laravel 0.43.0 → 0.44.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/dist/components/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- package/dist/components/extended/DXForm.vue.d.ts +66 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
- package/dist/components/extended/DXFormField.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
- package/dist/composables/useAsyncOptions.d.ts +14 -1
- package/dist/composables/useForm.d.ts +26 -0
- package/dist/dashboard-for-laravel.js +13374 -12823
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/docs/public/api-reference.json +146 -6
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +56 -0
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXField.vue +15 -9
- package/resources/js/components/extended/DXForm.vue +676 -28
- package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
- package/resources/js/components/extended/DXFormField.vue +11 -2
- package/resources/js/components/extended/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXTable.vue +36 -4
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +15 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- 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
|
-
|
|
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
|
-
|
|
273
|
-
state.
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
successTimer =
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
//
|
|
239
|
-
// seeding is synchronous — no interleaving
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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: (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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 = (
|
|
537
|
+
const itemId = (item as any).id;
|
|
487
538
|
const url = props.editUrl.replace(':id', itemId);
|
|
488
539
|
|
|
489
|
-
await
|
|
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',
|
|
501
|
-
|
|
502
|
-
|
|
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: (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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',
|
|
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',
|
|
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 = (
|
|
627
|
+
const itemId = (item as any).id;
|
|
587
628
|
const url = props.deleteUrl.replace(':id', itemId);
|
|
588
629
|
|
|
589
|
-
await
|
|
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',
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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',
|
|
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
|
|