@omnitend/dashboard-for-laravel 0.42.2 → 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/DXCurrencyInput.vue.d.ts +2 -2
- package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
- 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/DXStackingTable.vue.d.ts +25 -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 +12002 -11223
- 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 +7 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/types/navigation.d.ts +8 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/dist/utils/pageScrollLock.d.ts +9 -0
- package/docs/public/api-reference.json +223 -9
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +508 -36
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/base/DModal.vue +20 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
- package/resources/js/components/extended/DXDashboard.vue +236 -43
- package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
- package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
- 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/DXStackingTable.vue +264 -0
- 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/useContainerWidth.ts +12 -3
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +17 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/types/navigation.ts +9 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- package/resources/js/utils/pageScrollLock.ts +27 -0
- package/scripts/mcp-server.mjs +58 -6
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
:aria-busy="loading ? 'true' : undefined"
|
|
56
56
|
>
|
|
57
57
|
<DXForm
|
|
58
|
+
ref="innerForm"
|
|
58
59
|
:key="formInstanceKey"
|
|
59
60
|
:active-tab="activeTab"
|
|
60
61
|
:form="form"
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
:card-tabs="false"
|
|
67
68
|
:context="item ?? undefined"
|
|
68
69
|
:show-submit="false"
|
|
70
|
+
error-summary="external"
|
|
69
71
|
@update:active-tab="emit('update:activeTab', $event)"
|
|
70
72
|
@submit="emit('save')"
|
|
71
73
|
>
|
|
@@ -154,6 +156,24 @@
|
|
|
154
156
|
</fieldset>
|
|
155
157
|
|
|
156
158
|
<template #footer>
|
|
159
|
+
<!-- What the last failed save returned, beside the Save button it
|
|
160
|
+
answers (#194). The inner DXForm shows no summary or alert of
|
|
161
|
+
its own while this one lists a failure
|
|
162
|
+
(`error-summary="external"`; `false` would bring its top
|
|
163
|
+
alert back), so a failure shows one alert. Rows come from the inner form's own resolution, and a
|
|
164
|
+
row click drives the inner form's tab and focus. -->
|
|
165
|
+
<DXFormErrorSummary
|
|
166
|
+
v-if="form"
|
|
167
|
+
class="dx-edit-error-summary w-100"
|
|
168
|
+
:form="form"
|
|
169
|
+
:fields="fields"
|
|
170
|
+
:tabs="tabs"
|
|
171
|
+
:context="item ?? undefined"
|
|
172
|
+
:targets="innerForm?.errorTargets ?? null"
|
|
173
|
+
:title="errorSummaryTitle"
|
|
174
|
+
:other-title="errorSummaryOtherTitle"
|
|
175
|
+
@select-target="innerForm?.focusErrorTarget($event)"
|
|
176
|
+
/>
|
|
157
177
|
<div class="d-flex justify-content-between w-100">
|
|
158
178
|
<div>
|
|
159
179
|
<DButton
|
|
@@ -187,11 +207,12 @@
|
|
|
187
207
|
</template>
|
|
188
208
|
|
|
189
209
|
<script setup lang="ts">
|
|
190
|
-
import { computed, useSlots } from "vue";
|
|
210
|
+
import { computed, ref, useSlots } from "vue";
|
|
191
211
|
import DModal from "../base/DModal.vue";
|
|
192
212
|
import DButton from "../base/DButton.vue";
|
|
193
213
|
import DSpinner from "../base/DSpinner.vue";
|
|
194
214
|
import DXForm from "./DXForm.vue";
|
|
215
|
+
import DXFormErrorSummary from "./DXFormErrorSummary.vue";
|
|
195
216
|
import type { EditTab } from "./DXTable.vue";
|
|
196
217
|
import type { LabelCols } from "../../types";
|
|
197
218
|
|
|
@@ -241,10 +262,17 @@ interface Props {
|
|
|
241
262
|
createText?: string;
|
|
242
263
|
/** Override the Delete button label. */
|
|
243
264
|
deleteText?: string;
|
|
265
|
+
/** The footer summary's heading for a failure with field errors. */
|
|
266
|
+
errorSummaryTitle?: string;
|
|
267
|
+
/** The footer summary's sub-heading over rows no field owns. */
|
|
268
|
+
errorSummaryOtherTitle?: string;
|
|
244
269
|
}
|
|
245
270
|
|
|
246
271
|
const props = defineProps<Props>();
|
|
247
272
|
|
|
273
|
+
/** The inner form: its resolved error rows, and focusing an errored field. */
|
|
274
|
+
const innerForm = ref<InstanceType<typeof DXForm> | null>(null);
|
|
275
|
+
|
|
248
276
|
// Title-case the singular item noun for button labels ("customer" → "Customer",
|
|
249
277
|
// "sales order" → "Sales Order") — capitalize the first letter of each word.
|
|
250
278
|
const capitalizedItem = computed(() => {
|
|
@@ -8,13 +8,26 @@ import type { FieldDefinition, FieldOption } from "../types";
|
|
|
8
8
|
* out-of-order responses clobbering newer ones; loader failures are swallowed
|
|
9
9
|
* so the last good options (or the static `field.options` fallback) stay put.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Known limit: replacing a field's `optionsLoader` at runtime does NOT restart
|
|
12
|
+
* the "first load" state (loaded options and `firstLoadSettled` carry over).
|
|
13
|
+
* Loader identity is not a stable signal: an inline loader in a computed
|
|
14
|
+
* `fields` array is a new function on every recompute, so treating each new
|
|
15
|
+
* function as a new source would blank the field and refetch on every
|
|
16
|
+
* unrelated edit.
|
|
17
|
+
*
|
|
18
|
+
* Extracted from DXField's inline logic (#135).
|
|
12
19
|
* `getField` is an accessor (props aren't destructurable without losing
|
|
13
20
|
* reactivity); `effectiveModel` is the model passed to the loader.
|
|
14
21
|
*/
|
|
15
22
|
export interface UseAsyncOptionsReturn {
|
|
16
23
|
/** Options returned by the most recent successful load, or null if none yet. */
|
|
17
24
|
loadedOptions: Ref<FieldOption[] | null>;
|
|
25
|
+
/**
|
|
26
|
+
* False until the first load has SETTLED (succeeded or failed). Only that
|
|
27
|
+
* first load leaves a field with nothing to show; a later reload keeps the
|
|
28
|
+
* options it has, or the static fallback after a failure.
|
|
29
|
+
*/
|
|
30
|
+
firstLoadSettled: Ref<boolean>;
|
|
18
31
|
/** Trigger a load (no-op when the field has no `optionsLoader`). */
|
|
19
32
|
loadOptions: () => Promise<void>;
|
|
20
33
|
}
|
|
@@ -24,6 +37,9 @@ export function useAsyncOptions(
|
|
|
24
37
|
effectiveModel: ComputedRef<any> | Ref<any>,
|
|
25
38
|
): UseAsyncOptionsReturn {
|
|
26
39
|
const loadedOptions = ref<FieldOption[] | null>(null);
|
|
40
|
+
// False before mount when there is a loader, so the first render already
|
|
41
|
+
// knows options are on their way.
|
|
42
|
+
const firstLoadSettled = ref(!getField().optionsLoader);
|
|
27
43
|
|
|
28
44
|
// Monotonic token so out-of-order async responses can't clobber newer ones.
|
|
29
45
|
let optionsRequestToken = 0;
|
|
@@ -46,6 +62,8 @@ export function useAsyncOptions(
|
|
|
46
62
|
error,
|
|
47
63
|
);
|
|
48
64
|
}
|
|
65
|
+
} finally {
|
|
66
|
+
if (token === optionsRequestToken) firstLoadSettled.value = true;
|
|
49
67
|
}
|
|
50
68
|
}
|
|
51
69
|
|
|
@@ -53,9 +71,16 @@ export function useAsyncOptions(
|
|
|
53
71
|
void loadOptions();
|
|
54
72
|
});
|
|
55
73
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
// Read from the CURRENT field: DXForm keys DXField by `field.key`, so
|
|
75
|
+
// recomputed `fields` hand this instance a new field object that may
|
|
76
|
+
// turn reloading on or off.
|
|
77
|
+
watch(
|
|
78
|
+
effectiveModel,
|
|
79
|
+
() => {
|
|
80
|
+
if (getField().reloadOptionsOnChange) void loadOptions();
|
|
81
|
+
},
|
|
82
|
+
{ deep: true },
|
|
83
|
+
);
|
|
59
84
|
|
|
60
|
-
return { loadedOptions, loadOptions };
|
|
85
|
+
return { loadedOptions, firstLoadSettled, loadOptions };
|
|
61
86
|
}
|
|
@@ -270,10 +270,17 @@ export function useContainerWidth(
|
|
|
270
270
|
// express without mutating during evaluation. With `hysteresis: 0` it is
|
|
271
271
|
// exactly `width < threshold`. The watcher is `sync` so `isBelow` is never
|
|
272
272
|
// observed one tick behind `width`.
|
|
273
|
+
//
|
|
274
|
+
// The band only applies to a state that came from a MEASUREMENT. Before
|
|
275
|
+
// the first one, `isBelow` is the `initialWidth` guess (true by default),
|
|
276
|
+
// and holding that guess through the band would leave a container that
|
|
277
|
+
// measures just above the threshold (say 590 against 576 + 24) on the
|
|
278
|
+
// narrow layout until it next grows: it never crossed anything.
|
|
273
279
|
const initialThreshold = resolveThreshold();
|
|
274
280
|
const isBelow = ref<boolean>(
|
|
275
281
|
initialThreshold === undefined ? false : startWidth < initialThreshold,
|
|
276
282
|
);
|
|
283
|
+
let isBelowCameFromMeasurement = false;
|
|
277
284
|
watch(
|
|
278
285
|
[width, () => resolveThreshold()],
|
|
279
286
|
([currentWidth, currentThreshold]) => {
|
|
@@ -281,9 +288,11 @@ export function useContainerWidth(
|
|
|
281
288
|
isBelow.value = false;
|
|
282
289
|
return;
|
|
283
290
|
}
|
|
284
|
-
isBelow.value =
|
|
285
|
-
|
|
286
|
-
|
|
291
|
+
isBelow.value =
|
|
292
|
+
isBelow.value && isBelowCameFromMeasurement
|
|
293
|
+
? currentWidth < currentThreshold + hysteresis
|
|
294
|
+
: currentWidth < currentThreshold;
|
|
295
|
+
if (hasMeasured.value) isBelowCameFromMeasurement = true;
|
|
287
296
|
},
|
|
288
297
|
{ flush: "sync" },
|
|
289
298
|
);
|
|
@@ -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;
|