@omnitend/dashboard-for-laravel 0.33.0 → 0.34.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.js +82 -90
- package/dist/charts.js.map +1 -1
- package/dist/charts.umd.cjs +1 -1
- package/dist/charts.umd.cjs.map +1 -1
- package/dist/components/base/DButton.vue.d.ts +1 -1
- package/dist/components/charts/DXBarChart.vue.d.ts +3 -18
- package/dist/components/charts/DXLineChart.vue.d.ts +3 -18
- package/dist/components/charts/useThemedChart.d.ts +35 -0
- package/dist/components/extended/DXChoiceField.vue.d.ts +26 -0
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +1 -1
- package/dist/components/extended/DXField.vue.d.ts +10 -113
- package/dist/components/extended/DXFieldShell.vue.d.ts +73 -0
- package/dist/components/extended/DXNumericField.vue.d.ts +23 -0
- package/dist/components/extended/DXRepeater.vue.d.ts +4 -4
- package/dist/components/extended/DXRepeaterCards.vue.d.ts +45 -0
- package/dist/components/extended/DXTable.vue.d.ts +91 -2
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +17 -2
- package/dist/components/extended/DXUserAvatar.vue.d.ts +1 -1
- package/dist/composables/useAsyncOptions.d.ts +20 -0
- package/dist/composables/useResourceEditor.d.ts +2 -1
- package/dist/dashboard-for-laravel.js +9987 -10054
- 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 +3 -2
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +146 -0
- package/dist/utils/formSchema.d.ts +59 -0
- package/docs/public/api-reference.json +464 -30
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +5 -2
- package/package.json +1 -1
- package/resources/js/components/charts/DXBarChart.vue +4 -43
- package/resources/js/components/charts/DXLineChart.vue +4 -43
- package/resources/js/components/charts/useThemedChart.ts +69 -0
- package/resources/js/components/extended/DXChoiceField.vue +114 -0
- package/resources/js/components/extended/DXField.vue +328 -615
- package/resources/js/components/extended/DXFieldShell.vue +124 -0
- package/resources/js/components/extended/DXForm.vue +9 -5
- package/resources/js/components/extended/DXNumericField.vue +144 -0
- package/resources/js/components/extended/DXRepeater.vue +53 -175
- package/resources/js/components/extended/DXRepeaterCards.vue +133 -0
- package/resources/js/components/extended/DXTable.vue +250 -37
- package/resources/js/components/extended/DXTableEditorModal.vue +34 -2
- package/resources/js/composables/defineForm.ts +8 -44
- package/resources/js/composables/useAsyncOptions.ts +61 -0
- package/resources/js/composables/useResourceEditor.ts +24 -38
- package/resources/js/index.ts +33 -0
- package/resources/js/types/index.ts +231 -0
- package/resources/js/utils/formSchema.ts +126 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component (internal — not exported)
|
|
3
|
+
The chrome every DXField branch shares: the optional label (with its info
|
|
4
|
+
affordance and, in horizontal layout, its hint), the control (default slot),
|
|
5
|
+
and the trailing validation feedback + info block + hint + static help. Before
|
|
6
|
+
#135 this markup was re-rendered separately in each of DXField's ~7 branches;
|
|
7
|
+
it now lives here once.
|
|
8
|
+
|
|
9
|
+
Two shapes:
|
|
10
|
+
- `mode="group"` — root is a `DFormGroup` that owns the `#label` column and
|
|
11
|
+
holds the control + trailing block inside it (standard / horizontal
|
|
12
|
+
checkbox / horizontal switch).
|
|
13
|
+
- `mode="plain"` — root is a plain `div`; the control renders its own inline
|
|
14
|
+
label (vertical checkbox/switch), or a heading/rows (switch-list), or a
|
|
15
|
+
nested group (repeater). The trailing block follows the control in the div.
|
|
16
|
+
|
|
17
|
+
The `hint` and `info` slots are forwarded down from DXField (content AND slot
|
|
18
|
+
props). `hint` falls back to `{{ resolvedHint }}` when no slot is supplied.
|
|
19
|
+
-->
|
|
20
|
+
<template>
|
|
21
|
+
<DFormGroup
|
|
22
|
+
v-if="mode === 'group'"
|
|
23
|
+
:class="wrapperClass"
|
|
24
|
+
v-bind="groupAttrs"
|
|
25
|
+
>
|
|
26
|
+
<template v-if="!hideLabel" #label>
|
|
27
|
+
<DXFieldLabel :label="label" :info="info" />
|
|
28
|
+
<small
|
|
29
|
+
v-if="hintInLabel && (resolvedHint || $slots.hint)"
|
|
30
|
+
class="form-text text-muted d-block dx-field-hint"
|
|
31
|
+
>
|
|
32
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
33
|
+
resolvedHint
|
|
34
|
+
}}</slot>
|
|
35
|
+
</small>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<slot />
|
|
39
|
+
|
|
40
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
41
|
+
{{ form.getError(errorKey) }}
|
|
42
|
+
</DFormInvalidFeedback>
|
|
43
|
+
<slot name="info" :field="field" :model="model" />
|
|
44
|
+
<DFormText
|
|
45
|
+
v-if="!hintInLabel && (resolvedHint || $slots.hint)"
|
|
46
|
+
class="text-muted"
|
|
47
|
+
>
|
|
48
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
49
|
+
resolvedHint
|
|
50
|
+
}}</slot>
|
|
51
|
+
</DFormText>
|
|
52
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
53
|
+
</DFormGroup>
|
|
54
|
+
|
|
55
|
+
<div v-else :class="wrapperClass">
|
|
56
|
+
<slot />
|
|
57
|
+
|
|
58
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
59
|
+
{{ form.getError(errorKey) }}
|
|
60
|
+
</DFormInvalidFeedback>
|
|
61
|
+
<slot name="info" :field="field" :model="model" />
|
|
62
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
63
|
+
<slot name="hint" :field="field" :model="model">{{
|
|
64
|
+
resolvedHint
|
|
65
|
+
}}</slot>
|
|
66
|
+
</DFormText>
|
|
67
|
+
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
import DFormGroup from "../base/DFormGroup.vue";
|
|
73
|
+
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
74
|
+
import DFormText from "../base/DFormText.vue";
|
|
75
|
+
import DXFieldLabel from "./DXFieldLabel.vue";
|
|
76
|
+
import type { UseFormReturn } from "../../composables/useForm";
|
|
77
|
+
import type { FieldDefinition } from "../../types";
|
|
78
|
+
|
|
79
|
+
interface Props {
|
|
80
|
+
/** The field definition (for `field.help` and slot props). */
|
|
81
|
+
field: FieldDefinition;
|
|
82
|
+
|
|
83
|
+
/** Form instance owning the field's errors. */
|
|
84
|
+
form: UseFormReturn<any>;
|
|
85
|
+
|
|
86
|
+
/** Error key for validation lookups. */
|
|
87
|
+
errorKey: string;
|
|
88
|
+
|
|
89
|
+
/** Model passed to `hint`/`info` slot props. */
|
|
90
|
+
model?: any;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Layout shape. `group` = DFormGroup root with a label column; `plain` =
|
|
94
|
+
* plain div, the control owning its own inline label/heading.
|
|
95
|
+
*/
|
|
96
|
+
mode: "group" | "plain";
|
|
97
|
+
|
|
98
|
+
/** Wrapper class (`field.class || 'mb-3'`). */
|
|
99
|
+
wrapperClass?: string;
|
|
100
|
+
|
|
101
|
+
/** BFormGroup attrs (horizontal label-cols etc.); group mode only. */
|
|
102
|
+
groupAttrs?: Record<string, any>;
|
|
103
|
+
|
|
104
|
+
/** Resolved label text (group mode). Always supplied by DXField. */
|
|
105
|
+
label: string;
|
|
106
|
+
|
|
107
|
+
/** Resolved info text for the label popover (group mode). */
|
|
108
|
+
info?: string;
|
|
109
|
+
|
|
110
|
+
/** Skip the label column entirely (group mode). */
|
|
111
|
+
hideLabel?: boolean;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether the hint sits in the label column (horizontal) rather than below
|
|
115
|
+
* the control. Group mode only; plain mode always renders the hint below.
|
|
116
|
+
*/
|
|
117
|
+
hintInLabel?: boolean;
|
|
118
|
+
|
|
119
|
+
/** Resolved hint text (fallback when no `hint` slot is supplied). */
|
|
120
|
+
resolvedHint?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
defineProps<Props>();
|
|
124
|
+
</script>
|
|
@@ -177,6 +177,10 @@ import DXFormField from "./DXFormField.vue";
|
|
|
177
177
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
178
178
|
import type { DefineFormReturn } from "../../composables/defineForm";
|
|
179
179
|
import type { FieldDefinition, FormTab, LabelCols, MaybeFn } from "../../types";
|
|
180
|
+
import {
|
|
181
|
+
resolvePredicate as resolvePredicateFor,
|
|
182
|
+
isFieldVisible as isFieldVisibleFor,
|
|
183
|
+
} from "../../utils/formSchema";
|
|
180
184
|
|
|
181
185
|
interface Props {
|
|
182
186
|
/**
|
|
@@ -303,12 +307,14 @@ const model = computed(() => ({
|
|
|
303
307
|
...resolvedForm.value.data,
|
|
304
308
|
}));
|
|
305
309
|
|
|
310
|
+
// Thin wrappers binding the shared formSchema predicates to this form's live
|
|
311
|
+
// model (context + form data), so field/tab visibility follows the one rule
|
|
312
|
+
// every renderer shares (#134).
|
|
306
313
|
function resolvePredicate(
|
|
307
314
|
when: MaybeFn<boolean> | undefined,
|
|
308
315
|
fallback: boolean,
|
|
309
316
|
): boolean {
|
|
310
|
-
|
|
311
|
-
return typeof when === "function" ? when(model.value) : when;
|
|
317
|
+
return resolvePredicateFor(when, model.value, fallback);
|
|
312
318
|
}
|
|
313
319
|
|
|
314
320
|
/** Resolve a tab's (possibly function-valued) label against the live model. */
|
|
@@ -319,9 +325,7 @@ function resolveTabLabel(tab: FormTab): string {
|
|
|
319
325
|
}
|
|
320
326
|
|
|
321
327
|
function isFieldVisible(field: FieldDefinition): boolean {
|
|
322
|
-
|
|
323
|
-
const showOk = field.show ? field.show() : true;
|
|
324
|
-
return whenOk && showOk;
|
|
328
|
+
return isFieldVisibleFor(field, model.value);
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
// ————————————————— tabs
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component (internal — not exported)
|
|
3
|
+
The numeric control family for DXField: `currency`, `percentage`, `number`.
|
|
4
|
+
Extracted from DXField (#135) unchanged. The parent DXField still owns the
|
|
5
|
+
value/guards/`setValue` coercion — this component only renders the control and
|
|
6
|
+
emits the raw edited value back up. The `percentage` `asFraction` display
|
|
7
|
+
scaling (model holds a 0–1 fraction, input shows 0–100) lives here because it
|
|
8
|
+
is purely presentational to this control.
|
|
9
|
+
-->
|
|
10
|
+
<template>
|
|
11
|
+
<!-- Currency: the DXCurrencyInput leaf (#152) owns the symbol prepend, the
|
|
12
|
+
blur-padded display (#69), empty -> null, and the minor-units model
|
|
13
|
+
scaling (#116). -->
|
|
14
|
+
<DXCurrencyInput
|
|
15
|
+
v-if="field.type === 'currency'"
|
|
16
|
+
:model-value="modelValue"
|
|
17
|
+
:currency-symbol="field.currencySymbol || '£'"
|
|
18
|
+
:decimals="field.decimals ?? 2"
|
|
19
|
+
:minor-units="field.minorUnits ?? false"
|
|
20
|
+
:step="field.step"
|
|
21
|
+
:required="field.required"
|
|
22
|
+
:placeholder="field.placeholder"
|
|
23
|
+
:min="field.min"
|
|
24
|
+
:max="field.max"
|
|
25
|
+
:state="state"
|
|
26
|
+
:disabled="isDisabled"
|
|
27
|
+
:readonly="isReadonly || isPlaintext"
|
|
28
|
+
:plaintext="isPlaintext"
|
|
29
|
+
v-bind="inputProps"
|
|
30
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<!-- Percentage: numeric input with an affix -->
|
|
34
|
+
<DInputGroup v-else-if="field.type === 'percentage'">
|
|
35
|
+
<DFormInput
|
|
36
|
+
v-model="numericInputValue"
|
|
37
|
+
type="number"
|
|
38
|
+
:required="field.required"
|
|
39
|
+
:placeholder="field.placeholder"
|
|
40
|
+
:step="field.step"
|
|
41
|
+
:min="field.min"
|
|
42
|
+
:max="field.max"
|
|
43
|
+
:state="state"
|
|
44
|
+
:disabled="isDisabled"
|
|
45
|
+
:readonly="isReadonly || isPlaintext"
|
|
46
|
+
:plaintext="isPlaintext"
|
|
47
|
+
v-bind="inputProps"
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<template #append>
|
|
51
|
+
<span class="input-group-text">%</span>
|
|
52
|
+
</template>
|
|
53
|
+
</DInputGroup>
|
|
54
|
+
|
|
55
|
+
<!-- Number -->
|
|
56
|
+
<DFormInput
|
|
57
|
+
v-else
|
|
58
|
+
:model-value="modelValue"
|
|
59
|
+
type="number"
|
|
60
|
+
:required="field.required"
|
|
61
|
+
:placeholder="field.placeholder"
|
|
62
|
+
:step="field.step"
|
|
63
|
+
:min="field.min"
|
|
64
|
+
:max="field.max"
|
|
65
|
+
:state="state"
|
|
66
|
+
:disabled="isDisabled"
|
|
67
|
+
:readonly="isReadonly || isPlaintext"
|
|
68
|
+
:plaintext="isPlaintext"
|
|
69
|
+
v-bind="inputProps"
|
|
70
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
71
|
+
/>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
import { computed } from "vue";
|
|
76
|
+
import DFormInput from "../base/DFormInput.vue";
|
|
77
|
+
import DInputGroup from "../base/DInputGroup.vue";
|
|
78
|
+
import DXCurrencyInput from "./DXCurrencyInput.vue";
|
|
79
|
+
import type { FieldDefinition } from "../../types";
|
|
80
|
+
|
|
81
|
+
interface Props {
|
|
82
|
+
/** Field definition (type currency/percentage/number + numeric options). */
|
|
83
|
+
field: FieldDefinition;
|
|
84
|
+
|
|
85
|
+
/** Current model value. */
|
|
86
|
+
modelValue: any;
|
|
87
|
+
|
|
88
|
+
/** Validation state passed to the control. */
|
|
89
|
+
state?: boolean | null;
|
|
90
|
+
|
|
91
|
+
/** Disabled state (`isDisabled`). */
|
|
92
|
+
isDisabled?: boolean;
|
|
93
|
+
|
|
94
|
+
/** Readonly state (`isReadonly`). */
|
|
95
|
+
isReadonly?: boolean;
|
|
96
|
+
|
|
97
|
+
/** Plaintext (display-only) state (`isPlaintext`). */
|
|
98
|
+
isPlaintext?: boolean;
|
|
99
|
+
|
|
100
|
+
/** Escape-hatch input props, already guard-merged by DXField. */
|
|
101
|
+
inputProps?: Record<string, any>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const props = defineProps<Props>();
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits<{
|
|
107
|
+
(event: "update:modelValue", value: any): void;
|
|
108
|
+
}>();
|
|
109
|
+
|
|
110
|
+
// For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
|
|
111
|
+
// the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
|
|
112
|
+
// binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
|
|
113
|
+
// percentage fields pass straight through.
|
|
114
|
+
const isFractionPercentage = computed(
|
|
115
|
+
() => props.field.type === "percentage" && props.field.asFraction === true,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const numericInputValue = computed({
|
|
119
|
+
get: () => {
|
|
120
|
+
const value = props.modelValue;
|
|
121
|
+
if (!isFractionPercentage.value) return value;
|
|
122
|
+
if (value === null || value === undefined || value === "") return value;
|
|
123
|
+
const num = Number(value);
|
|
124
|
+
if (!Number.isFinite(num)) return value;
|
|
125
|
+
return Math.round(num * 100 * 1e6) / 1e6;
|
|
126
|
+
},
|
|
127
|
+
set: (value: any) => {
|
|
128
|
+
if (!isFractionPercentage.value) {
|
|
129
|
+
emit("update:modelValue", value);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (value === null || value === undefined || value === "") {
|
|
133
|
+
emit("update:modelValue", value);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const num = Number(value);
|
|
137
|
+
if (!Number.isFinite(num)) {
|
|
138
|
+
emit("update:modelValue", value);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
emit("update:modelValue", Math.round((num / 100) * 1e9) / 1e9);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
</script>
|
|
@@ -91,137 +91,65 @@
|
|
|
91
91
|
</DButton>
|
|
92
92
|
</div>
|
|
93
93
|
|
|
94
|
-
<!-- Cards fallback:
|
|
95
|
-
shown instead of the table when there isn't enough room for
|
|
96
|
-
`v-else` (not `v-show`) — only the OUTER wrapper above needs to
|
|
94
|
+
<!-- Cards fallback: the same DXRepeaterCards as the default branch
|
|
95
|
+
below, shown instead of the table when there isn't enough room for
|
|
96
|
+
it. `v-else` (not `v-show`) — only the OUTER wrapper above needs to
|
|
97
97
|
stay mounted for measurement; unmounting whichever of these two
|
|
98
98
|
isn't current avoids permanently double-running every DXField
|
|
99
99
|
in both layouts at once (double async optionsLoader fetches,
|
|
100
100
|
double watchers, double everything else a field might do). -->
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:remove="() => removeRow(entry.index)"
|
|
114
|
-
:path="rowPath(entry.index)"
|
|
115
|
-
/>
|
|
116
|
-
|
|
117
|
-
<template v-else>
|
|
118
|
-
<div class="dx-repeater-row-header">
|
|
119
|
-
<span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
|
|
120
|
-
<DButton
|
|
121
|
-
variant="outline-danger"
|
|
122
|
-
size="sm"
|
|
123
|
-
:disabled="visibleRows.length <= minItems"
|
|
124
|
-
@click="removeRow(entry.index)"
|
|
125
|
-
>
|
|
126
|
-
Remove
|
|
127
|
-
</DButton>
|
|
128
|
-
</div>
|
|
129
|
-
<DXField
|
|
130
|
-
v-for="subField in subFields"
|
|
131
|
-
:key="subField.key"
|
|
132
|
-
:field="subField"
|
|
133
|
-
:form="form"
|
|
134
|
-
:model="entry.row"
|
|
135
|
-
:key-path="`${rowPath(entry.index)}.${subField.key}`"
|
|
136
|
-
:error-key="`${rowPath(entry.index)}.${subField.key}`"
|
|
137
|
-
/>
|
|
138
|
-
</template>
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
|
-
<DButton
|
|
142
|
-
variant="outline-primary"
|
|
143
|
-
size="sm"
|
|
144
|
-
:disabled="maxItems !== undefined && visibleRows.length >= maxItems"
|
|
145
|
-
@click="addRow"
|
|
146
|
-
>
|
|
147
|
-
{{ field.addLabel || "Add" }}
|
|
148
|
-
</DButton>
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<!-- Cards layout (default, `repeaterLayout: "cards"` or unset): each row
|
|
153
|
-
is its own bordered card with sub-fields stacked vertically. Kept as
|
|
154
|
-
a separate, unconditional branch (rather than reusing the fallback
|
|
155
|
-
above) so this — the overwhelmingly common case — never pays for a
|
|
156
|
-
ResizeObserver or the extra wrapper div; its markup is byte-for-byte
|
|
157
|
-
what cards mode has always rendered. -->
|
|
158
|
-
<div v-else class="dx-repeater">
|
|
159
|
-
<div
|
|
160
|
-
v-for="(entry, position) in visibleRows"
|
|
161
|
-
:key="rowKey(entry.row, position)"
|
|
162
|
-
class="dx-repeater-row"
|
|
101
|
+
<DXRepeaterCards
|
|
102
|
+
v-else
|
|
103
|
+
:visible-rows="visibleRows"
|
|
104
|
+
:sub-fields="subFields"
|
|
105
|
+
:form="form"
|
|
106
|
+
:field="field"
|
|
107
|
+
:min-items="minItems"
|
|
108
|
+
:max-items="maxItems"
|
|
109
|
+
:row-key="rowKey"
|
|
110
|
+
:row-path="rowPath"
|
|
111
|
+
:remove-row="removeRow"
|
|
112
|
+
:add-row="addRow"
|
|
163
113
|
>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
@binding {object} row The current row's data object.
|
|
167
|
-
@binding {number} index The row's zero-based position among visible rows.
|
|
168
|
-
@binding {FieldDefinition[]} fields The sub-field definitions for the row.
|
|
169
|
-
@binding {function} remove Removes this row (respects `minItems`; soft-deletes instead of splicing when `field.softDeleteKey` is set and the row has an `id`).
|
|
170
|
-
@binding {string} path The dot path into `form.data` for this row (e.g. `lines.0`).
|
|
171
|
-
-->
|
|
172
|
-
<slot
|
|
173
|
-
v-if="$slots.row"
|
|
174
|
-
name="row"
|
|
175
|
-
:row="entry.row"
|
|
176
|
-
:index="position"
|
|
177
|
-
:fields="subFields"
|
|
178
|
-
:remove="() => removeRow(entry.index)"
|
|
179
|
-
:path="rowPath(entry.index)"
|
|
180
|
-
/>
|
|
181
|
-
|
|
182
|
-
<!-- Default row: stack each sub-field -->
|
|
183
|
-
<template v-else>
|
|
184
|
-
<div class="dx-repeater-row-header">
|
|
185
|
-
<span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
|
|
186
|
-
<DButton
|
|
187
|
-
variant="outline-danger"
|
|
188
|
-
size="sm"
|
|
189
|
-
:disabled="visibleRows.length <= minItems"
|
|
190
|
-
@click="removeRow(entry.index)"
|
|
191
|
-
>
|
|
192
|
-
Remove
|
|
193
|
-
</DButton>
|
|
194
|
-
</div>
|
|
195
|
-
<DXField
|
|
196
|
-
v-for="subField in subFields"
|
|
197
|
-
:key="subField.key"
|
|
198
|
-
:field="subField"
|
|
199
|
-
:form="form"
|
|
200
|
-
:model="entry.row"
|
|
201
|
-
:key-path="`${rowPath(entry.index)}.${subField.key}`"
|
|
202
|
-
:error-key="`${rowPath(entry.index)}.${subField.key}`"
|
|
203
|
-
/>
|
|
114
|
+
<template v-if="$slots.row" #row="binds">
|
|
115
|
+
<slot name="row" v-bind="binds" />
|
|
204
116
|
</template>
|
|
205
|
-
</
|
|
206
|
-
|
|
207
|
-
<DButton
|
|
208
|
-
variant="outline-primary"
|
|
209
|
-
size="sm"
|
|
210
|
-
:disabled="maxItems !== undefined && visibleRows.length >= maxItems"
|
|
211
|
-
@click="addRow"
|
|
212
|
-
>
|
|
213
|
-
{{ field.addLabel || "Add" }}
|
|
214
|
-
</DButton>
|
|
117
|
+
</DXRepeaterCards>
|
|
215
118
|
</div>
|
|
119
|
+
|
|
120
|
+
<!-- Cards layout (default, `repeaterLayout: "cards"` or unset): each row
|
|
121
|
+
is its own bordered card with sub-fields stacked vertically. The
|
|
122
|
+
overwhelmingly common case, rendered directly (no ResizeObserver, no
|
|
123
|
+
measurement wrapper) — only the `table` preference above pays for
|
|
124
|
+
those. Both branches render the same DXRepeaterCards (#135). -->
|
|
125
|
+
<DXRepeaterCards
|
|
126
|
+
v-else
|
|
127
|
+
:visible-rows="visibleRows"
|
|
128
|
+
:sub-fields="subFields"
|
|
129
|
+
:form="form"
|
|
130
|
+
:field="field"
|
|
131
|
+
:min-items="minItems"
|
|
132
|
+
:max-items="maxItems"
|
|
133
|
+
:row-key="rowKey"
|
|
134
|
+
:row-path="rowPath"
|
|
135
|
+
:remove-row="removeRow"
|
|
136
|
+
:add-row="addRow"
|
|
137
|
+
>
|
|
138
|
+
<template v-if="$slots.row" #row="binds">
|
|
139
|
+
<slot name="row" v-bind="binds" />
|
|
140
|
+
</template>
|
|
141
|
+
</DXRepeaterCards>
|
|
216
142
|
</template>
|
|
217
143
|
|
|
218
144
|
<script setup lang="ts">
|
|
219
145
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
220
146
|
import DButton from "../base/DButton.vue";
|
|
221
147
|
import DXField from "./DXField.vue";
|
|
148
|
+
import DXRepeaterCards from "./DXRepeaterCards.vue";
|
|
222
149
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
223
|
-
import type { FieldDefinition
|
|
150
|
+
import type { FieldDefinition } from "../../types";
|
|
224
151
|
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
152
|
+
import { resolveFieldDefault } from "../../utils/formSchema";
|
|
225
153
|
|
|
226
154
|
interface Props {
|
|
227
155
|
/** Form instance owning the repeater array */
|
|
@@ -378,42 +306,15 @@ const rowKey = (row: any, fallbackPosition: number): string | number => {
|
|
|
378
306
|
return fallbackPosition;
|
|
379
307
|
};
|
|
380
308
|
|
|
381
|
-
/**
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return false;
|
|
387
|
-
case "number":
|
|
388
|
-
case "currency":
|
|
389
|
-
case "percentage":
|
|
390
|
-
return 0;
|
|
391
|
-
case "repeater":
|
|
392
|
-
return [];
|
|
393
|
-
case "image":
|
|
394
|
-
case "file":
|
|
395
|
-
return null;
|
|
396
|
-
default:
|
|
397
|
-
return "";
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Deep-clone a sub-field default so object/array defaults aren't shared
|
|
402
|
-
* (by reference) across rows. Uses JSON (not structuredClone) because the
|
|
403
|
-
* default may be a Vue reactive proxy — which structuredClone can't clone —
|
|
404
|
-
* and form defaults are JSON-serializable by construction. */
|
|
405
|
-
function cloneDefault(value: any): any {
|
|
406
|
-
if (value === null || typeof value !== "object") return value;
|
|
407
|
-
return JSON.parse(JSON.stringify(value));
|
|
408
|
-
}
|
|
409
|
-
|
|
309
|
+
/**
|
|
310
|
+
* A freshly-added row: each sub-field seeded by the shared `resolveFieldDefault`
|
|
311
|
+
* rule (explicit default deep-cloned when defined, else the type default), so
|
|
312
|
+
* repeater rows agree with every other seeding site (#134).
|
|
313
|
+
*/
|
|
410
314
|
function buildRow(): Record<string, any> {
|
|
411
315
|
const row: Record<string, any> = {};
|
|
412
316
|
for (const subField of subFields.value) {
|
|
413
|
-
row[subField.key] =
|
|
414
|
-
subField.default !== undefined
|
|
415
|
-
? cloneDefault(subField.default)
|
|
416
|
-
: defaultForType(subField.type);
|
|
317
|
+
row[subField.key] = resolveFieldDefault(subField);
|
|
417
318
|
}
|
|
418
319
|
return row;
|
|
419
320
|
}
|
|
@@ -467,32 +368,9 @@ function removeRow(index: number): void {
|
|
|
467
368
|
</script>
|
|
468
369
|
|
|
469
370
|
<style scoped>
|
|
470
|
-
.dx-repeater-row
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
padding: 1rem;
|
|
474
|
-
margin-bottom: 0.75rem;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
.dx-repeater-row-header {
|
|
478
|
-
display: flex;
|
|
479
|
-
align-items: center;
|
|
480
|
-
margin-bottom: 0.75rem;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/* The Remove control is always this header's last child, whether or not the
|
|
484
|
-
(opt-in) row-index span is also present — push it to the end explicitly
|
|
485
|
-
rather than relying on `justify-content: space-between`, which centres a
|
|
486
|
-
single remaining child at the *start* once the index is off (the default). */
|
|
487
|
-
.dx-repeater-row-header > :last-child {
|
|
488
|
-
margin-inline-start: auto;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
.dx-repeater-row-index {
|
|
492
|
-
font-weight: 600;
|
|
493
|
-
color: var(--bs-secondary-color);
|
|
494
|
-
}
|
|
495
|
-
|
|
371
|
+
/* Cards-layout row styles (.dx-repeater-row*) live in DXRepeaterCards.vue with
|
|
372
|
+
the markup they target — a scoped rule can't reach another component (#135).
|
|
373
|
+
Only the table layout is rendered directly here, so only its styles remain. */
|
|
496
374
|
.dx-repeater-table td {
|
|
497
375
|
vertical-align: middle;
|
|
498
376
|
}
|