@omnitend/dashboard-for-laravel 0.4.14 → 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.
@@ -1,189 +1,40 @@
1
- <template>
2
- <BForm @submit.prevent="handleSubmit">
3
- <!-- Form-level error message -->
4
- <DAlert
5
- v-if="form.shouldShowMessage"
6
- :model-value="form.shouldShowMessage"
7
- variant="danger"
8
- class="mb-3"
9
- >
10
- {{ form.message }}
11
- </DAlert>
12
-
13
- <!-- Render each field -->
14
- <template v-for="field in visibleFields" :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 { computed } from "vue";
115
- import { BForm, BFormRadioGroup } from "bootstrap-vue-next";
116
- import DAlert from "../base/DAlert.vue";
117
- import DFormGroup from "../base/DFormGroup.vue";
118
- import DFormInput from "../base/DFormInput.vue";
119
- import DFormTextarea from "../base/DFormTextarea.vue";
120
- import DFormSelect from "../base/DFormSelect.vue";
121
- import DFormCheckbox from "../base/DFormCheckbox.vue";
122
- import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
123
- import DFormText from "../base/DFormText.vue";
124
- import DButton from "../base/DButton.vue";
125
- import type { UseFormReturn } from "../../composables/useForm";
126
- import type { FieldDefinition, FieldType } from "../../types";
127
-
128
- interface Props {
129
- /** Form instance from useForm composable */
130
- form: UseFormReturn<any>;
131
-
132
- /** Field definitions */
133
- fields: FieldDefinition[];
134
-
135
- /** Submit button text */
136
- submitText?: string;
137
-
138
- /** Submit button loading text */
139
- submitLoadingText?: string;
140
-
141
- /** Show submit button */
142
- showSubmit?: boolean;
143
- }
144
-
145
- const props = withDefaults(defineProps<Props>(), {
146
- submitText: "Submit",
147
- submitLoadingText: "Submitting...",
148
- showSubmit: true,
149
- });
150
-
151
- /**
152
- * Fields whose `show()` predicate evaluates to true (or omits the
153
- * predicate entirely). Computed so the form re-renders when reactive
154
- * sources used inside `show` change.
155
- */
156
- const visibleFields = computed(() => {
157
- return props.fields.filter((field) =>
158
- field.show ? field.show() : 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).",
159
26
  );
160
27
  });
161
-
162
- const emit = defineEmits<{
163
- submit: [];
164
- }>();
165
-
166
- /**
167
- * Check if field type is a text-based input
168
- */
169
- const isTextInput = (type: FieldType): boolean => {
170
- return [
171
- "text",
172
- "email",
173
- "password",
174
- "number",
175
- "url",
176
- "tel",
177
- "date",
178
- "datetime-local",
179
- "time",
180
- ].includes(type);
181
- };
182
-
183
- /**
184
- * Handle form submission
185
- */
186
- const handleSubmit = () => {
187
- emit("submit");
188
- };
189
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>