@omnitend/dashboard-for-laravel 0.27.0 → 0.29.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/charts.d.ts +19 -0
- package/dist/charts.js +225 -0
- package/dist/charts.js.map +1 -0
- package/dist/charts.umd.cjs +2 -0
- package/dist/charts.umd.cjs.map +1 -0
- package/dist/components/base/DAlert.vue.d.ts +6 -1
- package/dist/components/base/DFormCheckboxGroup.vue.d.ts +12 -0
- package/dist/components/charts/chartTheme.d.ts +1 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +2 -2
- package/dist/components/extended/DXField.vue.d.ts +4 -4
- package/dist/components/extended/DXForm.vue.d.ts +1 -1
- package/dist/components/extended/DXTable.vue.d.ts +9 -44
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +80 -0
- package/dist/components/extended/DXTablePagination.vue.d.ts +27 -0
- package/dist/composables/useResourceEditor.d.ts +74 -0
- package/dist/dashboard-for-laravel.js +12463 -12819
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -3
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/public/api-reference.json +224 -9
- package/docs/public/docs-map.md +2 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +12 -6
- package/resources/css/theme.scss +49 -7
- package/resources/js/charts.ts +19 -0
- package/resources/js/components/base/DAlert.vue +19 -1
- package/resources/js/components/base/DFormCheckboxGroup.vue +22 -0
- package/resources/js/components/charts/DXBarChart.vue +1 -10
- package/resources/js/components/charts/DXDoughnutChart.vue +1 -10
- package/resources/js/components/charts/DXLineChart.vue +1 -10
- package/resources/js/components/charts/chartTheme.ts +19 -16
- package/resources/js/components/extended/DXField.vue +12 -0
- package/resources/js/components/extended/DXTable.vue +161 -1034
- package/resources/js/components/extended/DXTableEditorModal.vue +247 -0
- package/resources/js/components/extended/DXTablePagination.vue +126 -0
- package/resources/js/composables/useForm.ts +10 -1
- package/resources/js/composables/useResourceEditor.ts +542 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/types/index.ts +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { computed, ref, type Ref } from "vue";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { useForm } from "./useForm";
|
|
4
|
+
import { useToast } from "./useToast";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The subset of `DXTable`'s props the resource editor reads. Declared
|
|
8
|
+
* structurally (not imported from DXTable.vue) so the composable stays a
|
|
9
|
+
* lower-level dependency — DXTable imports the composable, not the reverse.
|
|
10
|
+
* DXTable's full `Props` is structurally assignable to this.
|
|
11
|
+
*/
|
|
12
|
+
export interface ResourceEditorProps<T = any> {
|
|
13
|
+
/** Field definitions for the edit/create modal (enables edit-on-row-click). */
|
|
14
|
+
editFields?: any[];
|
|
15
|
+
/** Tab definitions — only their `key` is read here (DXForm consumes the rest). */
|
|
16
|
+
editTabs?: Array<{ key: string }>;
|
|
17
|
+
/** Modal title, or a function of the edited row. */
|
|
18
|
+
editModalTitle?: string | ((item: any) => string);
|
|
19
|
+
/** `PUT` endpoint pattern for updates, e.g. `/api/products/:id`. */
|
|
20
|
+
editUrl?: string;
|
|
21
|
+
/** `GET` endpoint pattern to fetch the full record on open, e.g. `/api/products/:id`. */
|
|
22
|
+
showUrl?: string;
|
|
23
|
+
/** `DELETE` endpoint pattern, e.g. `/api/products/:id`. */
|
|
24
|
+
deleteUrl?: string;
|
|
25
|
+
/** `POST` endpoint for creating new items, e.g. `/api/products`. */
|
|
26
|
+
createUrl?: string;
|
|
27
|
+
/** Guard run before delete — a message short-circuits with a toast. */
|
|
28
|
+
deleteGuard?: (item: T) => string | null | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The events the editor emits back through DXTable's `emit`. Declared as the
|
|
33
|
+
* exact call signatures it uses (a subset of DXTable's emits) so DXTable's typed
|
|
34
|
+
* `emit` is structurally assignable here.
|
|
35
|
+
*/
|
|
36
|
+
export interface ResourceEditorEmit<T = any> {
|
|
37
|
+
(event: 'rowCreated', item: any, response: any): void;
|
|
38
|
+
(event: 'createError', error: any): void;
|
|
39
|
+
(event: 'rowUpdated', item: T, response: any): void;
|
|
40
|
+
(event: 'editError', item: T, error: any): void;
|
|
41
|
+
(event: 'rowDeleted', item: T, response: any): void;
|
|
42
|
+
(event: 'deleteError', item: T, error: any): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseResourceEditorOptions {
|
|
46
|
+
/** Invalidate whichever data source is active, after a successful CRUD op. */
|
|
47
|
+
refresh: () => void;
|
|
48
|
+
/** Singular item noun (e.g. "product") — for toasts and the modal title. */
|
|
49
|
+
singularItemName: Ref<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The resource editor for `DXTable` — the create/edit/delete concern lifted out
|
|
54
|
+
* of the god-component (#129). Owns the edit-modal state, form seeding and
|
|
55
|
+
* visibility rules, the `showUrl` full-record fetch, submission (create `POST` /
|
|
56
|
+
* edit `PUT` / delete), the success/error toasts, and the edit-modal slot-key
|
|
57
|
+
* computeds. It never touches the table's data source, sort, filter, per-page
|
|
58
|
+
* or pagination state — the only coupling back to the table is the `refresh`
|
|
59
|
+
* callback it calls after a successful op.
|
|
60
|
+
*
|
|
61
|
+
* DXTable keeps `handleRowClick` (which emits `rowClicked` regardless) and calls
|
|
62
|
+
* `openEdit` from it; `openEdit` is a no-op unless `editFields` are set.
|
|
63
|
+
*/
|
|
64
|
+
export function useResourceEditor<T = any>(
|
|
65
|
+
props: ResourceEditorProps<T>,
|
|
66
|
+
emit: ResourceEditorEmit<T>,
|
|
67
|
+
options: UseResourceEditorOptions,
|
|
68
|
+
) {
|
|
69
|
+
const { refresh, singularItemName } = options;
|
|
70
|
+
|
|
71
|
+
// Edit Modal State
|
|
72
|
+
const showEditModal = ref(false);
|
|
73
|
+
|
|
74
|
+
// Bumped on every modal open (edit or create) to key the DXForm subtree, so
|
|
75
|
+
// each record gets fresh DXField instances. NOT bumped by the showUrl fetch,
|
|
76
|
+
// which reseeds the SAME record — remounting there would discard edits the
|
|
77
|
+
// user has already made while the fetch was in flight.
|
|
78
|
+
const editFormInstanceKey = ref(0);
|
|
79
|
+
const selectedItem = ref<T | null>(null);
|
|
80
|
+
const editForm = ref<any>(null);
|
|
81
|
+
const activeTabIndex = ref(0);
|
|
82
|
+
const isCreateMode = ref(false);
|
|
83
|
+
|
|
84
|
+
// Which modal action is in flight, so the Save and Delete buttons show their
|
|
85
|
+
// own loading label independently. `editForm.processing` is shared by every
|
|
86
|
+
// request the form makes, so it can't tell Save from Delete on its own.
|
|
87
|
+
const pendingAction = ref<'save' | 'delete' | null>(null);
|
|
88
|
+
|
|
89
|
+
// True while the edit modal is fetching the full record via `showUrl`.
|
|
90
|
+
const editLoading = ref(false);
|
|
91
|
+
|
|
92
|
+
// Monotonic token so a slow fetch for a previously-opened row can't overwrite
|
|
93
|
+
// the form after the user has since opened a different row.
|
|
94
|
+
let editFetchToken = 0;
|
|
95
|
+
|
|
96
|
+
// Toast (may not be available in test environment)
|
|
97
|
+
let createToast: ((obj: any) => any) | undefined;
|
|
98
|
+
try {
|
|
99
|
+
const toast = useToast();
|
|
100
|
+
createToast = toast.create;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// BApp not available (test environment or missing setup)
|
|
103
|
+
createToast = undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The edit/create form rendering is delegated to DXForm, which
|
|
107
|
+
// owns field/tab visibility, dynamic labels/hints, conditional fields,
|
|
108
|
+
// and auto-switching to the first tab with a validation error.
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* A field's own starting value, used to seed a CREATE form and as the fallback
|
|
112
|
+
* when a row lacks the key (#122).
|
|
113
|
+
*
|
|
114
|
+
* `field.default ?? ''` can't express a NULL default: a select whose "none"
|
|
115
|
+
* option is `value: null` — which is what the column stores — seeds '' instead,
|
|
116
|
+
* matches no option, and renders blank. Presence, not nullishness, decides here
|
|
117
|
+
* too, so the create and edit paths agree about the same field.
|
|
118
|
+
*/
|
|
119
|
+
const defaultValueFor = (field: any): any =>
|
|
120
|
+
Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* Seeding an EDIT form from a row (#117).
|
|
124
|
+
*
|
|
125
|
+
* `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
|
|
126
|
+
* from "the row's value IS null", so an explicitly-null column gets overwritten
|
|
127
|
+
* by the field's default — a value the user never saw, on a field they may not
|
|
128
|
+
* even be able to see. Presence, not nullishness, decides.
|
|
129
|
+
*/
|
|
130
|
+
const seedValueFor = (field: any, row: T): any => {
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
|
|
132
|
+
return (row as any)[field.key];
|
|
133
|
+
}
|
|
134
|
+
return defaultValueFor(field);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* Whether a field is currently VISIBLE, by the same rule DXForm renders by
|
|
139
|
+
* (`when` + the legacy `show`), against the same model (context + live data).
|
|
140
|
+
* Kept in step with DXForm.isFieldVisible.
|
|
141
|
+
*/
|
|
142
|
+
const editPredicateModel = computed(() => ({
|
|
143
|
+
...((selectedItem.value as any) ?? {}),
|
|
144
|
+
...(editForm.value?.data ?? {}),
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const isEditFieldVisible = (field: any): boolean => {
|
|
148
|
+
const when = field.when;
|
|
149
|
+
const whenOk =
|
|
150
|
+
when === undefined
|
|
151
|
+
? true
|
|
152
|
+
: typeof when === 'function'
|
|
153
|
+
? when(editPredicateModel.value)
|
|
154
|
+
: when;
|
|
155
|
+
const showOk = field.show ? field.show() : true;
|
|
156
|
+
return whenOk && showOk;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Fields whose value actually belongs in the payload. A presentational field
|
|
160
|
+
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
161
|
+
const submittableEditFields = computed(() =>
|
|
162
|
+
(props.editFields ?? []).filter((field) => field.submit !== false),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
166
|
+
// so a `submit: false` control — or a `span` slot calling `update` — can write
|
|
167
|
+
// its key back into the form data after seeding. Strip them on the way out.
|
|
168
|
+
const nonSubmittedFieldKeys = computed(
|
|
169
|
+
() =>
|
|
170
|
+
new Set(
|
|
171
|
+
(props.editFields ?? [])
|
|
172
|
+
.filter(
|
|
173
|
+
(field) =>
|
|
174
|
+
// Presentational: lays the form out, holds no data (#110).
|
|
175
|
+
field.submit === false ||
|
|
176
|
+
// Hidden by `when` at submit time (#117). Submitting a field
|
|
177
|
+
// the user cannot see is a silent write — and with `default`
|
|
178
|
+
// set it writes a value they never chose. Omitting the key
|
|
179
|
+
// leaves the stored value alone.
|
|
180
|
+
!isEditFieldVisible(field),
|
|
181
|
+
)
|
|
182
|
+
.map((field) => field.key),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const stripNonSubmittedFields = (data: Record<string, any>): Record<string, any> => {
|
|
187
|
+
if (nonSubmittedFieldKeys.value.size === 0) return data;
|
|
188
|
+
return Object.fromEntries(
|
|
189
|
+
Object.entries(data).filter(([key]) => !nonSubmittedFieldKeys.value.has(key)),
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Modal title (supports function)
|
|
194
|
+
const computedModalTitle = computed(() => {
|
|
195
|
+
if (isCreateMode.value) {
|
|
196
|
+
return `New ${singularItemName.value}`;
|
|
197
|
+
}
|
|
198
|
+
if (!selectedItem.value) {
|
|
199
|
+
return `Edit ${singularItemName.value}`;
|
|
200
|
+
}
|
|
201
|
+
if (!props.editModalTitle) {
|
|
202
|
+
return `Edit ${singularItemName.value}`;
|
|
203
|
+
}
|
|
204
|
+
return typeof props.editModalTitle === 'function'
|
|
205
|
+
? props.editModalTitle(selectedItem.value)
|
|
206
|
+
: props.editModalTitle;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Open the edit modal for a row. No-op unless `editFields` are set — the row
|
|
210
|
+
// click still emits `rowClicked` (in DXTable) regardless.
|
|
211
|
+
const openEdit = (item: T) => {
|
|
212
|
+
if (!props.editFields || props.editFields.length === 0) return;
|
|
213
|
+
|
|
214
|
+
// Set selected item FIRST before any rendering
|
|
215
|
+
isCreateMode.value = false;
|
|
216
|
+
selectedItem.value = item;
|
|
217
|
+
|
|
218
|
+
// Reset to first tab
|
|
219
|
+
activeTabIndex.value = 0;
|
|
220
|
+
|
|
221
|
+
// Initialize form with item data. `useForm` is statically imported, so
|
|
222
|
+
// seeding is synchronous — no interleaving between successive row opens.
|
|
223
|
+
if (!editForm.value) {
|
|
224
|
+
const formData: Record<string, any> = {};
|
|
225
|
+
// Presentational fields (submit: false) lay the form out; they hold
|
|
226
|
+
// no data and must not be POSTed just because they were declared.
|
|
227
|
+
submittableEditFields.value.forEach(field => {
|
|
228
|
+
formData[field.key] = seedValueFor(field, item);
|
|
229
|
+
});
|
|
230
|
+
editForm.value = useForm(formData);
|
|
231
|
+
} else {
|
|
232
|
+
// Update existing form
|
|
233
|
+
submittableEditFields.value.forEach(field => {
|
|
234
|
+
editForm.value.data[field.key] = seedValueFor(field, item);
|
|
235
|
+
});
|
|
236
|
+
editForm.value.clearErrors();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Open modal
|
|
240
|
+
editFormInstanceKey.value++;
|
|
241
|
+
showEditModal.value = true;
|
|
242
|
+
|
|
243
|
+
// Optionally replace the row-seeded data with the full record.
|
|
244
|
+
if (props.showUrl) void fetchFullRecordForEdit(item);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Fetch the full record for the edit modal and re-seed the form from it. The
|
|
248
|
+
// form is already showing row data; this fills in fields the list row omitted.
|
|
249
|
+
const fetchFullRecordForEdit = async (item: T) => {
|
|
250
|
+
const itemId = (item as any).id;
|
|
251
|
+
if (itemId === undefined || itemId === null) return;
|
|
252
|
+
|
|
253
|
+
const token = ++editFetchToken;
|
|
254
|
+
editLoading.value = true;
|
|
255
|
+
try {
|
|
256
|
+
const url = props.showUrl!.replace(':id', String(itemId));
|
|
257
|
+
const response = await axios.get(url);
|
|
258
|
+
// Superseded by a newer row-open — discard.
|
|
259
|
+
if (token !== editFetchToken) return;
|
|
260
|
+
|
|
261
|
+
const record = response.data?.data ?? response.data;
|
|
262
|
+
if (record && editForm.value) {
|
|
263
|
+
submittableEditFields.value.forEach(field => {
|
|
264
|
+
if (Object.prototype.hasOwnProperty.call(record, field.key)) {
|
|
265
|
+
editForm.value.data[field.key] = record[field.key];
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
// Widen selectedItem so predicates / the delete guard see full data.
|
|
269
|
+
selectedItem.value = { ...(selectedItem.value as any), ...record };
|
|
270
|
+
}
|
|
271
|
+
} catch (error: any) {
|
|
272
|
+
if (token !== editFetchToken) return;
|
|
273
|
+
const message =
|
|
274
|
+
error?.response?.data?.message ??
|
|
275
|
+
error?.message ??
|
|
276
|
+
'Failed to load the full record.';
|
|
277
|
+
createToast?.({
|
|
278
|
+
title: 'Error',
|
|
279
|
+
body: message,
|
|
280
|
+
variant: 'danger',
|
|
281
|
+
modelValue: 5000,
|
|
282
|
+
});
|
|
283
|
+
} finally {
|
|
284
|
+
if (token === editFetchToken) editLoading.value = false;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Open the create modal.
|
|
289
|
+
const openCreate = () => {
|
|
290
|
+
if (!props.editFields || props.editFields.length === 0) return;
|
|
291
|
+
|
|
292
|
+
isCreateMode.value = true;
|
|
293
|
+
selectedItem.value = null;
|
|
294
|
+
activeTabIndex.value = 0;
|
|
295
|
+
|
|
296
|
+
// Supersede any in-flight row-open fetch so a slow showUrl fetch from an
|
|
297
|
+
// earlier edit can't seed over this create.
|
|
298
|
+
editFetchToken++;
|
|
299
|
+
editLoading.value = false;
|
|
300
|
+
|
|
301
|
+
if (!editForm.value) {
|
|
302
|
+
const formData: Record<string, any> = {};
|
|
303
|
+
submittableEditFields.value.forEach(field => {
|
|
304
|
+
formData[field.key] = defaultValueFor(field);
|
|
305
|
+
});
|
|
306
|
+
editForm.value = useForm(formData);
|
|
307
|
+
} else {
|
|
308
|
+
// Reset existing form to defaults
|
|
309
|
+
submittableEditFields.value.forEach(field => {
|
|
310
|
+
editForm.value.data[field.key] = defaultValueFor(field);
|
|
311
|
+
});
|
|
312
|
+
editForm.value.clearErrors();
|
|
313
|
+
}
|
|
314
|
+
editFormInstanceKey.value++;
|
|
315
|
+
showEditModal.value = true;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Save from the edit modal
|
|
319
|
+
const save = async () => {
|
|
320
|
+
if (!editForm.value) return;
|
|
321
|
+
|
|
322
|
+
pendingAction.value = 'save';
|
|
323
|
+
try {
|
|
324
|
+
await performSave();
|
|
325
|
+
} finally {
|
|
326
|
+
pendingAction.value = null;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const performSave = async () => {
|
|
331
|
+
// Create mode: POST to createUrl
|
|
332
|
+
if (isCreateMode.value && props.createUrl) {
|
|
333
|
+
try {
|
|
334
|
+
await editForm.value.post(props.createUrl, {
|
|
335
|
+
transform: stripNonSubmittedFields,
|
|
336
|
+
onSuccess: (data: any) => {
|
|
337
|
+
createToast?.({
|
|
338
|
+
title: 'Success',
|
|
339
|
+
body: `${singularItemName.value} created successfully`,
|
|
340
|
+
variant: 'success',
|
|
341
|
+
modelValue: 3000,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
emit('rowCreated', data?.data ?? data, data);
|
|
345
|
+
showEditModal.value = false;
|
|
346
|
+
selectedItem.value = null;
|
|
347
|
+
isCreateMode.value = false;
|
|
348
|
+
|
|
349
|
+
refresh();
|
|
350
|
+
},
|
|
351
|
+
onError: (errors: any) => {
|
|
352
|
+
let errorMessage = 'Failed to create. Please check the form for errors.';
|
|
353
|
+
if (errors && typeof errors === 'object') {
|
|
354
|
+
const firstError = Object.values(errors).flat()[0];
|
|
355
|
+
if (typeof firstError === 'string') {
|
|
356
|
+
errorMessage = firstError;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
createToast?.({
|
|
361
|
+
title: 'Error',
|
|
362
|
+
body: errorMessage,
|
|
363
|
+
variant: 'danger',
|
|
364
|
+
modelValue: 5000,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// DXForm switches to the first errored tab via its
|
|
368
|
+
// own watcher on editForm.errors.
|
|
369
|
+
emit('createError', errors);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
} catch (error) {
|
|
373
|
+
emit('createError', error);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Edit mode: PUT to editUrl
|
|
379
|
+
if (!selectedItem.value) return;
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
// If editUrl provided, handle API call internally
|
|
383
|
+
if (props.editUrl) {
|
|
384
|
+
const itemId = (selectedItem.value as any).id;
|
|
385
|
+
const url = props.editUrl.replace(':id', itemId);
|
|
386
|
+
|
|
387
|
+
await editForm.value.put(url, {
|
|
388
|
+
transform: stripNonSubmittedFields,
|
|
389
|
+
onSuccess: (data: any) => {
|
|
390
|
+
// Show success toast
|
|
391
|
+
createToast?.({
|
|
392
|
+
title: 'Success',
|
|
393
|
+
body: `${singularItemName.value} updated successfully`,
|
|
394
|
+
variant: 'success',
|
|
395
|
+
modelValue: 3000, // Auto-dismiss after 3 seconds
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
emit('rowUpdated', selectedItem.value as T, data);
|
|
399
|
+
showEditModal.value = false;
|
|
400
|
+
selectedItem.value = null;
|
|
401
|
+
|
|
402
|
+
// Refresh table data to show updated values
|
|
403
|
+
refresh();
|
|
404
|
+
},
|
|
405
|
+
onError: (errors: any) => {
|
|
406
|
+
// Extract first error message for toast
|
|
407
|
+
let errorMessage = 'Failed to update. Please check the form for errors.';
|
|
408
|
+
if (errors && typeof errors === 'object') {
|
|
409
|
+
const firstError = Object.values(errors).flat()[0];
|
|
410
|
+
if (typeof firstError === 'string') {
|
|
411
|
+
errorMessage = firstError;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Show error toast with specific message
|
|
416
|
+
createToast?.({
|
|
417
|
+
title: 'Error',
|
|
418
|
+
body: errorMessage,
|
|
419
|
+
variant: 'danger',
|
|
420
|
+
modelValue: 5000, // Auto-dismiss after 5 seconds
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// DXForm switches to the first errored tab via its
|
|
424
|
+
// own watcher on editForm.errors.
|
|
425
|
+
emit('editError', selectedItem.value as T, errors);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
} else {
|
|
429
|
+
// No editUrl - just emit event for custom handling
|
|
430
|
+
emit('rowUpdated', selectedItem.value as T, editForm.value.data);
|
|
431
|
+
showEditModal.value = false;
|
|
432
|
+
selectedItem.value = null;
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
emit('editError', selectedItem.value as T, error);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Close the edit modal
|
|
440
|
+
const cancel = () => {
|
|
441
|
+
showEditModal.value = false;
|
|
442
|
+
selectedItem.value = null;
|
|
443
|
+
isCreateMode.value = false;
|
|
444
|
+
activeTabIndex.value = 0; // Reset tab for next time
|
|
445
|
+
// Abandon any in-flight showUrl fetch so it can't seed a closed modal.
|
|
446
|
+
editFetchToken++;
|
|
447
|
+
editLoading.value = false;
|
|
448
|
+
if (editForm.value) {
|
|
449
|
+
editForm.value.clearErrors();
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Delete from the edit modal
|
|
454
|
+
const remove = async () => {
|
|
455
|
+
if (!editForm.value || !selectedItem.value || !props.deleteUrl) return;
|
|
456
|
+
// Don't evaluate the guard until the full record has loaded (showUrl) — it
|
|
457
|
+
// may depend on fields the thin list row doesn't carry.
|
|
458
|
+
if (editLoading.value) return;
|
|
459
|
+
|
|
460
|
+
// Delete guard: a non-null message means this item can't be deleted — show
|
|
461
|
+
// it immediately and skip the confirm and the request entirely.
|
|
462
|
+
const guardMessage = props.deleteGuard?.(selectedItem.value as T);
|
|
463
|
+
if (guardMessage) {
|
|
464
|
+
createToast?.({
|
|
465
|
+
title: 'Cannot delete',
|
|
466
|
+
body: guardMessage,
|
|
467
|
+
variant: 'danger',
|
|
468
|
+
modelValue: 5000,
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Confirm deletion
|
|
474
|
+
const itemName = (selectedItem.value as any).name || (selectedItem.value as any).title || singularItemName.value;
|
|
475
|
+
const confirmed = window.confirm(`Are you sure you want to delete "${itemName}"? This action cannot be undone.`);
|
|
476
|
+
|
|
477
|
+
if (!confirmed) return;
|
|
478
|
+
|
|
479
|
+
pendingAction.value = 'delete';
|
|
480
|
+
try {
|
|
481
|
+
const itemId = (selectedItem.value as any).id;
|
|
482
|
+
const url = props.deleteUrl.replace(':id', itemId);
|
|
483
|
+
|
|
484
|
+
await editForm.value.delete(url, {
|
|
485
|
+
onSuccess: (data: any) => {
|
|
486
|
+
// Show success toast
|
|
487
|
+
createToast?.({
|
|
488
|
+
title: 'Success',
|
|
489
|
+
body: `${singularItemName.value} deleted successfully`,
|
|
490
|
+
variant: 'success',
|
|
491
|
+
modelValue: 3000, // Auto-dismiss after 3 seconds
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
emit('rowDeleted', selectedItem.value as T, data);
|
|
495
|
+
showEditModal.value = false;
|
|
496
|
+
selectedItem.value = null;
|
|
497
|
+
|
|
498
|
+
// Refresh table data to remove deleted item
|
|
499
|
+
refresh();
|
|
500
|
+
},
|
|
501
|
+
onError: (error: any) => {
|
|
502
|
+
// Extract error message from server response
|
|
503
|
+
const errorData = error?.response?.data ?? error?.data ?? error;
|
|
504
|
+
const errorMessage = errorData?.message ?? 'Failed to delete. Please try again.';
|
|
505
|
+
|
|
506
|
+
// Show error toast with server message
|
|
507
|
+
createToast?.({
|
|
508
|
+
title: 'Error',
|
|
509
|
+
body: errorMessage,
|
|
510
|
+
variant: 'danger',
|
|
511
|
+
modelValue: 5000, // Auto-dismiss after 5 seconds
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
emit('deleteError', selectedItem.value as T, error);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
} catch (error) {
|
|
518
|
+
emit('deleteError', selectedItem.value as T, error);
|
|
519
|
+
} finally {
|
|
520
|
+
pendingAction.value = null;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
// State the modal template binds
|
|
526
|
+
showEditModal,
|
|
527
|
+
editForm,
|
|
528
|
+
editFormInstanceKey,
|
|
529
|
+
activeTabIndex,
|
|
530
|
+
selectedItem,
|
|
531
|
+
isCreateMode,
|
|
532
|
+
pendingAction,
|
|
533
|
+
editLoading,
|
|
534
|
+
computedModalTitle,
|
|
535
|
+
// Actions
|
|
536
|
+
openEdit,
|
|
537
|
+
openCreate,
|
|
538
|
+
save,
|
|
539
|
+
cancel,
|
|
540
|
+
remove,
|
|
541
|
+
};
|
|
542
|
+
}
|
package/resources/js/index.ts
CHANGED
|
@@ -11,9 +11,9 @@ export { default as DXField } from "./components/extended/DXField.vue";
|
|
|
11
11
|
export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
|
|
12
12
|
export { default as DXSwitch } from "./components/extended/DXSwitch.vue";
|
|
13
13
|
export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// Chart components moved to the `@omnitend/dashboard-for-laravel/charts` entry
|
|
15
|
+
// (#142) so chart.js / vue-chartjs stay genuinely optional peers — the main
|
|
16
|
+
// entry no longer references them. See resources/js/charts.ts.
|
|
17
17
|
export { default as DXRepeater } from "./components/extended/DXRepeater.vue";
|
|
18
18
|
/**
|
|
19
19
|
* @deprecated Use `DXForm`. `DXBasicForm` is a thin wrapper around `DXForm`
|
|
@@ -53,6 +53,7 @@ export { default as DDropdownDivider } from "./components/base/DDropdownDivider.
|
|
|
53
53
|
export { default as DDropdownItem } from "./components/base/DDropdownItem.vue";
|
|
54
54
|
export { default as DForm } from "./components/base/DForm.vue";
|
|
55
55
|
export { default as DFormCheckbox } from "./components/base/DFormCheckbox.vue";
|
|
56
|
+
export { default as DFormCheckboxGroup } from "./components/base/DFormCheckboxGroup.vue";
|
|
56
57
|
export { default as DFormGroup } from "./components/base/DFormGroup.vue";
|
|
57
58
|
export { default as DFormInput } from "./components/base/DFormInput.vue";
|
|
58
59
|
export { default as DFormInvalidFeedback } from "./components/base/DFormInvalidFeedback.vue";
|