@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.
Files changed (54) 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/DXCurrencyInput.vue.d.ts +2 -2
  4. package/dist/components/extended/DXDashboard.vue.d.ts +14 -6
  5. package/dist/components/extended/DXDashboardNavbar.vue.d.ts +12 -3
  6. package/dist/components/extended/DXDashboardSidebar.vue.d.ts +21 -1
  7. package/dist/components/extended/DXForm.vue.d.ts +66 -1
  8. package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
  9. package/dist/components/extended/DXFormField.vue.d.ts +3 -0
  10. package/dist/components/extended/DXStackingTable.vue.d.ts +25 -0
  11. package/dist/components/extended/DXTable.vue.d.ts +20 -0
  12. package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
  13. package/dist/composables/useAsyncOptions.d.ts +14 -1
  14. package/dist/composables/useForm.d.ts +26 -0
  15. package/dist/dashboard-for-laravel.js +12002 -11223
  16. package/dist/dashboard-for-laravel.js.map +1 -1
  17. package/dist/dashboard-for-laravel.umd.cjs +8 -8
  18. package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
  19. package/dist/index.d.ts +7 -2
  20. package/dist/style.css +1 -1
  21. package/dist/types/index.d.ts +12 -0
  22. package/dist/types/navigation.d.ts +8 -0
  23. package/dist/utils/formErrorTargets.d.ts +86 -0
  24. package/dist/utils/pageScrollLock.d.ts +9 -0
  25. package/docs/public/api-reference.json +223 -9
  26. package/docs/public/docs-map.md +4 -2
  27. package/docs/public/llms.txt +4 -2
  28. package/package.json +9 -8
  29. package/resources/css/theme.scss +508 -36
  30. package/resources/js/components/base/DFormSelect.vue +5 -2
  31. package/resources/js/components/base/DModal.vue +20 -2
  32. package/resources/js/components/extended/DXChoiceField.vue +42 -26
  33. package/resources/js/components/extended/DXCurrencyInput.vue +3 -1
  34. package/resources/js/components/extended/DXDashboard.vue +236 -43
  35. package/resources/js/components/extended/DXDashboardNavbar.vue +23 -13
  36. package/resources/js/components/extended/DXDashboardSidebar.vue +103 -29
  37. package/resources/js/components/extended/DXField.vue +15 -9
  38. package/resources/js/components/extended/DXForm.vue +676 -28
  39. package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
  40. package/resources/js/components/extended/DXFormField.vue +11 -2
  41. package/resources/js/components/extended/DXRepeater.vue +30 -4
  42. package/resources/js/components/extended/DXStackingTable.vue +264 -0
  43. package/resources/js/components/extended/DXTable.vue +36 -4
  44. package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
  45. package/resources/js/composables/useAsyncOptions.ts +30 -5
  46. package/resources/js/composables/useContainerWidth.ts +12 -3
  47. package/resources/js/composables/useForm.ts +196 -18
  48. package/resources/js/composables/useResourceEditor.ts +140 -107
  49. package/resources/js/index.ts +17 -0
  50. package/resources/js/types/index.ts +13 -0
  51. package/resources/js/types/navigation.ts +9 -0
  52. package/resources/js/utils/formErrorTargets.ts +317 -0
  53. package/resources/js/utils/pageScrollLock.ts +27 -0
  54. 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
- * Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
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
- if (getField().reloadOptionsOnChange) {
57
- watch(effectiveModel, () => void loadOptions(), { deep: true });
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 = isBelow.value
285
- ? currentWidth < currentThreshold + hysteresis
286
- : currentWidth < currentThreshold;
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
- 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;