@omnitend/dashboard-for-laravel 0.4.13 → 0.5.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/DFormRadioGroup.vue.d.ts +12 -0
- 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 +16 -19
- package/dist/dashboard-for-laravel.js +8023 -7517
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +6 -6
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +117 -6
- package/dist/utils/objectPath.d.ts +18 -0
- package/docs/public/api-reference.json +345 -85
- package/docs/public/docs-map.md +5 -4
- package/docs/public/llms.txt +8 -5
- package/package.json +1 -1
- package/resources/js/components/base/DFormRadioGroup.vue +21 -0
- package/resources/js/components/extended/DXBasicForm.vue +36 -173
- 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 +202 -204
- package/resources/js/composables/defineForm.ts +7 -0
- package/resources/js/index.ts +12 -1
- package/resources/js/types/index.ts +150 -6
- package/resources/js/utils/objectPath.ts +59 -0
|
@@ -1,177 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<!-- Render each field -->
|
|
14
|
-
<template v-for="field in fields" :key="field.key">
|
|
15
|
-
<!-- Custom slot for this field -->
|
|
16
|
-
<slot :name="`field-${field.key}`" :field="field" :form="form">
|
|
17
|
-
<!-- Default field rendering -->
|
|
18
|
-
<DFormGroup
|
|
19
|
-
:label="field.label"
|
|
20
|
-
:label-for="field.key"
|
|
21
|
-
:class="field.class || 'mb-3'"
|
|
22
|
-
>
|
|
23
|
-
<!-- Text-based inputs -->
|
|
24
|
-
<DFormInput
|
|
25
|
-
v-if="isTextInput(field.type)"
|
|
26
|
-
:id="field.key"
|
|
27
|
-
v-model="form.data[field.key]"
|
|
28
|
-
:type="field.type"
|
|
29
|
-
:required="field.required"
|
|
30
|
-
:placeholder="field.placeholder"
|
|
31
|
-
:state="form.getState(field.key)"
|
|
32
|
-
v-bind="field.inputProps"
|
|
33
|
-
@input="form.clearError(field.key)"
|
|
34
|
-
/>
|
|
35
|
-
|
|
36
|
-
<!-- Textarea -->
|
|
37
|
-
<DFormTextarea
|
|
38
|
-
v-else-if="field.type === 'textarea'"
|
|
39
|
-
:id="field.key"
|
|
40
|
-
v-model="form.data[field.key]"
|
|
41
|
-
:required="field.required"
|
|
42
|
-
:placeholder="field.placeholder"
|
|
43
|
-
:rows="field.rows || 3"
|
|
44
|
-
:state="form.getState(field.key)"
|
|
45
|
-
v-bind="field.inputProps"
|
|
46
|
-
@input="form.clearError(field.key)"
|
|
47
|
-
/>
|
|
48
|
-
|
|
49
|
-
<!-- Select -->
|
|
50
|
-
<DFormSelect
|
|
51
|
-
v-else-if="field.type === 'select'"
|
|
52
|
-
:id="field.key"
|
|
53
|
-
v-model="form.data[field.key]"
|
|
54
|
-
:required="field.required"
|
|
55
|
-
:options="field.options"
|
|
56
|
-
:state="form.getState(field.key)"
|
|
57
|
-
v-bind="field.inputProps"
|
|
58
|
-
@change="form.clearError(field.key)"
|
|
59
|
-
/>
|
|
60
|
-
|
|
61
|
-
<!-- Checkbox -->
|
|
62
|
-
<DFormCheckbox
|
|
63
|
-
v-else-if="field.type === 'checkbox'"
|
|
64
|
-
:id="field.key"
|
|
65
|
-
v-model="form.data[field.key]"
|
|
66
|
-
v-bind="field.inputProps"
|
|
67
|
-
>
|
|
68
|
-
{{ field.label }}
|
|
69
|
-
</DFormCheckbox>
|
|
70
|
-
|
|
71
|
-
<!-- Radio group -->
|
|
72
|
-
<BFormRadioGroup
|
|
73
|
-
v-else-if="field.type === 'radio'"
|
|
74
|
-
:id="field.key"
|
|
75
|
-
v-model="form.data[field.key]"
|
|
76
|
-
:options="field.options"
|
|
77
|
-
:required="field.required"
|
|
78
|
-
:state="form.getState(field.key)"
|
|
79
|
-
v-bind="field.inputProps"
|
|
80
|
-
@change="form.clearError(field.key)"
|
|
81
|
-
/>
|
|
82
|
-
|
|
83
|
-
<!-- Validation error -->
|
|
84
|
-
<DFormInvalidFeedback v-if="form.hasError(field.key)">
|
|
85
|
-
{{ form.getError(field.key) }}
|
|
86
|
-
</DFormInvalidFeedback>
|
|
87
|
-
|
|
88
|
-
<!-- Help text -->
|
|
89
|
-
<DFormText v-if="field.help">
|
|
90
|
-
{{ field.help }}
|
|
91
|
-
</DFormText>
|
|
92
|
-
</DFormGroup>
|
|
93
|
-
</slot>
|
|
94
|
-
</template>
|
|
95
|
-
|
|
96
|
-
<!-- Submit button -->
|
|
97
|
-
<DButton
|
|
98
|
-
v-if="showSubmit"
|
|
99
|
-
type="submit"
|
|
100
|
-
variant="primary"
|
|
101
|
-
:disabled="form.processing"
|
|
102
|
-
class="w-100"
|
|
103
|
-
>
|
|
104
|
-
<span v-if="form.processing">{{ submitLoadingText }}</span>
|
|
105
|
-
<span v-else>{{ submitText }}</span>
|
|
106
|
-
</DButton>
|
|
107
|
-
|
|
108
|
-
<!-- Footer slot -->
|
|
109
|
-
<slot name="footer"></slot>
|
|
110
|
-
</BForm>
|
|
111
|
-
</template>
|
|
1
|
+
<!--
|
|
2
|
+
DXBasicForm — deprecated alias of DXForm.
|
|
3
|
+
|
|
4
|
+
A flat form is just DXForm without a `tabs` prop. This wrapper forwards
|
|
5
|
+
everything to DXForm and logs a one-time deprecation warning so existing
|
|
6
|
+
callers keep working while they migrate. Remove in a future major.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
// Module scope: warn at most once per session, not once per instance.
|
|
10
|
+
let hasWarned = false;
|
|
11
|
+
</script>
|
|
112
12
|
|
|
113
13
|
<script setup lang="ts">
|
|
114
|
-
import {
|
|
115
|
-
import
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
interface Props {
|
|
128
|
-
/** Form instance from useForm composable */
|
|
129
|
-
form: UseFormReturn<any>;
|
|
130
|
-
|
|
131
|
-
/** Field definitions */
|
|
132
|
-
fields: FieldDefinition[];
|
|
133
|
-
|
|
134
|
-
/** Submit button text */
|
|
135
|
-
submitText?: string;
|
|
136
|
-
|
|
137
|
-
/** Submit button loading text */
|
|
138
|
-
submitLoadingText?: string;
|
|
139
|
-
|
|
140
|
-
/** Show submit button */
|
|
141
|
-
showSubmit?: boolean;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
withDefaults(defineProps<Props>(), {
|
|
145
|
-
submitText: "Submit",
|
|
146
|
-
submitLoadingText: "Submitting...",
|
|
147
|
-
showSubmit: true,
|
|
14
|
+
import { onMounted } from "vue";
|
|
15
|
+
import DXForm from "./DXForm.vue";
|
|
16
|
+
|
|
17
|
+
defineOptions({ inheritAttrs: false });
|
|
18
|
+
|
|
19
|
+
onMounted(() => {
|
|
20
|
+
if (hasWarned) return;
|
|
21
|
+
hasWarned = true;
|
|
22
|
+
console.warn(
|
|
23
|
+
"[dashboard-for-laravel] DXBasicForm is deprecated and will be " +
|
|
24
|
+
"removed in a future major version. Use DXForm instead " +
|
|
25
|
+
"(a flat form is DXForm without a `tabs` prop).",
|
|
26
|
+
);
|
|
148
27
|
});
|
|
149
|
-
|
|
150
|
-
const emit = defineEmits<{
|
|
151
|
-
submit: [];
|
|
152
|
-
}>();
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Check if field type is a text-based input
|
|
156
|
-
*/
|
|
157
|
-
const isTextInput = (type: FieldType): boolean => {
|
|
158
|
-
return [
|
|
159
|
-
"text",
|
|
160
|
-
"email",
|
|
161
|
-
"password",
|
|
162
|
-
"number",
|
|
163
|
-
"url",
|
|
164
|
-
"tel",
|
|
165
|
-
"date",
|
|
166
|
-
"datetime-local",
|
|
167
|
-
"time",
|
|
168
|
-
].includes(type);
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Handle form submission
|
|
173
|
-
*/
|
|
174
|
-
const handleSubmit = () => {
|
|
175
|
-
emit("submit");
|
|
176
|
-
};
|
|
177
28
|
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<!-- `form`/`fields`/etc. arrive via $attrs (inheritAttrs: false) and are
|
|
32
|
+
forwarded to DXForm. Cast because the type-checker can't see the
|
|
33
|
+
required `form` prop inside the untyped attrs spread. -->
|
|
34
|
+
<DXForm v-bind="($attrs as any)">
|
|
35
|
+
<!-- Forward all slots (#value(<key>), #footer, etc.) to DXForm. -->
|
|
36
|
+
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
|
|
37
|
+
<slot :name="name" v-bind="slotProps" />
|
|
38
|
+
</template>
|
|
39
|
+
</DXForm>
|
|
40
|
+
</template>
|
|
@@ -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>
|