@omnitend/dashboard-for-laravel 0.4.14 → 0.6.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/base/DButton.vue.d.ts +2 -3
- package/dist/components/base/DTable.vue.d.ts +11 -3
- package/dist/components/extended/DXBasicForm.vue.d.ts +4 -33
- package/dist/components/extended/DXField.vue.d.ts +88 -0
- package/dist/components/extended/DXForm.vue.d.ts +34 -8
- package/dist/components/extended/DXRepeater.vue.d.ts +30 -0
- package/dist/components/extended/DXTable.vue.d.ts +10 -17
- package/dist/dashboard-for-laravel.js +31131 -16052
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +10 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +12 -4
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +114 -9
- package/dist/utils/objectPath.d.ts +18 -0
- package/docs/public/api-reference.json +354 -130
- package/docs/public/docs-map.md +5 -4
- package/docs/public/llms.txt +8 -7
- package/package.json +3 -3
- package/resources/js/components/base/DButton.vue +2 -3
- package/resources/js/components/base/{DCarousel.vue → DFormRadioGroup.vue} +5 -5
- package/resources/js/components/base/DTable.vue +39 -3
- package/resources/js/components/base/DToaster.vue +5 -3
- package/resources/js/components/extended/DXBasicForm.vue +35 -184
- package/resources/js/components/extended/DXField.vue +402 -0
- package/resources/js/components/extended/DXForm.vue +282 -17
- package/resources/js/components/extended/DXRepeater.vue +216 -0
- package/resources/js/components/extended/DXTable.vue +96 -210
- package/resources/js/composables/defineForm.ts +7 -0
- package/resources/js/index.ts +18 -3
- package/resources/js/types/index.ts +146 -9
- package/resources/js/utils/objectPath.ts +59 -0
- package/dist/components/base/DCarouselSlide.vue.d.ts +0 -12
- package/resources/js/components/base/DCarouselSlide.vue +0 -14
- /package/dist/components/base/{DCarousel.vue.d.ts → DFormRadioGroup.vue.d.ts} +0 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Full-width span field: delegate entirely to the #span slot -->
|
|
3
|
+
<div v-if="field.span" :class="field.class || 'mb-3'">
|
|
4
|
+
<slot
|
|
5
|
+
name="span"
|
|
6
|
+
:field="field"
|
|
7
|
+
:model="model"
|
|
8
|
+
:value="fieldValue"
|
|
9
|
+
:update="setValue"
|
|
10
|
+
/>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<!-- Checkbox: no label wrapper, label sits beside the control -->
|
|
14
|
+
<div v-else-if="field.type === 'checkbox'" :class="field.class || 'mb-3'">
|
|
15
|
+
<slot
|
|
16
|
+
v-if="$slots.value"
|
|
17
|
+
name="value"
|
|
18
|
+
:field="field"
|
|
19
|
+
:model="model"
|
|
20
|
+
:value="fieldValue"
|
|
21
|
+
:update="setValue"
|
|
22
|
+
/>
|
|
23
|
+
<DFormCheckbox
|
|
24
|
+
v-else
|
|
25
|
+
v-model="fieldValue"
|
|
26
|
+
:disabled="isDisabled || isReadonly"
|
|
27
|
+
v-bind="field.inputProps"
|
|
28
|
+
>
|
|
29
|
+
{{ resolvedLabel }}
|
|
30
|
+
</DFormCheckbox>
|
|
31
|
+
|
|
32
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
33
|
+
{{ form.getError(errorKey) }}
|
|
34
|
+
</DFormInvalidFeedback>
|
|
35
|
+
<slot name="info" :field="field" :model="model" />
|
|
36
|
+
<DFormText v-if="resolvedHint" class="text-muted">
|
|
37
|
+
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
38
|
+
</DFormText>
|
|
39
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Repeater: nested, repeatable sub-form -->
|
|
43
|
+
<div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
|
|
44
|
+
<DFormGroup :label="resolvedLabel">
|
|
45
|
+
<DXRepeater
|
|
46
|
+
:form="form"
|
|
47
|
+
:field="field"
|
|
48
|
+
:key-path="keyPath"
|
|
49
|
+
:model="model"
|
|
50
|
+
>
|
|
51
|
+
<!-- Forward repeater row slot for custom row layouts -->
|
|
52
|
+
<template v-if="$slots['repeater-row']" #row="rowProps">
|
|
53
|
+
<slot name="repeater-row" v-bind="rowProps" />
|
|
54
|
+
</template>
|
|
55
|
+
</DXRepeater>
|
|
56
|
+
</DFormGroup>
|
|
57
|
+
<slot name="info" :field="field" :model="model" />
|
|
58
|
+
<DFormText v-if="resolvedHint" class="text-muted">
|
|
59
|
+
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
60
|
+
</DFormText>
|
|
61
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Standard labelled field -->
|
|
65
|
+
<DFormGroup v-else :label="resolvedLabel" :class="field.class || 'mb-3'">
|
|
66
|
+
<!-- Custom value slot overrides the built-in control -->
|
|
67
|
+
<slot
|
|
68
|
+
v-if="$slots.value"
|
|
69
|
+
name="value"
|
|
70
|
+
:field="field"
|
|
71
|
+
:model="model"
|
|
72
|
+
:value="fieldValue"
|
|
73
|
+
:update="setValue"
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
<!-- Component escape hatch -->
|
|
77
|
+
<component
|
|
78
|
+
:is="field.component"
|
|
79
|
+
v-else-if="field.type === 'component' && field.component"
|
|
80
|
+
v-model="fieldValue"
|
|
81
|
+
:field="field"
|
|
82
|
+
:model="model"
|
|
83
|
+
:disabled="isDisabled || isReadonly"
|
|
84
|
+
v-bind="field.inputProps"
|
|
85
|
+
/>
|
|
86
|
+
|
|
87
|
+
<!-- Textarea. Error clearing happens in the v-model setter. -->
|
|
88
|
+
<DFormTextarea
|
|
89
|
+
v-else-if="field.type === 'textarea'"
|
|
90
|
+
v-model="fieldValue"
|
|
91
|
+
:required="field.required"
|
|
92
|
+
:placeholder="field.placeholder"
|
|
93
|
+
:rows="field.rows || 3"
|
|
94
|
+
:state="fieldState"
|
|
95
|
+
:disabled="isDisabled"
|
|
96
|
+
:readonly="isReadonly"
|
|
97
|
+
v-bind="field.inputProps"
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<!-- Select (sync or async options) -->
|
|
101
|
+
<DFormSelect
|
|
102
|
+
v-else-if="field.type === 'select'"
|
|
103
|
+
v-model="fieldValue"
|
|
104
|
+
:required="field.required"
|
|
105
|
+
:options="resolvedOptions"
|
|
106
|
+
:state="fieldState"
|
|
107
|
+
:disabled="isDisabled || isReadonly"
|
|
108
|
+
v-bind="field.inputProps"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<!-- Radio group -->
|
|
112
|
+
<DFormRadioGroup
|
|
113
|
+
v-else-if="field.type === 'radio'"
|
|
114
|
+
v-model="fieldValue"
|
|
115
|
+
:options="resolvedOptions"
|
|
116
|
+
:required="field.required"
|
|
117
|
+
:state="fieldState"
|
|
118
|
+
:disabled="isDisabled || isReadonly"
|
|
119
|
+
v-bind="field.inputProps"
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
<!-- File / image upload -->
|
|
123
|
+
<div v-else-if="field.type === 'image' || field.type === 'file'">
|
|
124
|
+
<DFormInput
|
|
125
|
+
type="file"
|
|
126
|
+
:accept="field.accept || (field.type === 'image' ? 'image/*' : undefined)"
|
|
127
|
+
:required="field.required"
|
|
128
|
+
:state="fieldState"
|
|
129
|
+
:disabled="isDisabled || isReadonly"
|
|
130
|
+
v-bind="field.inputProps"
|
|
131
|
+
@change="handleFileChange"
|
|
132
|
+
/>
|
|
133
|
+
<img
|
|
134
|
+
v-if="field.type === 'image' && imagePreview"
|
|
135
|
+
:src="imagePreview"
|
|
136
|
+
alt="Preview"
|
|
137
|
+
class="dx-field-image-preview mt-2"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- Currency / percentage: numeric input with an affix -->
|
|
142
|
+
<DInputGroup v-else-if="field.type === 'currency' || field.type === 'percentage'">
|
|
143
|
+
<template v-if="field.type === 'currency'" #prepend>
|
|
144
|
+
<span class="input-group-text">{{ field.currencySymbol || "£" }}</span>
|
|
145
|
+
</template>
|
|
146
|
+
<DFormInput
|
|
147
|
+
v-model="fieldValue"
|
|
148
|
+
type="number"
|
|
149
|
+
:required="field.required"
|
|
150
|
+
:placeholder="field.placeholder"
|
|
151
|
+
:step="field.step ?? (field.type === 'currency' ? '0.01' : undefined)"
|
|
152
|
+
:min="field.min"
|
|
153
|
+
:max="field.max"
|
|
154
|
+
:state="fieldState"
|
|
155
|
+
:disabled="isDisabled"
|
|
156
|
+
:readonly="isReadonly"
|
|
157
|
+
v-bind="field.inputProps"
|
|
158
|
+
/>
|
|
159
|
+
<template v-if="field.type === 'percentage'" #append>
|
|
160
|
+
<span class="input-group-text">%</span>
|
|
161
|
+
</template>
|
|
162
|
+
</DInputGroup>
|
|
163
|
+
|
|
164
|
+
<!-- Text-based inputs (text/email/password/number/url/tel/date/time/datetime) -->
|
|
165
|
+
<DFormInput
|
|
166
|
+
v-else
|
|
167
|
+
v-model="fieldValue"
|
|
168
|
+
:type="inputType"
|
|
169
|
+
:required="field.required"
|
|
170
|
+
:placeholder="field.placeholder"
|
|
171
|
+
:step="field.step"
|
|
172
|
+
:min="field.min"
|
|
173
|
+
:max="field.max"
|
|
174
|
+
:state="fieldState"
|
|
175
|
+
:disabled="isDisabled"
|
|
176
|
+
:readonly="isReadonly"
|
|
177
|
+
v-bind="field.inputProps"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
<!-- Validation error -->
|
|
181
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
182
|
+
{{ form.getError(errorKey) }}
|
|
183
|
+
</DFormInvalidFeedback>
|
|
184
|
+
|
|
185
|
+
<!-- Optional rich info block -->
|
|
186
|
+
<slot name="info" :field="field" :model="model" />
|
|
187
|
+
|
|
188
|
+
<!-- Hint (dynamic, slot-overridable) -->
|
|
189
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
190
|
+
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
191
|
+
</DFormText>
|
|
192
|
+
|
|
193
|
+
<!-- Static help text -->
|
|
194
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
195
|
+
</DFormGroup>
|
|
196
|
+
</template>
|
|
197
|
+
|
|
198
|
+
<script setup lang="ts">
|
|
199
|
+
import {
|
|
200
|
+
computed,
|
|
201
|
+
defineAsyncComponent,
|
|
202
|
+
onBeforeUnmount,
|
|
203
|
+
onMounted,
|
|
204
|
+
ref,
|
|
205
|
+
watch,
|
|
206
|
+
} from "vue";
|
|
207
|
+
import DFormGroup from "../base/DFormGroup.vue";
|
|
208
|
+
import DFormInput from "../base/DFormInput.vue";
|
|
209
|
+
import DFormTextarea from "../base/DFormTextarea.vue";
|
|
210
|
+
import DFormSelect from "../base/DFormSelect.vue";
|
|
211
|
+
import DFormRadioGroup from "../base/DFormRadioGroup.vue";
|
|
212
|
+
import DFormCheckbox from "../base/DFormCheckbox.vue";
|
|
213
|
+
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
214
|
+
import DFormText from "../base/DFormText.vue";
|
|
215
|
+
import DInputGroup from "../base/DInputGroup.vue";
|
|
216
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
217
|
+
import type { FieldDefinition, FieldOption, FieldType } from "../../types";
|
|
218
|
+
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
219
|
+
|
|
220
|
+
// Async to break the DXField <-> DXRepeater circular import.
|
|
221
|
+
const DXRepeater = defineAsyncComponent(() => import("./DXRepeater.vue"));
|
|
222
|
+
|
|
223
|
+
interface Props {
|
|
224
|
+
/** Field definition to render */
|
|
225
|
+
field: FieldDefinition;
|
|
226
|
+
|
|
227
|
+
/** Form instance owning the field's data and errors */
|
|
228
|
+
form: UseFormReturn<any>;
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Model passed to predicates (label/hint/when/disabled/readonly).
|
|
232
|
+
* Defaults to the live form data; a parent may widen it (e.g. a table
|
|
233
|
+
* merging the original row).
|
|
234
|
+
*/
|
|
235
|
+
model?: any;
|
|
236
|
+
|
|
237
|
+
/** Dot path into form.data for the value (defaults to field.key). */
|
|
238
|
+
keyPath?: string;
|
|
239
|
+
|
|
240
|
+
/** Error key for validation lookups (defaults to keyPath/field.key). */
|
|
241
|
+
errorKey?: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const props = defineProps<Props>();
|
|
245
|
+
|
|
246
|
+
const effectiveModel = computed(() => props.model ?? props.form.data);
|
|
247
|
+
|
|
248
|
+
// Path semantics (getByPath/setByPath) are only used when a parent passes an
|
|
249
|
+
// explicit keyPath (e.g. a repeater binding `lines.0.price`). For top-level
|
|
250
|
+
// fields the key is used literally, so a field whose key legitimately contains
|
|
251
|
+
// a dot (e.g. "user.email") still maps to form.data["user.email"].
|
|
252
|
+
const usePathSemantics = computed(() => props.keyPath !== undefined);
|
|
253
|
+
const valuePath = computed(() => props.keyPath ?? props.field.key);
|
|
254
|
+
const errorKey = computed(
|
|
255
|
+
() => props.errorKey ?? props.keyPath ?? props.field.key,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
/** Resolve a value-or-function against the current model. */
|
|
259
|
+
function resolveMaybe<TValue>(
|
|
260
|
+
value: TValue | ((model: any) => TValue) | undefined,
|
|
261
|
+
): TValue | undefined {
|
|
262
|
+
if (typeof value === "function") {
|
|
263
|
+
return (value as (model: any) => TValue)(effectiveModel.value);
|
|
264
|
+
}
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const fieldValue = computed({
|
|
269
|
+
get: () =>
|
|
270
|
+
usePathSemantics.value
|
|
271
|
+
? getByPath(props.form.data, valuePath.value)
|
|
272
|
+
: (props.form.data as Record<string, any>)[props.field.key],
|
|
273
|
+
set: (value: any) => setValue(value),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const NUMERIC_TYPES: ReadonlySet<FieldType> = new Set([
|
|
277
|
+
"number",
|
|
278
|
+
"currency",
|
|
279
|
+
"percentage",
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
function setValue(value: any): void {
|
|
283
|
+
let next = value;
|
|
284
|
+
// Native number inputs emit strings; keep numeric field types numeric so
|
|
285
|
+
// the API and any client-side maths see numbers, not "12.50".
|
|
286
|
+
if (
|
|
287
|
+
NUMERIC_TYPES.has(props.field.type) &&
|
|
288
|
+
typeof value === "string" &&
|
|
289
|
+
value.trim() !== ""
|
|
290
|
+
) {
|
|
291
|
+
const parsed = Number(value);
|
|
292
|
+
if (Number.isFinite(parsed)) next = parsed;
|
|
293
|
+
}
|
|
294
|
+
if (usePathSemantics.value) {
|
|
295
|
+
setByPath(props.form.data, valuePath.value, next);
|
|
296
|
+
} else {
|
|
297
|
+
(props.form.data as Record<string, any>)[props.field.key] = next;
|
|
298
|
+
}
|
|
299
|
+
props.form.clearError(errorKey.value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const fieldState = computed(() => props.form.getState(errorKey.value));
|
|
303
|
+
|
|
304
|
+
const resolvedLabel = computed(
|
|
305
|
+
() => resolveMaybe(props.field.label) ?? props.field.key,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const resolvedHint = computed(() => resolveMaybe(props.field.hint));
|
|
309
|
+
|
|
310
|
+
const isDisabled = computed(() => {
|
|
311
|
+
if (props.field.disabledWhen) {
|
|
312
|
+
return props.field.disabledWhen(effectiveModel.value);
|
|
313
|
+
}
|
|
314
|
+
return resolveMaybe(props.field.disabled) ?? false;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const isReadonly = computed(() => resolveMaybe(props.field.readonly) ?? false);
|
|
318
|
+
|
|
319
|
+
const inputType = computed<string>(() => {
|
|
320
|
+
const type: FieldType = props.field.type;
|
|
321
|
+
if (type === "datetime") return "datetime-local";
|
|
322
|
+
return type;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ————————————————— async options
|
|
326
|
+
|
|
327
|
+
const loadedOptions = ref<FieldOption[] | null>(null);
|
|
328
|
+
|
|
329
|
+
const resolvedOptions = computed<FieldOption[] | undefined>(
|
|
330
|
+
() => loadedOptions.value ?? props.field.options,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// Monotonic token so out-of-order async responses can't clobber newer ones.
|
|
334
|
+
let optionsRequestToken = 0;
|
|
335
|
+
|
|
336
|
+
async function loadOptions(): Promise<void> {
|
|
337
|
+
if (!props.field.optionsLoader) return;
|
|
338
|
+
const token = ++optionsRequestToken;
|
|
339
|
+
try {
|
|
340
|
+
const options = await props.field.optionsLoader(effectiveModel.value);
|
|
341
|
+
// Ignore a stale response superseded by a newer load.
|
|
342
|
+
if (token === optionsRequestToken) loadedOptions.value = options;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
// Swallow loader failures: keep the last successfully loaded options
|
|
345
|
+
// (or the static `field.options` fallback when none have loaded yet).
|
|
346
|
+
if (token === optionsRequestToken) {
|
|
347
|
+
// eslint-disable-next-line no-console
|
|
348
|
+
console.error(
|
|
349
|
+
`optionsLoader failed for field "${props.field.key}"`,
|
|
350
|
+
error,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
onMounted(() => {
|
|
357
|
+
void loadOptions();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
if (props.field.reloadOptionsOnChange) {
|
|
361
|
+
watch(effectiveModel, () => void loadOptions(), { deep: true });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ————————————————— image preview
|
|
365
|
+
|
|
366
|
+
const objectUrl = ref<string | null>(null);
|
|
367
|
+
|
|
368
|
+
const imagePreview = computed<string | null>(() => {
|
|
369
|
+
if (objectUrl.value) return objectUrl.value;
|
|
370
|
+
const value = fieldValue.value;
|
|
371
|
+
// Existing value may be a URL string from the server.
|
|
372
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
function handleFileChange(event: Event): void {
|
|
376
|
+
const input = event.target as HTMLInputElement;
|
|
377
|
+
const file = input.files?.[0] ?? null;
|
|
378
|
+
|
|
379
|
+
if (objectUrl.value) {
|
|
380
|
+
URL.revokeObjectURL(objectUrl.value);
|
|
381
|
+
objectUrl.value = null;
|
|
382
|
+
}
|
|
383
|
+
if (file && props.field.type === "image" && typeof URL !== "undefined") {
|
|
384
|
+
objectUrl.value = URL.createObjectURL(file);
|
|
385
|
+
}
|
|
386
|
+
setValue(file);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
onBeforeUnmount(() => {
|
|
390
|
+
if (objectUrl.value) URL.revokeObjectURL(objectUrl.value);
|
|
391
|
+
});
|
|
392
|
+
</script>
|
|
393
|
+
|
|
394
|
+
<style scoped>
|
|
395
|
+
.dx-field-image-preview {
|
|
396
|
+
max-width: 160px;
|
|
397
|
+
max-height: 160px;
|
|
398
|
+
object-fit: cover;
|
|
399
|
+
border: 1px solid var(--bs-border-color);
|
|
400
|
+
border-radius: var(--bs-border-radius);
|
|
401
|
+
}
|
|
402
|
+
</style>
|