@omnitend/dashboard-for-laravel 0.10.0 → 0.13.1

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.
@@ -346,7 +346,7 @@
346
346
  <div v-else></div>
347
347
 
348
348
  <!-- Per-page selector -->
349
- <div v-if="clientSidePagination.total >= Math.min(...perPageOptions)" class="d-flex align-items-center gap-2">
349
+ <div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
350
350
  <label for="perPageSelectClientSide" class="mb-0 small text-muted">Per page</label>
351
351
  <DFormSelect
352
352
  id="perPageSelectClientSide"
@@ -484,6 +484,15 @@
484
484
  :title="computedModalTitle"
485
485
  :size="editModalSize"
486
486
  >
487
+ <!-- Loading the full record for edit (showUrl fetch) -->
488
+ <div
489
+ v-if="editLoading"
490
+ class="dx-edit-loading d-flex align-items-center gap-2 text-muted mb-3"
491
+ >
492
+ <DSpinner small />
493
+ <span>Loading…</span>
494
+ </div>
495
+
487
496
  <!-- Edit/create form (tabbed when editTabs provided, flat otherwise) -->
488
497
  <DXForm
489
498
  v-if="editForm"
@@ -584,7 +593,7 @@
584
593
  <DButton
585
594
  v-if="deleteUrl && !isCreateMode"
586
595
  variant="danger"
587
- :disabled="editForm?.processing"
596
+ :disabled="editForm?.processing || editLoading"
588
597
  @click="handleDelete"
589
598
  >
590
599
  {{ pendingAction === 'delete' ? 'Deleting...' : 'Delete' }}
@@ -596,7 +605,7 @@
596
605
  </DButton>
597
606
  <DButton
598
607
  variant="primary"
599
- :disabled="editForm?.processing"
608
+ :disabled="editForm?.processing || editLoading"
600
609
  @click="handleEditSave"
601
610
  >
602
611
  <template v-if="isCreateMode">
