@omnitend/dashboard-for-laravel 0.11.0 → 0.14.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/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 +18 -1
- package/dist/components/extended/DXTableShell.vue.d.ts +24 -0
- package/dist/dashboard-for-laravel.js +7680 -7256
- 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 +339 -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 +134 -45
- package/resources/js/components/extended/DXTableShell.vue +54 -0
- package/resources/js/composables/useForm.ts +91 -7
- package/resources/js/index.ts +4 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<DContainer :fluid="fluid" :class="containerClass">
|
|
10
10
|
<DRow class="justify-content-center">
|
|
11
11
|
<DCol :md="columnSize">
|
|
12
|
-
<
|
|
12
|
+
<DXTableShell :card="card">
|
|
13
13
|
<template v-if="title || createUrl || $slots.header" #header>
|
|
14
14
|
<!-- @slot Card header content; overrides the default title heading and the "New {item}" button. -->
|
|
15
15
|
<slot name="header">
|
|
@@ -69,13 +69,15 @@
|
|
|
69
69
|
@update:model-value="handleFilterChange(field.key, $event as string)"
|
|
70
70
|
/>
|
|
71
71
|
|
|
72
|
-
<!-- Select Filter -->
|
|
73
|
-
<
|
|
72
|
+
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
73
|
+
<DAutocomplete
|
|
74
74
|
v-else-if="field.filter === 'select'"
|
|
75
75
|
:model-value="effectiveFilters[field.key] || ''"
|
|
76
|
-
:options="
|
|
76
|
+
:options="getFieldFilterOptions(field)"
|
|
77
|
+
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
77
78
|
size="sm"
|
|
78
|
-
|
|
79
|
+
open-on-focus
|
|
80
|
+
@update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
|
|
79
81
|
/>
|
|
80
82
|
|
|
81
83
|
<!-- Number Filter -->
|
|
@@ -165,13 +167,15 @@
|
|
|
165
167
|
@update:model-value="handleFilterChange(field.key, $event as string)"
|
|
166
168
|
/>
|
|
167
169
|
|
|
168
|
-
<!-- Select Filter -->
|
|
169
|
-
<
|
|
170
|
+
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
171
|
+
<DAutocomplete
|
|
170
172
|
v-else-if="field.filter === 'select'"
|
|
171
173
|
:model-value="effectiveFilters[field.key] || ''"
|
|
172
|
-
:options="
|
|
174
|
+
:options="getFieldFilterOptions(field)"
|
|
175
|
+
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
173
176
|
size="sm"
|
|
174
|
-
|
|
177
|
+
open-on-focus
|
|
178
|
+
@update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
|
|
175
179
|
/>
|
|
176
180
|
|
|
177
181
|
<!-- Number Filter -->
|
|
@@ -262,13 +266,15 @@
|
|
|
262
266
|
@update:model-value="handleFilterChange(field.key, $event as string)"
|
|
263
267
|
/>
|
|
264
268
|
|
|
265
|
-
<!-- Select Filter -->
|
|
266
|
-
<
|
|
269
|
+
<!-- Select Filter: typeahead — browse the full list on focus, or type to narrow; clear (✕) resets to "no filter" -->
|
|
270
|
+
<DAutocomplete
|
|
267
271
|
v-else-if="field.filter === 'select'"
|
|
268
272
|
:model-value="effectiveFilters[field.key] || ''"
|
|
269
|
-
:options="
|
|
273
|
+
:options="getFieldFilterOptions(field)"
|
|
274
|
+
:placeholder="field.filterPlaceholder || `All ${field.label || field.key}`"
|
|
270
275
|
size="sm"
|
|
271
|
-
|
|
276
|
+
open-on-focus
|
|
277
|
+
@update:model-value="handleFilterChange(field.key, ($event ?? '') as string)"
|
|
272
278
|
/>
|
|
273
279
|
|
|
274
280
|
<!-- Number Filter -->
|
|
@@ -346,7 +352,7 @@
|
|
|
346
352
|
<div v-else></div>
|
|
347
353
|
|
|
348
354
|
<!-- Per-page selector -->
|
|
349
|
-
<div v-if="
|
|
355
|
+
<div v-if="shouldShowPerPageSelector" class="d-flex align-items-center gap-2">
|
|
350
356
|
<label for="perPageSelectClientSide" class="mb-0 small text-muted">Per page</label>
|
|
351
357
|
<DFormSelect
|
|
352
358
|
id="perPageSelectClientSide"
|
|
@@ -473,7 +479,7 @@
|
|
|
473
479
|
</div>
|
|
474
480
|
</div>
|
|
475
481
|
</div>
|
|
476
|
-
</
|
|
482
|
+
</DXTableShell>
|
|
477
483
|
</DCol>
|
|
478
484
|
</DRow>
|
|
479
485
|
|
|
@@ -484,6 +490,15 @@
|
|
|
484
490
|
:title="computedModalTitle"
|
|
485
491
|
:size="editModalSize"
|
|
486
492
|
>
|
|
493
|
+
<!-- Loading the full record for edit (showUrl fetch) -->
|
|
494
|
+
<div
|
|
495
|
+
v-if="editLoading"
|
|
496
|
+
class="dx-edit-loading d-flex align-items-center gap-2 text-muted mb-3"
|
|
497
|
+
>
|
|
498
|
+
<DSpinner small />
|
|
499
|
+
<span>Loading…</span>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
487
502
|
<!-- Edit/create form (tabbed when editTabs provided, flat otherwise) -->
|
|
488
503
|
<DXForm
|
|
489
504
|
v-if="editForm"
|
|
@@ -584,7 +599,7 @@
|
|
|
584
599
|
<DButton
|
|
585
600
|
v-if="deleteUrl && !isCreateMode"
|
|
586
601
|
variant="danger"
|
|
587
|
-
:disabled="editForm?.processing"
|
|
602
|
+
:disabled="editForm?.processing || editLoading"
|
|
588
603
|
@click="handleDelete"
|
|
589
604
|
>
|
|
590
605
|
{{ pendingAction === 'delete' ? 'Deleting...' : 'Delete' }}
|
|
@@ -596,7 +611,7 @@
|
|
|
596
611
|
</DButton>
|
|
597
612
|
<DButton
|
|
598
613
|
variant="primary"
|
|
599
|
-
:disabled="editForm?.processing"
|
|
614
|
+
:disabled="editForm?.processing || editLoading"
|
|
600
615
|
@click="handleEditSave"
|
|
601
616
|
>
|
|
602
617
|
<template v-if="isCreateMode">
|
|
@@ -619,15 +634,17 @@ import { router } from "@inertiajs/vue3";
|
|
|
619
634
|
import axios from "axios";
|
|
620
635
|
import pluralize from "pluralize";
|
|
621
636
|
import { useToast } from "../../composables/useToast";
|
|
637
|
+
import { useForm } from "../../composables/useForm";
|
|
622
638
|
import DContainer from "../base/DContainer.vue";
|
|
623
639
|
import DRow from "../base/DRow.vue";
|
|
624
640
|
import DCol from "../base/DCol.vue";
|
|
625
|
-
import
|
|
641
|
+
import DXTableShell from "./DXTableShell.vue";
|
|
626
642
|
import DSpinner from "../base/DSpinner.vue";
|
|
627
643
|
import DTable from "../base/DTable.vue";
|
|
628
644
|
import DPagination from "../base/DPagination.vue";
|
|
629
645
|
import DFormInput from "../base/DFormInput.vue";
|
|
630
646
|
import DFormSelect from "../base/DFormSelect.vue";
|
|
647
|
+
import DAutocomplete from "../base/DAutocomplete.vue";
|
|
631
648
|
import DModal from "../base/DModal.vue";
|
|
632
649
|
import DButton from "../base/DButton.vue";
|
|
633
650
|
import DXForm from "./DXForm.vue";
|
|
@@ -757,7 +774,8 @@ export interface Props<TItem = any> {
|
|
|
757
774
|
/** Items per page (for provider mode, v-model support) */
|
|
758
775
|
perPage?: number;
|
|
759
776
|
|
|
760
|
-
/** Striped rows
|
|
777
|
+
/** Striped (banded) rows. Off by default — banding adds little on short
|
|
778
|
+
* tables and can read as noise; opt in with `:striped="true"`. */
|
|
761
779
|
striped?: boolean;
|
|
762
780
|
|
|
763
781
|
/** Hover effect on rows */
|
|
@@ -775,6 +793,14 @@ export interface Props<TItem = any> {
|
|
|
775
793
|
/** Column size (Bootstrap grid) */
|
|
776
794
|
columnSize?: string | number;
|
|
777
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Wrap the table in a `DCard`. Defaults to `true`. Set `false` for a plain,
|
|
798
|
+
* borderless variant — the same header, filter row, table and pagination
|
|
799
|
+
* rendered directly on the page background (no card border/shadow), for
|
|
800
|
+
* data-heavy admin index pages that read as busy inside a card.
|
|
801
|
+
*/
|
|
802
|
+
card?: boolean;
|
|
803
|
+
|
|
778
804
|
// Edit Modal Props
|
|
779
805
|
/** Form field definitions for edit modal (if provided, enables edit on row click) */
|
|
780
806
|
editFields?: any[]; // FieldDefinition[] - using any to avoid circular import
|
|
@@ -791,6 +817,16 @@ export interface Props<TItem = any> {
|
|
|
791
817
|
/** API endpoint pattern for updates (e.g., "/api/products/:id") */
|
|
792
818
|
editUrl?: string;
|
|
793
819
|
|
|
820
|
+
/**
|
|
821
|
+
* API endpoint pattern to fetch the FULL record for the edit modal
|
|
822
|
+
* (e.g., "/api/products/:id"). When set, opening a row seeds the form from
|
|
823
|
+
* this fetch (shown with a loading state) instead of the thin list row —
|
|
824
|
+
* use when the list payload omits fields the edit form needs (notes, nested
|
|
825
|
+
* relations, repeater rows, …). The response is unwrapped from `data.data`
|
|
826
|
+
* (Laravel API Resource) or used as-is.
|
|
827
|
+
*/
|
|
828
|
+
showUrl?: string;
|
|
829
|
+
|
|
794
830
|
/** API endpoint pattern for deletions (e.g., "/api/products/:id") */
|
|
795
831
|
deleteUrl?: string;
|
|
796
832
|
|
|
@@ -829,12 +865,13 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
829
865
|
perPageOptions: () => [10, 25, 50, 100],
|
|
830
866
|
currentPage: 1,
|
|
831
867
|
// perPage: 10, // Don't set default - let internalPerPage handle it
|
|
832
|
-
striped:
|
|
868
|
+
striped: false,
|
|
833
869
|
hover: true,
|
|
834
870
|
responsive: true,
|
|
835
871
|
fluid: false,
|
|
836
872
|
containerClass: "py-5",
|
|
837
873
|
columnSize: "12",
|
|
874
|
+
card: true,
|
|
838
875
|
editModalSize: "lg",
|
|
839
876
|
});
|
|
840
877
|
|
|
@@ -1185,9 +1222,11 @@ const shouldShowPerPageSelector = computed(() => {
|
|
|
1185
1222
|
if (!props.showPerPageSelector) return false;
|
|
1186
1223
|
|
|
1187
1224
|
const smallestOption = Math.min(...props.perPageOptions);
|
|
1188
|
-
const total =
|
|
1189
|
-
?
|
|
1190
|
-
:
|
|
1225
|
+
const total = isClientSideMode.value
|
|
1226
|
+
? clientSidePagination.value?.total || 0
|
|
1227
|
+
: isInertiaMode.value
|
|
1228
|
+
? props.pagination?.total || 0
|
|
1229
|
+
: apiPaginationMeta.value?.total || 0;
|
|
1191
1230
|
|
|
1192
1231
|
return total >= smallestOption;
|
|
1193
1232
|
});
|
|
@@ -1472,6 +1511,13 @@ const isCreateMode = ref(false);
|
|
|
1472
1511
|
// request the form makes, so it can't tell Save from Delete on its own.
|
|
1473
1512
|
const pendingAction = ref<'save' | 'delete' | null>(null);
|
|
1474
1513
|
|
|
1514
|
+
// True while the edit modal is fetching the full record via `showUrl`.
|
|
1515
|
+
const editLoading = ref(false);
|
|
1516
|
+
|
|
1517
|
+
// Monotonic token so a slow fetch for a previously-opened row can't overwrite
|
|
1518
|
+
// the form after the user has since opened a different row.
|
|
1519
|
+
let editFetchToken = 0;
|
|
1520
|
+
|
|
1475
1521
|
// Toast (may not be available in test environment)
|
|
1476
1522
|
let createToast: ((obj: any) => any) | undefined;
|
|
1477
1523
|
try {
|
|
@@ -1552,29 +1598,68 @@ const handleRowClick = (item: T, index: number, event: MouseEvent) => {
|
|
|
1552
1598
|
// Reset to first tab
|
|
1553
1599
|
activeTabIndex.value = 0;
|
|
1554
1600
|
|
|
1555
|
-
// Initialize form with item data
|
|
1601
|
+
// Initialize form with item data. `useForm` is statically imported, so
|
|
1602
|
+
// seeding is synchronous — no interleaving between successive row opens.
|
|
1556
1603
|
if (!editForm.value) {
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
props.editFields!.forEach(field => {
|
|
1561
|
-
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1562
|
-
});
|
|
1563
|
-
editForm.value = useForm(formData);
|
|
1564
|
-
|
|
1565
|
-
// Open modal
|
|
1566
|
-
showEditModal.value = true;
|
|
1604
|
+
const formData: Record<string, any> = {};
|
|
1605
|
+
props.editFields!.forEach(field => {
|
|
1606
|
+
formData[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1567
1607
|
});
|
|
1608
|
+
editForm.value = useForm(formData);
|
|
1568
1609
|
} else {
|
|
1569
1610
|
// Update existing form
|
|
1570
1611
|
props.editFields.forEach(field => {
|
|
1571
1612
|
editForm.value.data[field.key] = (item as any)[field.key] ?? field.default ?? '';
|
|
1572
1613
|
});
|
|
1573
1614
|
editForm.value.clearErrors();
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Open modal
|
|
1618
|
+
showEditModal.value = true;
|
|
1619
|
+
|
|
1620
|
+
// Optionally replace the row-seeded data with the full record.
|
|
1621
|
+
if (props.showUrl) void fetchFullRecordForEdit(item);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
// Fetch the full record for the edit modal and re-seed the form from it. The
|
|
1626
|
+
// form is already showing row data; this fills in fields the list row omitted.
|
|
1627
|
+
const fetchFullRecordForEdit = async (item: T) => {
|
|
1628
|
+
const itemId = (item as any).id;
|
|
1629
|
+
if (itemId === undefined || itemId === null) return;
|
|
1574
1630
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1631
|
+
const token = ++editFetchToken;
|
|
1632
|
+
editLoading.value = true;
|
|
1633
|
+
try {
|
|
1634
|
+
const url = props.showUrl!.replace(':id', String(itemId));
|
|
1635
|
+
const response = await axios.get(url);
|
|
1636
|
+
// Superseded by a newer row-open — discard.
|
|
1637
|
+
if (token !== editFetchToken) return;
|
|
1638
|
+
|
|
1639
|
+
const record = response.data?.data ?? response.data;
|
|
1640
|
+
if (record && editForm.value) {
|
|
1641
|
+
props.editFields!.forEach(field => {
|
|
1642
|
+
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
1643
|
+
editForm.value.data[field.key] = record[field.key];
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
// Widen selectedItem so predicates / the delete guard see full data.
|
|
1647
|
+
selectedItem.value = { ...(selectedItem.value as any), ...record };
|
|
1577
1648
|
}
|
|
1649
|
+
} catch (error: any) {
|
|
1650
|
+
if (token !== editFetchToken) return;
|
|
1651
|
+
const message =
|
|
1652
|
+
error?.response?.data?.message ??
|
|
1653
|
+
error?.message ??
|
|
1654
|
+
'Failed to load the full record.';
|
|
1655
|
+
createToast?.({
|
|
1656
|
+
title: 'Error',
|
|
1657
|
+
body: message,
|
|
1658
|
+
variant: 'danger',
|
|
1659
|
+
modelValue: 5000,
|
|
1660
|
+
});
|
|
1661
|
+
} finally {
|
|
1662
|
+
if (token === editFetchToken) editLoading.value = false;
|
|
1578
1663
|
}
|
|
1579
1664
|
};
|
|
1580
1665
|
|
|
@@ -1586,27 +1671,25 @@ const handleCreateNew = () => {
|
|
|
1586
1671
|
selectedItem.value = null;
|
|
1587
1672
|
activeTabIndex.value = 0;
|
|
1588
1673
|
|
|
1589
|
-
|
|
1674
|
+
// Supersede any in-flight row-open fetch so a slow showUrl fetch from an
|
|
1675
|
+
// earlier edit can't seed over this create.
|
|
1676
|
+
editFetchToken++;
|
|
1677
|
+
editLoading.value = false;
|
|
1678
|
+
|
|
1679
|
+
if (!editForm.value) {
|
|
1590
1680
|
const formData: Record<string, any> = {};
|
|
1591
1681
|
props.editFields!.forEach(field => {
|
|
1592
1682
|
formData[field.key] = field.default ?? '';
|
|
1593
1683
|
});
|
|
1594
1684
|
editForm.value = useForm(formData);
|
|
1595
|
-
showEditModal.value = true;
|
|
1596
|
-
};
|
|
1597
|
-
|
|
1598
|
-
if (!editForm.value) {
|
|
1599
|
-
import('../../composables/useForm').then(({ useForm }) => {
|
|
1600
|
-
initForm(useForm);
|
|
1601
|
-
});
|
|
1602
1685
|
} else {
|
|
1603
1686
|
// Reset existing form to defaults
|
|
1604
1687
|
props.editFields!.forEach(field => {
|
|
1605
1688
|
editForm.value.data[field.key] = field.default ?? '';
|
|
1606
1689
|
});
|
|
1607
1690
|
editForm.value.clearErrors();
|
|
1608
|
-
showEditModal.value = true;
|
|
1609
1691
|
}
|
|
1692
|
+
showEditModal.value = true;
|
|
1610
1693
|
};
|
|
1611
1694
|
|
|
1612
1695
|
// Handle save from edit modal
|
|
@@ -1734,6 +1817,9 @@ const handleEditCancel = () => {
|
|
|
1734
1817
|
selectedItem.value = null;
|
|
1735
1818
|
isCreateMode.value = false;
|
|
1736
1819
|
activeTabIndex.value = 0; // Reset tab for next time
|
|
1820
|
+
// Abandon any in-flight showUrl fetch so it can't seed a closed modal.
|
|
1821
|
+
editFetchToken++;
|
|
1822
|
+
editLoading.value = false;
|
|
1737
1823
|
if (editForm.value) {
|
|
1738
1824
|
editForm.value.clearErrors();
|
|
1739
1825
|
}
|
|
@@ -1742,6 +1828,9 @@ const handleEditCancel = () => {
|
|
|
1742
1828
|
// Handle delete from edit modal
|
|
1743
1829
|
const handleDelete = async () => {
|
|
1744
1830
|
if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
|
|
1831
|
+
// Don't evaluate the guard until the full record has loaded (showUrl) — it
|
|
1832
|
+
// may depend on fields the thin list row doesn't carry.
|
|
1833
|
+
if (editLoading.value) return;
|
|
1745
1834
|
|
|
1746
1835
|
// Delete guard: a non-null message means this item can't be deleted — show
|
|
1747
1836
|
// it immediately and skip the confirm and the request entirely.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
Internal chrome wrapper for `DXTable`. Renders its content either inside a
|
|
4
|
+
`DCard` (the default) or as a plain, borderless block on the page background
|
|
5
|
+
(`:card="false"`). Extracted so `DXTable`'s ~460-line body and all its slots
|
|
6
|
+
aren't duplicated across a card/plain `v-if`.
|
|
7
|
+
|
|
8
|
+
Slots:
|
|
9
|
+
- `#header` — the title / action row. In card mode it fills `DCard`'s header;
|
|
10
|
+
in plain mode it renders above the table with a subtle bottom rule.
|
|
11
|
+
- default — the table body (spinner/error/table/pagination).
|
|
12
|
+
-->
|
|
13
|
+
<template>
|
|
14
|
+
<DCard v-if="card">
|
|
15
|
+
<template v-if="$slots.header" #header>
|
|
16
|
+
<slot name="header" />
|
|
17
|
+
</template>
|
|
18
|
+
<slot />
|
|
19
|
+
</DCard>
|
|
20
|
+
|
|
21
|
+
<div v-else class="dx-table-plain">
|
|
22
|
+
<div v-if="$slots.header" class="dx-table-plain__header">
|
|
23
|
+
<slot name="header" />
|
|
24
|
+
</div>
|
|
25
|
+
<slot />
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import DCard from '../base/DCard.vue';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/** Wrap the content in a `DCard`. When false, render plain/borderless. */
|
|
34
|
+
card?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
withDefaults(defineProps<Props>(), {
|
|
38
|
+
card: true,
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<style scoped>
|
|
43
|
+
/*
|
|
44
|
+
Plain variant: no card border/shadow/background — the table sits directly on
|
|
45
|
+
the page. The header row gets a subtle bottom rule so it still reads as a
|
|
46
|
+
distinct band above the table (mirrors the card header's separation without
|
|
47
|
+
the full card chrome).
|
|
48
|
+
*/
|
|
49
|
+
.dx-table-plain__header {
|
|
50
|
+
padding-bottom: 0.75rem;
|
|
51
|
+
margin-bottom: 0.75rem;
|
|
52
|
+
border-bottom: 1px solid var(--bs-border-color);
|
|
53
|
+
}
|
|
54
|
+
</style>
|
|
@@ -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`
|