@omnitend/dashboard-for-laravel 0.11.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.
- package/dist/components/charts/DXBarChart.vue.d.ts +22 -0
- package/dist/components/charts/DXDoughnutChart.vue.d.ts +24 -0
- package/dist/components/charts/DXLineChart.vue.d.ts +22 -0
- package/dist/components/charts/chartTheme.d.ts +32 -0
- package/dist/components/extended/DXStatCard.vue.d.ts +56 -0
- package/dist/components/extended/DXTable.vue.d.ts +11 -1
- package/dist/dashboard-for-laravel.js +7562 -7172
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/style.css +1 -1
- package/docs/public/api-reference.json +303 -6
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +12 -2
- package/resources/js/components/charts/DXBarChart.vue +79 -0
- package/resources/js/components/charts/DXDoughnutChart.vue +85 -0
- package/resources/js/components/charts/DXLineChart.vue +79 -0
- package/resources/js/components/charts/chartTheme.ts +229 -0
- package/resources/js/components/extended/DXStatCard.vue +211 -0
- package/resources/js/components/extended/DXTable.vue +108 -8
- package/resources/js/composables/useForm.ts +91 -7
- package/resources/js/index.ts +4 -0
|
@@ -346,7 +346,7 @@
|
|
|
346
346
|
<div v-else></div>
|
|
347
347
|
|
|
348
348
|
<!-- Per-page selector -->
|
|
349
|
-
<div v-if="
|
|
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,6 +801,16 @@ 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
|
|
|
@@ -829,7 +849,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
829
849
|
perPageOptions: () => [10, 25, 50, 100],
|
|
830
850
|
currentPage: 1,
|
|
831
851
|
// perPage: 10, // Don't set default - let internalPerPage handle it
|
|
832
|
-
striped:
|
|
852
|
+
striped: false,
|
|
833
853
|
hover: true,
|
|
834
854
|
responsive: true,
|
|
835
855
|
fluid: false,
|
|
@@ -1185,9 +1205,11 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1185
1205
|
if (!props.showPerPageSelector) return false;
|
|
1186
1206
|
|
|
1187
1207
|
const smallestOption = Math.min(...props.perPageOptions);
|
|
1188
|
-
const total =
|
|
1189
|
-
?
|
|
1190
|
-
:
|
|
1208
|
+
const total = isClientSideMode.value
|
|
1209
|
+
? clientSidePagination.value?.total || 0
|
|
1210
|
+
: isInertiaMode.value
|
|
1211
|
+
? props.pagination?.total || 0
|
|
1212
|
+
: apiPaginationMeta.value?.total || 0;
|
|
1191
1213
|
|
|
1192
1214
|
return total >= smallestOption;
|
|
1193
1215
|
});
|
|
@@ -1472,6 +1494,19 @@ const isCreateMode = ref(false);
|
|
|
1472
1494
|
// request the form makes, so it can't tell Save from Delete on its own.
|
|
1473
1495
|
const pendingAction = ref<'save' | 'delete' | null>(null);
|
|
1474
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
|
+
|
|
1475
1510
|
// Toast (may not be available in test environment)
|
|
1476
1511
|
let createToast: ((obj: any) => any) | undefined;
|
|
1477
1512
|
try {
|
|
@@ -1552,10 +1587,15 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1552
1587
|
// Reset to first tab
|
|
1553
1588
|
activeTabIndex.value = 0;
|
|
1554
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
|
+
|
|
1555
1594
|
// Initialize form with item data
|
|
1556
1595
|
if (!editForm.value) {
|
|
1557
1596
|
// Dynamically import useForm to avoid circular dependency
|
|
1558
1597
|
import('../../composables/useForm').then(({ useForm }) => {
|
|
1598
|
+
if (openToken !== modalOpenToken) return; // superseded
|
|
1559
1599
|
const formData: Record<string, any> = {};
|
|
1560
1600
|
props.editFields!.forEach(field => {
|
|
1561
1601
|
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
@@ -1564,6 +1604,9 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1564
1604
|
|
|
1565
1605
|
// Open modal
|
|
1566
1606
|
showEditModal.value = true;
|
|
1607
|
+
|
|
1608
|
+
// Optionally replace the row-seeded data with the full record.
|
|
1609
|
+
if (props.showUrl) void fetchFullRecordForEdit(item);
|
|
1567
1610
|
});
|
|
1568
1611
|
} else {
|
|
1569
1612
|
// Update existing form
|
|
@@ -1574,10 +1617,54 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1574
1617
|
|
|
1575
1618
|
// Open modal
|
|
1576
1619
|
showEditModal.value = true;
|
|
1620
|
+
|
|
1621
|
+
// Optionally replace the row-seeded data with the full record.
|
|
1622
|
+
if (props.showUrl) void fetchFullRecordForEdit(item);
|
|
1577
1623
|
}
|
|
1578
1624
|
}
|
|
1579
1625
|
};
|
|
1580
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 };
|
|
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;
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1581
1668
|
// Handle "New" button click
|
|
1582
1669
|
const handleCreateNew = () => {
|
|
1583
1670
|
if (!props.editFields || props.editFields.length === 0) return;
|
|
@@ -1586,6 +1673,12 @@ const handleCreateNew = () => {
|
|
|
1586
1673
|
selectedItem.value = null;
|
|
1587
1674
|
activeTabIndex.value = 0;
|
|
1588
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
|
+
|
|
1589
1682
|
const initForm = (useForm: any) => {
|
|
1590
1683
|
const formData: Record<string, any> = {};
|
|
1591
1684
|
props.editFields!.forEach(field => {
|
|
@@ -1597,6 +1690,7 @@ const handleCreateNew = () => {
|
|
|
1597
1690
|
|
|
1598
1691
|
if (!editForm.value) {
|
|
1599
1692
|
import('../../composables/useForm').then(({ useForm }) => {
|
|
1693
|
+
if (openToken !== modalOpenToken) return; // superseded
|
|
1600
1694
|
initForm(useForm);
|
|
1601
1695
|
});
|
|
1602
1696
|
} else {
|
|
@@ -1734,6 +1828,9 @@ const handleEditCancel = () => {
|
|
|
1734
1828
|
selectedItem.value = null;
|
|
1735
1829
|
isCreateMode.value = false;
|
|
1736
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;
|
|
1737
1834
|
if (editForm.value) {
|
|
1738
1835
|
editForm.value.clearErrors();
|
|
1739
1836
|
}
|
|
@@ -1742,6 +1839,9 @@ const handleEditCancel = () => {
|
|
|
1742
1839
|
// Handle delete from edit modal
|
|
1743
1840
|
const handleDelete = async () => {
|
|
1744
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;
|
|
1745
1845
|
|
|
1746
1846
|
// Delete guard: a non-null message means this item can't be deleted — show
|
|
1747
1847
|
// it immediately and skip the confirm and the request entirely.
|
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
290
|
+
sendMethod === "get"
|
|
207
291
|
? await api.get<TResponse>(
|
|
208
292
|
url,
|
|
209
|
-
|
|
293
|
+
sendPayload as Record<string, unknown>,
|
|
210
294
|
{ signal: options?.signal },
|
|
211
295
|
)
|
|
212
|
-
: await api[
|
|
296
|
+
: await api[sendMethod]<TResponse>(url, sendPayload, {
|
|
213
297
|
signal: options?.signal,
|
|
214
298
|
});
|
|
215
299
|
|
package/resources/js/index.ts
CHANGED
|
@@ -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`
|