@omnitend/dashboard-for-laravel 0.8.0 → 0.9.1
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/extended/DXField.vue.d.ts +33 -16
- package/dist/components/extended/DXFieldLabel.vue.d.ts +8 -0
- package/dist/components/extended/DXTable.vue.d.ts +6 -2
- package/dist/dashboard-for-laravel.js +7417 -7238
- 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 +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +35 -3
- package/docs/public/api-reference.json +27 -4
- package/docs/public/docs-map.md +1 -1
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -1
- package/resources/js/components/extended/DXField.vue +145 -6
- package/resources/js/components/extended/DXFieldLabel.vue +72 -0
- package/resources/js/components/extended/DXForm.vue +8 -1
- package/resources/js/components/extended/DXRepeater.vue +1 -0
- package/resources/js/components/extended/DXTable.vue +26 -5
- package/resources/js/composables/defineForm.ts +1 -0
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +39 -2
package/dist/types/index.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ import type { Component } from "vue";
|
|
|
10
10
|
* - `image` / `file` — file input (`image` additionally shows a preview).
|
|
11
11
|
* - `component` — escape hatch that renders `field.component`.
|
|
12
12
|
* - `repeater` — nested, repeatable sub-form driven by `field.fields`.
|
|
13
|
+
* - `switch` — a toggle checkbox with contextual on/off text and an
|
|
14
|
+
* on-state (filled) style; see `textWhenTrue` / `textWhenFalse`.
|
|
13
15
|
*/
|
|
14
|
-
export type FieldType = "text" | "email" | "password" | "number" | "url" | "tel" | "date" | "datetime-local" | "datetime" | "time" | "currency" | "percentage" | "textarea" | "select" | "checkbox" | "radio" | "image" | "file" | "component" | "repeater";
|
|
16
|
+
export type FieldType = "text" | "email" | "password" | "number" | "url" | "tel" | "date" | "datetime-local" | "datetime" | "time" | "currency" | "percentage" | "textarea" | "select" | "checkbox" | "switch" | "radio" | "image" | "file" | "component" | "repeater";
|
|
15
17
|
/**
|
|
16
18
|
* A value that may be supplied directly or computed from the live form
|
|
17
19
|
* model. Predicates receive the current model so fields can react to
|
|
@@ -68,6 +70,14 @@ export interface FieldDefinition {
|
|
|
68
70
|
max?: number | string;
|
|
69
71
|
/** Symbol shown for `currency` fields (default: the locale's, "£"). */
|
|
70
72
|
currencySymbol?: string;
|
|
73
|
+
/**
|
|
74
|
+
* For `percentage` fields: treat the underlying model value as a 0–1
|
|
75
|
+
* fraction while showing/editing it as a 0–100 percentage. The model keeps
|
|
76
|
+
* the fraction (e.g. `0.2`), the input shows `20`. Off by default (the value
|
|
77
|
+
* is taken as a whole percentage). Use for fields stored as ratios (VAT
|
|
78
|
+
* rates, discounts, …).
|
|
79
|
+
*/
|
|
80
|
+
asFraction?: boolean;
|
|
71
81
|
/** `accept` attribute for `image`/`file` inputs (e.g. "image/*"). */
|
|
72
82
|
accept?: string;
|
|
73
83
|
/** Help text displayed below the field (always visible). */
|
|
@@ -77,6 +87,23 @@ export interface FieldDefinition {
|
|
|
77
87
|
* function of the model for dynamic hints.
|
|
78
88
|
*/
|
|
79
89
|
hint?: MaybeFn<string>;
|
|
90
|
+
/**
|
|
91
|
+
* Longer help text revealed in a popover from a small info affordance
|
|
92
|
+
* on the field's label (on hover/focus). Complements `hint` (which is
|
|
93
|
+
* always-visible muted text below the control). May be a function of
|
|
94
|
+
* the model. For rich content, use the `#info` slot instead.
|
|
95
|
+
*/
|
|
96
|
+
info?: MaybeFn<string>;
|
|
97
|
+
/**
|
|
98
|
+
* For `switch` fields: contextual label shown when the toggle is on.
|
|
99
|
+
* Falls back to `label` when omitted. May be a function of the model.
|
|
100
|
+
*/
|
|
101
|
+
textWhenTrue?: MaybeFn<string>;
|
|
102
|
+
/**
|
|
103
|
+
* For `switch` fields: contextual label shown when the toggle is off.
|
|
104
|
+
* Falls back to `label` when omitted. May be a function of the model.
|
|
105
|
+
*/
|
|
106
|
+
textWhenFalse?: MaybeFn<string>;
|
|
80
107
|
/** CSS class for the form group */
|
|
81
108
|
class?: string;
|
|
82
109
|
/** Additional props to pass to the input component */
|
|
@@ -137,8 +164,13 @@ export interface FieldDefinition {
|
|
|
137
164
|
export interface FormTab {
|
|
138
165
|
/** Unique key for this tab */
|
|
139
166
|
key: string;
|
|
140
|
-
/**
|
|
141
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Display label (optional, defaults to the key). May be a function of the
|
|
169
|
+
* form model — the live form data merged with any `context` (e.g. the row
|
|
170
|
+
* DXTable is editing) — so a tab title can reflect the record, such as
|
|
171
|
+
* `label: (model) => \`Products (${model.products_count ?? 0})\``.
|
|
172
|
+
*/
|
|
173
|
+
label?: MaybeFn<string>;
|
|
142
174
|
/** Field keys (from the form's fields) to render in this tab */
|
|
143
175
|
fieldKeys: string[];
|
|
144
176
|
/** Conditional display — boolean or a function of the form model */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated": "2026-07-
|
|
2
|
+
"generated": "2026-07-04T11:43:17.570Z",
|
|
3
3
|
"package": {
|
|
4
4
|
"name": "@omnitend/dashboard-for-laravel",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.9.1"
|
|
6
6
|
},
|
|
7
7
|
"components": {
|
|
8
8
|
"base": [
|
|
@@ -1605,6 +1605,29 @@
|
|
|
1605
1605
|
],
|
|
1606
1606
|
"methods": []
|
|
1607
1607
|
},
|
|
1608
|
+
{
|
|
1609
|
+
"name": "DXFieldLabel",
|
|
1610
|
+
"category": "extended",
|
|
1611
|
+
"filePath": "resources/js/components/extended/DXFieldLabel.vue",
|
|
1612
|
+
"description": "",
|
|
1613
|
+
"props": [
|
|
1614
|
+
{
|
|
1615
|
+
"name": "label",
|
|
1616
|
+
"type": "string",
|
|
1617
|
+
"required": true,
|
|
1618
|
+
"description": "Visible label text."
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
"name": "info",
|
|
1622
|
+
"type": "string",
|
|
1623
|
+
"required": false,
|
|
1624
|
+
"description": "Optional help text revealed in a popover from an info affordance."
|
|
1625
|
+
}
|
|
1626
|
+
],
|
|
1627
|
+
"events": [],
|
|
1628
|
+
"slots": [],
|
|
1629
|
+
"methods": []
|
|
1630
|
+
},
|
|
1608
1631
|
{
|
|
1609
1632
|
"name": "DXForm",
|
|
1610
1633
|
"category": "extended",
|
|
@@ -2244,8 +2267,8 @@
|
|
|
2244
2267
|
]
|
|
2245
2268
|
},
|
|
2246
2269
|
"stats": {
|
|
2247
|
-
"totalComponents":
|
|
2270
|
+
"totalComponents": 66,
|
|
2248
2271
|
"baseComponents": 57,
|
|
2249
|
-
"extendedComponents":
|
|
2272
|
+
"extendedComponents": 9
|
|
2250
2273
|
}
|
|
2251
2274
|
}
|
package/docs/public/docs-map.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Documentation Map
|
|
2
2
|
|
|
3
3
|
> Auto-generated hierarchical overview of all documentation
|
|
4
|
-
> Last updated: 2026-07-
|
|
4
|
+
> Last updated: 2026-07-04T11:43:17.631Z
|
|
5
5
|
|
|
6
6
|
This file provides a complete map of all available documentation for AI agents and developers.
|
|
7
7
|
|
package/docs/public/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Vue 3 dashboard components for Laravel with Bootstrap Vue Next
|
|
4
4
|
>
|
|
5
|
-
> A dual-package library (NPM + Composer) providing
|
|
5
|
+
> A dual-package library (NPM + Composer) providing 66 Vue 3 components for building Laravel dashboards with Bootstrap Vue Next.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -81,7 +81,7 @@ Lightweight type-safe wrappers around Bootstrap Vue Next components providing AP
|
|
|
81
81
|
- [DToaster](/components/base/dtoaster): Bootstrap Vue Next Toaster wrapper
|
|
82
82
|
- [DTooltip](/components/base/dtooltip): Bootstrap Vue Next Tooltip wrapper
|
|
83
83
|
|
|
84
|
-
## Extended Components (
|
|
84
|
+
## Extended Components (9 components)
|
|
85
85
|
|
|
86
86
|
Custom dashboard components with advanced functionality beyond Bootstrap Vue Next.
|
|
87
87
|
|
|
@@ -90,6 +90,7 @@ Custom dashboard components with advanced functionality beyond Bootstrap Vue Nex
|
|
|
90
90
|
- [DXDashboardNavbar](/components/extended/dxdashboardnavbar): Top navbar with user dropdown
|
|
91
91
|
- [DXDashboardSidebar](/components/extended/dxdashboardsidebar): Collapsible sidebar with navigation
|
|
92
92
|
- [DXField](/components/extended/dxfield): Single-field renderer for any field type with value/span/info/hint slots
|
|
93
|
+
- [DXFieldLabel](/components/extended/dxfieldlabel): Extended dashboard component
|
|
93
94
|
- [DXForm](/components/extended/dxform): Form renderer driven by field definitions, with optional tabs, conditional fields, per-field slots, async options, nested repeaters, and auto error-tab switching
|
|
94
95
|
- [DXRepeater](/components/extended/dxrepeater): Repeatable nested sub-form (field array) primitive
|
|
95
96
|
- [DXTable](/components/extended/dxtable): Data table with pagination, filtering, and sorting
|
package/package.json
CHANGED
|
@@ -26,14 +26,47 @@
|
|
|
26
26
|
:disabled="isDisabled || isReadonly"
|
|
27
27
|
v-bind="field.inputProps"
|
|
28
28
|
>
|
|
29
|
-
|
|
29
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
30
30
|
</DFormCheckbox>
|
|
31
31
|
|
|
32
32
|
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
33
33
|
{{ form.getError(errorKey) }}
|
|
34
34
|
</DFormInvalidFeedback>
|
|
35
35
|
<slot name="info" :field="field" :model="model" />
|
|
36
|
-
<DFormText v-if="resolvedHint" class="text-muted">
|
|
36
|
+
<DFormText v-if="resolvedHint || $slots.hint" 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
|
+
<!-- Switch: toggle with contextual on/off text and an on-state style -->
|
|
43
|
+
<div
|
|
44
|
+
v-else-if="field.type === 'switch'"
|
|
45
|
+
:class="[field.class || 'mb-3', 'dx-switch', { 'dx-switch--on': switchIsOn }]"
|
|
46
|
+
>
|
|
47
|
+
<slot
|
|
48
|
+
v-if="$slots.value"
|
|
49
|
+
name="value"
|
|
50
|
+
:field="field"
|
|
51
|
+
:model="model"
|
|
52
|
+
:value="fieldValue"
|
|
53
|
+
:update="setValue"
|
|
54
|
+
/>
|
|
55
|
+
<DFormCheckbox
|
|
56
|
+
v-else
|
|
57
|
+
v-model="switchModel"
|
|
58
|
+
switch
|
|
59
|
+
:disabled="isDisabled || isReadonly"
|
|
60
|
+
v-bind="field.inputProps"
|
|
61
|
+
>
|
|
62
|
+
<DXFieldLabel :label="switchText" :info="resolvedInfo" />
|
|
63
|
+
</DFormCheckbox>
|
|
64
|
+
|
|
65
|
+
<DFormInvalidFeedback v-if="form.hasError(errorKey)" force-show>
|
|
66
|
+
{{ form.getError(errorKey) }}
|
|
67
|
+
</DFormInvalidFeedback>
|
|
68
|
+
<slot name="info" :field="field" :model="model" />
|
|
69
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
37
70
|
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
38
71
|
</DFormText>
|
|
39
72
|
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
@@ -41,7 +74,10 @@
|
|
|
41
74
|
|
|
42
75
|
<!-- Repeater: nested, repeatable sub-form -->
|
|
43
76
|
<div v-else-if="field.type === 'repeater'" :class="field.class || 'mb-3'">
|
|
44
|
-
<DFormGroup
|
|
77
|
+
<DFormGroup>
|
|
78
|
+
<template #label>
|
|
79
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
80
|
+
</template>
|
|
45
81
|
<DXRepeater
|
|
46
82
|
:form="form"
|
|
47
83
|
:field="field"
|
|
@@ -55,14 +91,19 @@
|
|
|
55
91
|
</DXRepeater>
|
|
56
92
|
</DFormGroup>
|
|
57
93
|
<slot name="info" :field="field" :model="model" />
|
|
58
|
-
<DFormText v-if="resolvedHint" class="text-muted">
|
|
94
|
+
<DFormText v-if="resolvedHint || $slots.hint" class="text-muted">
|
|
59
95
|
<slot name="hint" :field="field" :model="model">{{ resolvedHint }}</slot>
|
|
60
96
|
</DFormText>
|
|
61
97
|
<DFormText v-if="field.help">{{ field.help }}</DFormText>
|
|
62
98
|
</div>
|
|
63
99
|
|
|
64
100
|
<!-- Standard labelled field -->
|
|
65
|
-
<DFormGroup v-else :
|
|
101
|
+
<DFormGroup v-else :class="field.class || 'mb-3'">
|
|
102
|
+
<!-- Label with optional info popover -->
|
|
103
|
+
<template #label>
|
|
104
|
+
<DXFieldLabel :label="resolvedLabel" :info="resolvedInfo" />
|
|
105
|
+
</template>
|
|
106
|
+
|
|
66
107
|
<!-- Custom value slot overrides the built-in control -->
|
|
67
108
|
<slot
|
|
68
109
|
v-if="$slots.value"
|
|
@@ -144,7 +185,7 @@
|
|
|
144
185
|
<span class="input-group-text">{{ field.currencySymbol || "£" }}</span>
|
|
145
186
|
</template>
|
|
146
187
|
<DFormInput
|
|
147
|
-
v-model="
|
|
188
|
+
v-model="numericInputValue"
|
|
148
189
|
type="number"
|
|
149
190
|
:required="field.required"
|
|
150
191
|
:placeholder="field.placeholder"
|
|
@@ -213,6 +254,7 @@ import DFormCheckbox from "../base/DFormCheckbox.vue";
|
|
|
213
254
|
import DFormInvalidFeedback from "../base/DFormInvalidFeedback.vue";
|
|
214
255
|
import DFormText from "../base/DFormText.vue";
|
|
215
256
|
import DInputGroup from "../base/DInputGroup.vue";
|
|
257
|
+
import DXFieldLabel from "./DXFieldLabel.vue";
|
|
216
258
|
import type { UseFormReturn } from "../../composables/useForm";
|
|
217
259
|
import type { FieldDefinition, FieldOption, FieldType } from "../../types";
|
|
218
260
|
import { getByPath, setByPath } from "../../utils/objectPath";
|
|
@@ -273,6 +315,41 @@ const fieldValue = computed({
|
|
|
273
315
|
set: (value: any) => setValue(value),
|
|
274
316
|
});
|
|
275
317
|
|
|
318
|
+
// For `percentage` fields with `asFraction`, the model holds a 0–1 fraction but
|
|
319
|
+
// the input shows/edits a 0–100 percentage. Scale on read/write, rounding away
|
|
320
|
+
// binary-float artefacts (0.2 * 100 = 20.000000000000004). Currency and plain
|
|
321
|
+
// percentage fields pass straight through.
|
|
322
|
+
const isFractionPercentage = computed(
|
|
323
|
+
() => props.field.type === "percentage" && props.field.asFraction === true,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const numericInputValue = computed({
|
|
327
|
+
get: () => {
|
|
328
|
+
const value = fieldValue.value;
|
|
329
|
+
if (!isFractionPercentage.value) return value;
|
|
330
|
+
if (value === null || value === undefined || value === "") return value;
|
|
331
|
+
const num = Number(value);
|
|
332
|
+
if (!Number.isFinite(num)) return value;
|
|
333
|
+
return Math.round(num * 100 * 1e6) / 1e6;
|
|
334
|
+
},
|
|
335
|
+
set: (value: any) => {
|
|
336
|
+
if (!isFractionPercentage.value) {
|
|
337
|
+
setValue(value);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (value === null || value === undefined || value === "") {
|
|
341
|
+
setValue(value);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const num = Number(value);
|
|
345
|
+
if (!Number.isFinite(num)) {
|
|
346
|
+
setValue(value);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
setValue(Math.round((num / 100) * 1e9) / 1e9);
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
276
353
|
const NUMERIC_TYPES: ReadonlySet<FieldType> = new Set([
|
|
277
354
|
"number",
|
|
278
355
|
"currency",
|
|
@@ -307,6 +384,46 @@ const resolvedLabel = computed(
|
|
|
307
384
|
|
|
308
385
|
const resolvedHint = computed(() => resolveMaybe(props.field.hint));
|
|
309
386
|
|
|
387
|
+
const resolvedInfo = computed(() => resolveMaybe(props.field.info));
|
|
388
|
+
|
|
389
|
+
// ————————————————— switch (toggle) field
|
|
390
|
+
|
|
391
|
+
// Whether a `switch` field is currently on. Coerces the model value to a
|
|
392
|
+
// boolean, but treats the common "falsey" string encodings a backend might send
|
|
393
|
+
// for a boolean ("0", "false", "") as off — plain `Boolean("0")` is `true`,
|
|
394
|
+
// which would wrongly render such a value on.
|
|
395
|
+
const switchIsOn = computed(() => {
|
|
396
|
+
const value = fieldValue.value;
|
|
397
|
+
if (typeof value === "string") {
|
|
398
|
+
const normalised = value.trim().toLowerCase();
|
|
399
|
+
return normalised !== "" && normalised !== "0" && normalised !== "false";
|
|
400
|
+
}
|
|
401
|
+
return Boolean(value);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Bind the toggle to a normalised boolean. The underlying bvn checkbox only
|
|
405
|
+
// treats a literal `true` as checked, so a truthy non-boolean model (e.g.
|
|
406
|
+
// Laravel serialising a boolean column as `1`, or a `"1"` string) would render
|
|
407
|
+
// the toggle in the *off* position while `switchIsOn` styled it *on* — the
|
|
408
|
+
// control contradicting itself. Reading a real boolean keeps the checkbox
|
|
409
|
+
// position, the on-state style, and the contextual text in agreement; writing
|
|
410
|
+
// stores a clean boolean.
|
|
411
|
+
const switchModel = computed({
|
|
412
|
+
get: () => switchIsOn.value,
|
|
413
|
+
set: (value: boolean) => setValue(value),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Contextual label for a `switch` field: `textWhenTrue`/`textWhenFalse`
|
|
418
|
+
* for the current state, falling back to the field's label.
|
|
419
|
+
*/
|
|
420
|
+
const switchText = computed(() => {
|
|
421
|
+
const contextual = switchIsOn.value
|
|
422
|
+
? resolveMaybe(props.field.textWhenTrue)
|
|
423
|
+
: resolveMaybe(props.field.textWhenFalse);
|
|
424
|
+
return contextual ?? resolvedLabel.value;
|
|
425
|
+
});
|
|
426
|
+
|
|
310
427
|
const isDisabled = computed(() => {
|
|
311
428
|
if (props.field.disabledWhen) {
|
|
312
429
|
return props.field.disabledWhen(effectiveModel.value);
|
|
@@ -399,4 +516,26 @@ onBeforeUnmount(() => {
|
|
|
399
516
|
border: 1px solid var(--bs-border-color);
|
|
400
517
|
border-radius: var(--bs-border-radius);
|
|
401
518
|
}
|
|
519
|
+
|
|
520
|
+
/* Switch field: contextual styling that responds to the on/off state.
|
|
521
|
+
Off is muted; on turns the control and label a filled success green. */
|
|
522
|
+
.dx-switch :deep(.form-check-label) {
|
|
523
|
+
color: var(--bs-secondary-color);
|
|
524
|
+
transition: color 0.15s ease-in-out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.dx-switch--on :deep(.form-check-label) {
|
|
528
|
+
color: var(--bs-success);
|
|
529
|
+
font-weight: 500;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.dx-switch--on :deep(.form-check-input:checked) {
|
|
533
|
+
background-color: var(--bs-success);
|
|
534
|
+
border-color: var(--bs-success);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.dx-switch--on :deep(.form-check-input:focus) {
|
|
538
|
+
border-color: var(--bs-success);
|
|
539
|
+
box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25);
|
|
540
|
+
}
|
|
402
541
|
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span class="dx-field-label">
|
|
3
|
+
<span class="dx-field-label__text">{{ label }}</span>
|
|
4
|
+
<template v-if="info">
|
|
5
|
+
<button
|
|
6
|
+
:id="infoId"
|
|
7
|
+
type="button"
|
|
8
|
+
class="dx-field-label__info"
|
|
9
|
+
:aria-label="`More information: ${label}`"
|
|
10
|
+
@click.stop.prevent
|
|
11
|
+
>
|
|
12
|
+
<i-lucide-info aria-hidden="true" />
|
|
13
|
+
</button>
|
|
14
|
+
<DPopover
|
|
15
|
+
:target="infoId"
|
|
16
|
+
hover
|
|
17
|
+
focus
|
|
18
|
+
placement="top"
|
|
19
|
+
>
|
|
20
|
+
{{ info }}
|
|
21
|
+
</DPopover>
|
|
22
|
+
</template>
|
|
23
|
+
</span>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { useId } from "vue";
|
|
28
|
+
import DPopover from "../base/DPopover.vue";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
/** Visible label text. */
|
|
32
|
+
label: string;
|
|
33
|
+
|
|
34
|
+
/** Optional help text revealed in a popover from an info affordance. */
|
|
35
|
+
info?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
defineProps<Props>();
|
|
39
|
+
|
|
40
|
+
// Stable, SSR-safe id so the popover can target the trigger button.
|
|
41
|
+
const infoId = `dx-field-info-${useId()}`;
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.dx-field-label {
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 0.35rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.dx-field-label__info {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
padding: 0;
|
|
56
|
+
border: 0;
|
|
57
|
+
background: none;
|
|
58
|
+
line-height: 1;
|
|
59
|
+
color: var(--bs-secondary-color);
|
|
60
|
+
cursor: help;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.dx-field-label__info:hover,
|
|
64
|
+
.dx-field-label__info:focus-visible {
|
|
65
|
+
color: var(--bs-primary);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.dx-field-label__info svg {
|
|
69
|
+
width: 0.9em;
|
|
70
|
+
height: 0.9em;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<DTab
|
|
30
30
|
v-for="(tab, index) in visibleTabs"
|
|
31
31
|
:key="tab.key"
|
|
32
|
-
:title="tab
|
|
32
|
+
:title="resolveTabLabel(tab)"
|
|
33
33
|
:lazy="tab.lazy"
|
|
34
34
|
:active="index === 0"
|
|
35
35
|
>
|
|
@@ -207,6 +207,13 @@ function resolvePredicate(
|
|
|
207
207
|
return typeof when === "function" ? when(model.value) : when;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** Resolve a tab's (possibly function-valued) label against the live model. */
|
|
211
|
+
function resolveTabLabel(tab: FormTab): string {
|
|
212
|
+
const label =
|
|
213
|
+
typeof tab.label === "function" ? tab.label(model.value) : tab.label;
|
|
214
|
+
return label || tab.key;
|
|
215
|
+
}
|
|
216
|
+
|
|
210
217
|
function isFieldVisible(field: FieldDefinition): boolean {
|
|
211
218
|
const whenOk = resolvePredicate(field.when, true);
|
|
212
219
|
const showOk = field.show ? field.show() : true;
|
|
@@ -529,7 +529,7 @@
|
|
|
529
529
|
:disabled="editForm?.processing"
|
|
530
530
|
@click="handleDelete"
|
|
531
531
|
>
|
|
532
|
-
{{
|
|
532
|
+
{{ pendingAction === 'delete' ? 'Deleting...' : 'Delete' }}
|
|
533
533
|
</DButton>
|
|
534
534
|
</div>
|
|
535
535
|
<div class="d-flex gap-2">
|
|
@@ -542,10 +542,10 @@
|
|
|
542
542
|
@click="handleEditSave"
|
|
543
543
|
>
|
|
544
544
|
<template v-if="isCreateMode">
|
|
545
|
-
{{
|
|
545
|
+
{{ pendingAction === 'save' ? 'Creating...' : 'Create' }}
|
|
546
546
|
</template>
|
|
547
547
|
<template v-else>
|
|
548
|
-
{{
|
|
548
|
+
{{ pendingAction === 'save' ? 'Saving...' : 'Save Changes' }}
|
|
549
549
|
</template>
|
|
550
550
|
</DButton>
|
|
551
551
|
</div>
|
|
@@ -621,8 +621,12 @@ export interface EditTab {
|
|
|
621
621
|
/** Unique key for this tab */
|
|
622
622
|
key: string;
|
|
623
623
|
|
|
624
|
-
/**
|
|
625
|
-
|
|
624
|
+
/**
|
|
625
|
+
* Display label (optional, auto-derived from key if omitted). May be a
|
|
626
|
+
* function of the model (the edited row merged with the live form data),
|
|
627
|
+
* e.g. `label: (item) => \`Products (${item.products_count ?? 0})\``.
|
|
628
|
+
*/
|
|
629
|
+
label?: string | ((item: any) => string);
|
|
626
630
|
|
|
627
631
|
/** Field keys to display in this tab (from editFields) */
|
|
628
632
|
fieldKeys: string[];
|
|
@@ -1380,6 +1384,11 @@ const editForm = ref<any>(null);
|
|
|
1380
1384
|
const activeTabIndex = ref(0);
|
|
1381
1385
|
const isCreateMode = ref(false);
|
|
1382
1386
|
|
|
1387
|
+
// Which modal action is in flight, so the Save and Delete buttons show their
|
|
1388
|
+
// own loading label independently. `editForm.processing` is shared by every
|
|
1389
|
+
// request the form makes, so it can't tell Save from Delete on its own.
|
|
1390
|
+
const pendingAction = ref<'save' | 'delete' | null>(null);
|
|
1391
|
+
|
|
1383
1392
|
// Toast (may not be available in test environment)
|
|
1384
1393
|
let createToast: ((obj: any) => any) | undefined;
|
|
1385
1394
|
try {
|
|
@@ -1521,6 +1530,15 @@ const handleCreateNew = () => {
|
|
|
1521
1530
|
const handleEditSave = async () => {
|
|
1522
1531
|
if (!editForm.value) return;
|
|
1523
1532
|
|
|
1533
|
+
pendingAction.value = 'save';
|
|
1534
|
+
try {
|
|
1535
|
+
await performSave();
|
|
1536
|
+
} finally {
|
|
1537
|
+
pendingAction.value = null;
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
const performSave = async () => {
|
|
1524
1542
|
// Create mode: POST to createUrl
|
|
1525
1543
|
if (isCreateMode.value && props.createUrl) {
|
|
1526
1544
|
try {
|
|
@@ -1648,6 +1666,7 @@ const handleDelete = async () => {
|
|
|
1648
1666
|
|
|
1649
1667
|
if (!confirmed) return;
|
|
1650
1668
|
|
|
1669
|
+
pendingAction.value = 'delete';
|
|
1651
1670
|
try {
|
|
1652
1671
|
const itemId = (selectedItem.value as any).id;
|
|
1653
1672
|
const url = props.deleteUrl.replace(':id', itemId);
|
|
@@ -1687,6 +1706,8 @@ const handleDelete = async () => {
|
|
|
1687
1706
|
});
|
|
1688
1707
|
} catch (error) {
|
|
1689
1708
|
emit('deleteError', selectedItem.value as T, error);
|
|
1709
|
+
} finally {
|
|
1710
|
+
pendingAction.value = null;
|
|
1690
1711
|
}
|
|
1691
1712
|
};
|
|
1692
1713
|
|
package/resources/js/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from 'bootstrap-vue-next';
|
|
|
8
8
|
export { default as DXDashboard } from "./components/extended/DXDashboard.vue";
|
|
9
9
|
export { default as DXForm } from "./components/extended/DXForm.vue";
|
|
10
10
|
export { default as DXField } from "./components/extended/DXField.vue";
|
|
11
|
+
export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
|
|
11
12
|
export { default as DXRepeater } from "./components/extended/DXRepeater.vue";
|
|
12
13
|
/**
|
|
13
14
|
* @deprecated Use `DXForm`. `DXBasicForm` is a thin wrapper around `DXForm`
|
|
@@ -11,6 +11,8 @@ import type { Component } from "vue";
|
|
|
11
11
|
* - `image` / `file` — file input (`image` additionally shows a preview).
|
|
12
12
|
* - `component` — escape hatch that renders `field.component`.
|
|
13
13
|
* - `repeater` — nested, repeatable sub-form driven by `field.fields`.
|
|
14
|
+
* - `switch` — a toggle checkbox with contextual on/off text and an
|
|
15
|
+
* on-state (filled) style; see `textWhenTrue` / `textWhenFalse`.
|
|
14
16
|
*/
|
|
15
17
|
export type FieldType =
|
|
16
18
|
| "text"
|
|
@@ -28,6 +30,7 @@ export type FieldType =
|
|
|
28
30
|
| "textarea"
|
|
29
31
|
| "select"
|
|
30
32
|
| "checkbox"
|
|
33
|
+
| "switch"
|
|
31
34
|
| "radio"
|
|
32
35
|
| "image"
|
|
33
36
|
| "file"
|
|
@@ -105,6 +108,15 @@ export interface FieldDefinition {
|
|
|
105
108
|
/** Symbol shown for `currency` fields (default: the locale's, "£"). */
|
|
106
109
|
currencySymbol?: string;
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* For `percentage` fields: treat the underlying model value as a 0–1
|
|
113
|
+
* fraction while showing/editing it as a 0–100 percentage. The model keeps
|
|
114
|
+
* the fraction (e.g. `0.2`), the input shows `20`. Off by default (the value
|
|
115
|
+
* is taken as a whole percentage). Use for fields stored as ratios (VAT
|
|
116
|
+
* rates, discounts, …).
|
|
117
|
+
*/
|
|
118
|
+
asFraction?: boolean;
|
|
119
|
+
|
|
108
120
|
/** `accept` attribute for `image`/`file` inputs (e.g. "image/*"). */
|
|
109
121
|
accept?: string;
|
|
110
122
|
|
|
@@ -117,6 +129,26 @@ export interface FieldDefinition {
|
|
|
117
129
|
*/
|
|
118
130
|
hint?: MaybeFn<string>;
|
|
119
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Longer help text revealed in a popover from a small info affordance
|
|
134
|
+
* on the field's label (on hover/focus). Complements `hint` (which is
|
|
135
|
+
* always-visible muted text below the control). May be a function of
|
|
136
|
+
* the model. For rich content, use the `#info` slot instead.
|
|
137
|
+
*/
|
|
138
|
+
info?: MaybeFn<string>;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* For `switch` fields: contextual label shown when the toggle is on.
|
|
142
|
+
* Falls back to `label` when omitted. May be a function of the model.
|
|
143
|
+
*/
|
|
144
|
+
textWhenTrue?: MaybeFn<string>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* For `switch` fields: contextual label shown when the toggle is off.
|
|
148
|
+
* Falls back to `label` when omitted. May be a function of the model.
|
|
149
|
+
*/
|
|
150
|
+
textWhenFalse?: MaybeFn<string>;
|
|
151
|
+
|
|
120
152
|
/** CSS class for the form group */
|
|
121
153
|
class?: string;
|
|
122
154
|
|
|
@@ -191,8 +223,13 @@ export interface FormTab {
|
|
|
191
223
|
/** Unique key for this tab */
|
|
192
224
|
key: string;
|
|
193
225
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Display label (optional, defaults to the key). May be a function of the
|
|
228
|
+
* form model — the live form data merged with any `context` (e.g. the row
|
|
229
|
+
* DXTable is editing) — so a tab title can reflect the record, such as
|
|
230
|
+
* `label: (model) => \`Products (${model.products_count ?? 0})\``.
|
|
231
|
+
*/
|
|
232
|
+
label?: MaybeFn<string>;
|
|
196
233
|
|
|
197
234
|
/** Field keys (from the form's fields) to render in this tab */
|
|
198
235
|
fieldKeys: string[];
|