@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
@@ -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
 
@@ -7,6 +7,7 @@ export * from 'bootstrap-vue-next';
7
7
  // Extended components (custom functionality beyond Bootstrap Vue Next)
8
8
  export { default as DXDashboard } from "./components/extended/DXDashboard.vue";
9
9
  export { default as DXForm } from "./components/extended/DXForm.vue";
10
+ export { default as DXFormErrorSummary } from "./components/extended/DXFormErrorSummary.vue";
10
11
  export { default as DXField } from "./components/extended/DXField.vue";
11
12
  export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
12
13
  export { default as DXModalActions } from "./components/extended/DXModalActions.vue";
@@ -20,6 +21,7 @@ export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
20
21
  // (#142) so chart.js / vue-chartjs stay genuinely optional peers — the main
21
22
  // entry no longer references them. See resources/js/charts.ts.
22
23
  export { default as DXRepeater } from "./components/extended/DXRepeater.vue";
24
+ export { default as DXStackingTable } from "./components/extended/DXStackingTable.vue";
23
25
  /**
24
26
  * @deprecated Use `DXForm`. `DXBasicForm` is a thin wrapper around `DXForm`
25
27
  * (a flat form is just `DXForm` without a `tabs` prop) that logs a one-time
@@ -129,6 +131,12 @@ export {
129
131
  isFieldVisible,
130
132
  isSubmittableField,
131
133
  } from "./utils/formSchema";
134
+ // Which field a validation-error key belongs to (#194): the one rule the
135
+ // error summary, tab selection and scrolling share.
136
+ export {
137
+ resolveErrorTargets,
138
+ humaniseErrorKey,
139
+ } from "./utils/formErrorTargets";
132
140
 
133
141
  // Types
134
142
  export type {
@@ -167,8 +175,16 @@ export type {
167
175
  FormSubmitOptions,
168
176
  FormState,
169
177
  UseFormReturn,
178
+ SubmitFailure,
170
179
  } from "./composables/useForm";
171
180
 
181
+ export type {
182
+ ErrorTarget,
183
+ ResolveErrorTargetsOptions,
184
+ } from "./utils/formErrorTargets";
185
+
186
+ export type { ErrorSummarySelection } from "./components/extended/DXFormErrorSummary.vue";
187
+
172
188
  export type {
173
189
  FormFieldDefinition,
174
190
  DefineFormReturn,
@@ -202,4 +218,5 @@ export type {
202
218
  Navigation,
203
219
  NavbarSearchAlign,
204
220
  NavbarActionsOnMobile,
221
+ SidebarVariant,
205
222
  } from "./types/navigation";
@@ -367,6 +367,18 @@ export interface FieldDefinition {
367
367
  */
368
368
  submit?: boolean;
369
369
 
370
+ /**
371
+ * Extra validation-error keys this field owns (#194), for a field whose
372
+ * server-side errors arrive under keys other than its own: a span or
373
+ * component field that edits nested data, say. Patterns, where `*`
374
+ * matches exactly one dot segment: `["lines.*", "lines.*.*"]` claims
375
+ * `lines.0` and `lines.0.price` but not `lines` or `lines.0.a.b`.
376
+ * Checked before the usual matching (the field's own key, then any key
377
+ * nested under it), so it decides which field an error belongs to: the
378
+ * label it is listed under in the error summary and the tab it selects.
379
+ */
380
+ errorKeys?: string[];
381
+
370
382
  /**
371
383
  * Conditionally show or hide this field. When omitted the field is
372
384
  * always visible. Boolean or a function of the form model; evaluated
@@ -427,6 +439,7 @@ export interface BaseFieldDef {
427
439
  readonly?: MaybeFn<boolean>;
428
440
  plaintext?: MaybeFn<boolean>;
429
441
  submit?: boolean;
442
+ errorKeys?: string[];
430
443
  when?: MaybeFn<boolean>;
431
444
  show?: () => boolean;
432
445
  }
@@ -38,6 +38,15 @@ export type Navigation = NavigationGroup[];
38
38
  */
39
39
  export type NavbarSearchAlign = "start" | "center";
40
40
 
41
+ /**
42
+ * Colour scheme of the dashboard sidebar: `"light"` (the default: a white pane,
43
+ * tinted group headers, a green active item, Poppins) or `"dark"` (the navy
44
+ * rail dfl shipped before). Every colour comes from a `--dx-sidebar-*` custom
45
+ * property, so either can be rebranded in CSS. Shared by DXDashboard (which
46
+ * forwards it as `sidebarVariant`) and DXDashboardSidebar (`variant`).
47
+ */
48
+ export type SidebarVariant = "light" | "dark";
49
+
41
50
  /**
42
51
  * What the navbar actions slot does below the `md` breakpoint: `"wrap"` moves
43
52
  * it to its own full-width row (the bar grows), `"hide"` removes it entirely