@@ -757,7 +766,8 @@ export interface Props<TItem = any> {
757
766
  /** Items per page (for provider mode, v-model support) */
758
767
  perPage?: number;
759
768
 
760
- /** Striped rows */
769
+ /** Striped (banded) rows. Off by default — banding adds little on short
770
+ * tables and can read as noise; opt in with `:striped="true"`. */
761
771
  striped?: boolean;
762
772
 
763
773
  /** Hover effect on rows */
@@ -791,9 +801,29 @@ export interface Props<TItem = any> {
791
801
  /** API endpoint pattern for updates (e.g., "/api/products/:id") */
792
802
  editUrl?: string;
793
803
 
804
+ /**
805
+ * API endpoint pattern to fetch the FULL record for the edit modal
806
+ * (e.g., "/api/products/:id"). When set, opening a row seeds the form from
807
+ * this fetch (shown with a loading state) instead of the thin list row —
808
+ * use when the list payload omits fields the edit form needs (notes, nested
809
+ * relations, repeater rows, …). The response is unwrapped from `data.data`
810
+ * (Laravel API Resource) or used as-is.
811
+ */
812
+ showUrl?: string;
813
+
794
814
  /** API endpoint pattern for deletions (e.g., "/api/products/:id") */
795
815
  deleteUrl?: string;
796
816
 
817
+ /**
818
+ * Guard run when Delete is clicked, before the confirm dialog and request.
819
+ * Return a message for a non-deletable item to show it immediately (as a
820
+ * toast) and skip both the confirm and the delete request; return
821
+ * `null`/`undefined` to proceed with the normal confirm + delete. Lets you
822
+ * short-circuit a doomed delete (e.g. a record with dependents that the
823
+ * server would reject) with an immediate, specific reason.
824
+ */
825
+ deleteGuard?: (item: TItem) => string | null | undefined;
826
+
797
827
  /** API endpoint for creating new items (e.g., "/api/products") — enables "New" button */
798
828
  createUrl?: string;
799
829
 
@@ -819,7 +849,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
819
849
  perPageOptions: () => [10, 25, 50, 100],
820
850
  currentPage: 1,
821
851
  // perPage: 10, // Don't set default - let internalPerPage handle it
822
- striped: true,
852
+ striped: false,
823
853
  hover: true,
824
854
  responsive: true,
825
855
  fluid: false,
@@ -1175,9 +1205,11 @@ const shouldShowPerPageSelector = computed(() => {
1175
1205
  if (!props.showPerPageSelector) return false;
1176
1206
 
1177
1207
  const smallestOption = Math.min(...props.perPageOptions);
1178
- const total = isInertiaMode.value
1179
- ? props.pagination?.total || 0
1180
- : apiPaginationMeta.value?.total || 0;
1208
+ const total = isClientSideMode.value
1209
+ ? clientSidePagination.value?.total || 0
1210
+ : isInertiaMode.value
1211
+ ? props.pagination?.total || 0
1212
+ : apiPaginationMeta.value?.total || 0;
1181
1213
 
1182
1214
  return total >= smallestOption;
1183
1215
  });
@@ -1462,6 +1494,19 @@ const isCreateMode = ref(false);
1462
1494
  // request the form makes, so it can't tell Save from Delete on its own.
1463
1495
  const pendingAction = ref<'save' | 'delete' | null>(null);
1464
1496
 
1497
+ // True while the edit modal is fetching the full record via `showUrl`.
1498
+ const editLoading = ref(false);
1499
+
1500
+ // Monotonic token so a slow fetch for a previously-opened row can't overwrite
1501
+ // the form after the user has since opened a different row.
1502
+ let editFetchToken = 0;
1503
+
1504
+ // Monotonic token for opening the modal itself. The first edit dynamically
1505
+ // imports useForm; if the user clicks row A then row B before that import
1506
+ // resolves, A's callback must not seed/open/fetch over B. Each open captures
1507
+ // the current token and bails if it's since been superseded.
1508
+ let modalOpenToken = 0;
1509
+
1465
1510
  // Toast (may not be available in test environment)
1466
1511
  let createToast: ((obj: any) => any) | undefined;
1467
1512
  try {
@@ -1542,10 +1587,15 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1542
1587
  // Reset to first tab
1543
1588
  activeTabIndex.value = 0;
1544
1589
 
1590
+ // Capture this open so a slow first-time useForm import can't seed the
1591
+ // form after the user has since opened a different row (or create).
1592
+ const openToken = ++modalOpenToken;
1593
+
1545
1594
  // Initialize form with item data
1546
1595
  if (!editForm.value) {
1547
1596
  // Dynamically import useForm to avoid circular dependency
1548
1597
  import('../../composables/useForm').then(({ useForm }) => {
1598
+ if (openToken !== modalOpenToken) return; // superseded
1549
1599
  const formData: Record<string, any> = {};
1550
1600
  props.editFields!.forEach(field => {
1551
1601
  formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
@@ -1554,6 +1604,9 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1554
1604
 
1555
1605
  // Open modal
1556
1606
  showEditModal.value = true;
1607
+
1608
+ // Optionally replace the row-seeded data with the full record.
1609
+ if (props.showUrl) void fetchFullRecordForEdit(item);
1557
1610
  });
1558
1611
  } else {
1559
1612
  // Update existing form
@@ -1564,7 +1617,51 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
1564
1617
 
1565
1618
  // Open modal
1566
1619
  showEditModal.value = true;
1620
+
1621
+ // Optionally replace the row-seeded data with the full record.
1622
+ if (props.showUrl) void fetchFullRecordForEdit(item);
1623
+ }
1624
+ }
1625
+ };
1626
+
1627
+ // Fetch the full record for the edit modal and re-seed the form from it. The
1628
+ // form is already showing row data; this fills in fields the list row omitted.
1629
+ const fetchFullRecordForEdit = async (item: T) => {
1630
+ const itemId = (item as any).id;
1631
+ if (itemId === undefined || itemId === null) return;
1632
+
1633
+ const token = ++editFetchToken;
1634
+ editLoading.value = true;
1635
+ try {
1636
+ const url = props.showUrl!.replace(':id', String(itemId));
1637
+ const response = await axios.get(url);
1638
+ // Superseded by a newer row-open — discard.
1639
+ if (token !== editFetchToken) return;
1640
+
1641
+ const record = response.data?.data ?? response.data;
1642
+ if (record && editForm.value) {
1643
+ props.editFields!.forEach(field => {
1644
+ if (Object.prototype.hasOwnProperty.call(record, field.key)) {
1645
+ editForm.value.data[field.key] = record[field.key];
1646
+ }
1647
+ });
1648
+ // Widen selectedItem so predicates / the delete guard see full data.
1649
+ selectedItem.value = { ...(selectedItem.value as any), ...record };
1567
1650
  }
1651
+ } catch (error: any) {
1652
+ if (token !== editFetchToken) return;
1653
+ const message =
1654
+ error?.response?.data?.message ??
1655
+ error?.message ??
1656
+ 'Failed to load the full record.';
1657
+ createToast?.({
1658
+ title: 'Error',
1659
+ body: message,
1660
+ variant: 'danger',
1661
+ modelValue: 5000,
1662
+ });
1663
+ } finally {
1664
+ if (token === editFetchToken) editLoading.value = false;
1568
1665
  }
1569
1666
  };
1570
1667
 
@@ -1576,6 +1673,12 @@ const handleCreateNew = () => {
1576
1673
  selectedItem.value = null;
1577
1674
  activeTabIndex.value = 0;
1578
1675
 
1676
+ // Supersede any in-flight row-open (and its pending fetch) so a slow
1677
+ // useForm import from an earlier edit can't seed over this create.
1678
+ const openToken = ++modalOpenToken;
1679
+ editFetchToken++;
1680
+ editLoading.value = false;
1681
+
1579
1682
  const initForm = (useForm: any) => {
1580
1683
  const formData: Record<string, any> = {};
1581
1684
  props.editFields!.forEach(field => {
@@ -1587,6 +1690,7 @@ const handleCreateNew = () => {
1587
1690
 
1588
1691
  if (!editForm.value) {
1589
1692
  import('../../composables/useForm').then(({ useForm }) => {
1693
+ if (openToken !== modalOpenToken) return; // superseded
1590
1694
  initForm(useForm);
1591
1695
  });
1592
1696
  } else {
@@ -1724,6 +1828,9 @@ const handleEditCancel = () => {
1724
1828
  selectedItem.value = null;
1725
1829
  isCreateMode.value = false;
1726
1830
  activeTabIndex.value = 0; // Reset tab for next time
1831
+ // Abandon any in-flight showUrl fetch so it can't seed a closed modal.
1832
+ editFetchToken++;
1833
+ editLoading.value = false;
1727
1834
  if (editForm.value) {
1728
1835
  editForm.value.clearErrors();
1729
1836
  }
@@ -1732,6 +1839,22 @@ const handleEditCancel = () => {
1732
1839
  // Handle delete from edit modal
1733
1840
  const handleDelete = async () => {
1734
1841
  if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
1842
+ // Don't evaluate the guard until the full record has loaded (showUrl) — it
1843
+ // may depend on fields the thin list row doesn't carry.
1844
+ if (editLoading.value) return;
1845
+
1846
+ // Delete guard: a non-null message means this item can't be deleted — show
1847
+ // it immediately and skip the confirm and the request entirely.
1848
+ const guardMessage = props.deleteGuard?.(selectedItem.value as T);
1849
+ if (guardMessage) {
1850
+ createToast?.({
1851
+ title: 'Cannot delete',
1852
+ body: guardMessage,
1853
+ variant: 'danger',
1854
+ modelValue: 5000,
1855
+ });
1856
+ return;
1857
+ }
1735
1858
 
1736
1859
  // Confirm deletion
1737
1860
  const itemName = (selectedItem.value as any).name || (selectedItem.value as any).title || singularItemName.value;
@@ -1786,6 +1909,12 @@ const handleDelete = async () => {
1786
1909
 
1787
1910
  defineExpose({
1788
1911
  refresh,
1912
+ /**
1913
+ * Open the built-in create modal (same as clicking the default "New {item}"
1914
+ * button). Lets the create action live outside the table card — e.g. in a
1915
+ * page header or the dashboard navbar. No-op unless `editFields` are set.
1916
+ */
1917
+ openCreate: handleCreateNew,
1789
1918
  });
1790
1919
  </script>
1791
1920
 
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  reactive,
3
3
  computed,
4
+ toRaw,
4
5
  type ComputedRef,
5
6
  type WritableComputedRef,
6
7
  } from "vue";
@@ -71,10 +72,69 @@ export interface UseFormReturn<TData extends Record<string, any>>
71
72
 
72
73
  // ————————————————— helpers
73
74
 
74
- const deepClone = <T>(v: T): T =>
75
- typeof structuredClone === "function"
76
- ? structuredClone(v)
77
- : JSON.parse(JSON.stringify(v));
75
+ // Deep-clone the seed data for the form's working copy. `structuredClone`
76
+ // refuses to clone a Vue reactive Proxy (a `default: []` / `default: {}` inside
77
+ // a reactive `editFields` ref is a Proxy) and throws DataCloneError. `toRaw`
78
+ // unwraps the top-level proxy; a try/catch falls back to a JSON clone for any
79
+ // residual proxy (nested reactive values) or otherwise-uncloneable input. Form
80
+ // data is JSON-bound at the API boundary, so the JSON fallback is lossless here.
81
+ const deepClone = <T>(value: T): T => {
82
+ const raw = toRaw(value);
83
+ if (typeof structuredClone === "function") {
84
+ try {
85
+ return structuredClone(raw);
86
+ } catch {
87
+ // Fall through to the JSON clone below.
88
+ }
89
+ }
90
+ return JSON.parse(JSON.stringify(raw));
91
+ };
92
+
93
+ const isFileLike = (value: unknown): boolean =>
94
+ (typeof File !== "undefined" && value instanceof File) ||
95
+ (typeof Blob !== "undefined" && value instanceof Blob);
96
+
97
+ /** Whether a payload contains a File/Blob anywhere (→ needs multipart). */
98
+ function containsFile(value: unknown): boolean {
99
+ if (isFileLike(value)) return true;
100
+ if (Array.isArray(value)) return value.some(containsFile);
101
+ if (value !== null && typeof value === "object") {
102
+ return Object.values(value as Record<string, unknown>).some(containsFile);
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** Append a value to FormData using Laravel-style bracket keys. */
108
+ function appendToFormData(fd: FormData, key: string, value: unknown): void {
109
+ if (value === null || value === undefined) {
110
+ fd.append(key, "");
111
+ } else if (isFileLike(value)) {
112
+ fd.append(key, value as Blob);
113
+ } else if (Array.isArray(value)) {
114
+ value.forEach((entry, index) =>
115
+ appendToFormData(fd, `${key}[${index}]`, entry),
116
+ );
117
+ } else if (value instanceof Date) {
118
+ fd.append(key, value.toISOString());
119
+ } else if (typeof value === "object") {
120
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
121
+ appendToFormData(fd, `${key}[${k}]`, v);
122
+ }
123
+ } else if (typeof value === "boolean") {
124
+ // Laravel's boolean validation accepts "1"/"0".
125
+ fd.append(key, value ? "1" : "0");
126
+ } else {
127
+ fd.append(key, String(value));
128
+ }
129
+ }
130
+
131
+ function objectToFormData(payload: Record<string, unknown>): FormData {
132
+ const fd = new FormData();
133
+ for (const [key, value] of Object.entries(payload)) {
134
+ appendToFormData(fd, key, value);
135
+ }
136
+ return fd;
137
+ }
78
138
 
79
139
  function errorsFromLaravel(error: any): {
80
140
  errors: ValidationErrors;
@@ -201,15 +261,39 @@ export function useForm<TData extends Record<string, any>>(
201
261
 
202
262
  options.onBefore?.(state.data as TData);
203
263
 
264
+ // When the payload carries a File/Blob (an image/file field), send it as
265
+ // multipart/form-data. PHP only parses multipart bodies on POST, so a
266
+ // put/patch is spoofed as POST + `_method` (Laravel reads that). This
267
+ // also covers a `transform` that returns a FormData directly.
268
+ let sendMethod = method;
269
+ let sendPayload: any = payloadRaw;
270
+ if (method !== "get" && payloadRaw !== null && typeof payloadRaw === "object") {
271
+ const alreadyFormData =
272
+ typeof FormData !== "undefined" && payloadRaw instanceof FormData;
273
+ let formData: FormData | null = null;
274
+ if (alreadyFormData) {
275
+ formData = payloadRaw as FormData;
276
+ } else if (containsFile(payloadRaw)) {
277
+ formData = objectToFormData(payloadRaw as Record<string, unknown>);
278
+ }
279
+ if (formData) {
280
+ if (method === "put" || method === "patch") {
281
+ formData.append("_method", method.toUpperCase());
282
+ sendMethod = "post";
283
+ }
284
+ sendPayload = formData;
285
+ }
286
+ }
287
+
204
288
  try {
205
289
  const { data } =
206
- method === "get"
290
+ sendMethod === "get"
207
291
  ? await api.get<TResponse>(
208
292
  url,
209
- payloadRaw as Record<string, unknown>,
293
+ sendPayload as Record<string, unknown>,
210
294
  { signal: options?.signal },
211
295
  )
212
- : await api[method]<TResponse>(url, payloadRaw, {
296
+ : await api[sendMethod]<TResponse>(url, sendPayload, {
213
297
  signal: options?.signal,
214
298
  });
215
299
 
@@ -9,6 +9,10 @@ export { default as DXDashboard } from "./components/extended/DXDashboard.vue";
9
9
  export { default as DXForm } from "./components/extended/DXForm.vue";
10
10
  export { default as DXField } from "./components/extended/DXField.vue";
11
11
  export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
12
+ export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
13
+ export { default as DXBarChart } from "./components/charts/DXBarChart.vue";
14
+ export { default as DXLineChart } from "./components/charts/DXLineChart.vue";
15
+ export { default as DXDoughnutChart } from "./components/charts/DXDoughnutChart.vue";
12
16
  export { default as DXRepeater } from "./components/extended/DXRepeater.vue";
13
17
  /**
14
18
  * @deprecated Use `DXForm`. `DXBasicForm` is a thin wrapper around `DXForm`