@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
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
:form="form"
|
|
48
48
|
:fields="fields"
|
|
49
49
|
:tabs="tabs"
|
|
50
|
+
:layout="layout"
|
|
51
|
+
:label-cols="labelCols"
|
|
52
|
+
:card="card"
|
|
50
53
|
:card-tabs="false"
|
|
51
54
|
:context="item ?? undefined"
|
|
52
55
|
:show-submit="false"
|
|
@@ -147,7 +150,7 @@
|
|
|
147
150
|
:disabled="form?.processing || loading"
|
|
148
151
|
@click="emit('delete')"
|
|
149
152
|
>
|
|
150
|
-
|
|
153
|
+
{{ deleteLabel }}
|
|
151
154
|
</DButton>
|
|
152
155
|
</div>
|
|
153
156
|
<div class="d-flex gap-2">
|
|
@@ -161,7 +164,7 @@
|
|
|
161
164
|
:disabled="form?.processing || loading"
|
|
162
165
|
@click="emit('save')"
|
|
163
166
|
>
|
|
164
|
-
{{ isCreateMode ?
|
|
167
|
+
{{ isCreateMode ? createLabel : saveLabel }}
|
|
165
168
|
</DButton>
|
|
166
169
|
</div>
|
|
167
170
|
</div>
|
|
@@ -176,6 +179,7 @@ import DButton from "../base/DButton.vue";
|
|
|
176
179
|
import DSpinner from "../base/DSpinner.vue";
|
|
177
180
|
import DXForm from "./DXForm.vue";
|
|
178
181
|
import type { EditTab } from "./DXTable.vue";
|
|
182
|
+
import type { LabelCols } from "../../types";
|
|
179
183
|
|
|
180
184
|
interface Props {
|
|
181
185
|
/** Whether the modal is open (v-model:show). */
|
|
@@ -204,10 +208,38 @@ interface Props {
|
|
|
204
208
|
pendingAction: 'save' | 'delete' | null;
|
|
205
209
|
/** Delete endpoint — gates the Delete button. */
|
|
206
210
|
deleteUrl?: string;
|
|
211
|
+
/** Field layout forwarded to the modal's DXForm (defaults handled upstream). */
|
|
212
|
+
layout?: 'vertical' | 'horizontal';
|
|
213
|
+
/** Label column width forwarded to DXForm (horizontal layout). */
|
|
214
|
+
labelCols?: LabelCols;
|
|
215
|
+
/** Wrap the modal form in a card (opt-in — off by default). */
|
|
216
|
+
card?: boolean;
|
|
217
|
+
/** Singular item noun for the default button labels (e.g. "customer"). */
|
|
218
|
+
itemName?: string;
|
|
219
|
+
/** Override the Save button label (edit mode). */
|
|
220
|
+
saveText?: string;
|
|
221
|
+
/** Override the Create button label (create mode). */
|
|
222
|
+
createText?: string;
|
|
223
|
+
/** Override the Delete button label. */
|
|
224
|
+
deleteText?: string;
|
|
207
225
|
}
|
|
208
226
|
|
|
209
227
|
const props = defineProps<Props>();
|
|
210
228
|
|
|
229
|
+
// Title-case the singular item noun for button labels ("customer" → "Customer",
|
|
230
|
+
// "sales order" → "Sales Order") — capitalize the first letter of each word.
|
|
231
|
+
const capitalizedItem = computed(() => {
|
|
232
|
+
const noun = props.itemName?.trim() || 'item';
|
|
233
|
+
return noun.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Buttons default to item-named copy ("Save Customer" / "Create Customer" /
|
|
237
|
+
// "Delete Customer") — clearer than a generic "Save Changes" — overridable per
|
|
238
|
+
// action. Falls back to "Save Item" etc. when no `itemName` is set.
|
|
239
|
+
const saveLabel = computed(() => props.saveText ?? `Save ${capitalizedItem.value}`);
|
|
240
|
+
const createLabel = computed(() => props.createText ?? `Create ${capitalizedItem.value}`);
|
|
241
|
+
const deleteLabel = computed(() => props.deleteText ?? `Delete ${capitalizedItem.value}`);
|
|
242
|
+
|
|
211
243
|
const emit = defineEmits<{
|
|
212
244
|
/** v-model:show — the modal open state changed. */
|
|
213
245
|
'update:show': [value: boolean];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useForm, type UseFormReturn } from "./useForm";
|
|
2
|
-
import type { FieldDefinition
|
|
2
|
+
import type { FieldDefinition } from "../types";
|
|
3
|
+
import { resolveFieldDefault } from "../utils/formSchema";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Extended field definition that includes default value
|
|
@@ -30,35 +31,6 @@ export interface DefineFormReturn<TData extends Record<string, any>> {
|
|
|
30
31
|
fields: FieldDefinition[];
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
* Helper to get default value for a field type
|
|
35
|
-
*/
|
|
36
|
-
function getDefaultValueForType(type: FieldType): any {
|
|
37
|
-
switch (type) {
|
|
38
|
-
case "checkbox":
|
|
39
|
-
case "switch":
|
|
40
|
-
return false;
|
|
41
|
-
case "number":
|
|
42
|
-
case "currency":
|
|
43
|
-
case "percentage":
|
|
44
|
-
return 0;
|
|
45
|
-
case "repeater":
|
|
46
|
-
// Array-valued selections: checkbox-group previously fell through to
|
|
47
|
-
// the "" default, which is the wrong shape for an array model.
|
|
48
|
-
case "checkbox-group":
|
|
49
|
-
case "switch-list":
|
|
50
|
-
return [];
|
|
51
|
-
case "image":
|
|
52
|
-
case "file":
|
|
53
|
-
return null;
|
|
54
|
-
case "select":
|
|
55
|
-
case "radio":
|
|
56
|
-
return "";
|
|
57
|
-
default:
|
|
58
|
-
return "";
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
34
|
/**
|
|
63
35
|
* Define a form with type-safe field definitions.
|
|
64
36
|
* Reduces duplication by inferring initial values from field definitions.
|
|
@@ -75,22 +47,14 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
|
|
|
75
47
|
): DefineFormReturn<InferFormData<TFields>> {
|
|
76
48
|
type FormData = InferFormData<TFields>;
|
|
77
49
|
|
|
78
|
-
// Extract initial data from field definitions.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
|
|
84
|
-
// definedness, not nullishness, so null survives; an absent (or explicitly
|
|
85
|
-
// `undefined`) default still falls back to the type default rather than
|
|
86
|
-
// seeding `undefined`, which would drop the key from the JSON payload. This
|
|
87
|
-
// matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
|
|
50
|
+
// Extract initial data from field definitions. Seeding (definedness over
|
|
51
|
+
// nullishness so a `null` default survives — #122/#125 — plus a type-aware
|
|
52
|
+
// fallback and deep-cloning) is the shared `resolveFieldDefault` rule, so
|
|
53
|
+
// every seeding site (defineForm / useResourceEditor / DXRepeater) agrees
|
|
54
|
+
// by construction (#134).
|
|
88
55
|
const initialData = fieldDefinitions.reduce(
|
|
89
56
|
(acc, field) => {
|
|
90
|
-
acc[field.key] =
|
|
91
|
-
field.default !== undefined
|
|
92
|
-
? field.default
|
|
93
|
-
: getDefaultValueForType(field.type);
|
|
57
|
+
acc[field.key] = resolveFieldDefault(field);
|
|
94
58
|
return acc;
|
|
95
59
|
},
|
|
96
60
|
{} as Record<string, any>,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { onMounted, ref, watch, type ComputedRef, type Ref } from "vue";
|
|
2
|
+
import type { FieldDefinition, FieldOption } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Async option loading for a `DXField`: runs `field.optionsLoader` on mount and
|
|
6
|
+
* (when `field.reloadOptionsOnChange` is set) whenever the model changes,
|
|
7
|
+
* exposing the loaded options. A monotonic request token guards against
|
|
8
|
+
* out-of-order responses clobbering newer ones; loader failures are swallowed
|
|
9
|
+
* so the last good options (or the static `field.options` fallback) stay put.
|
|
10
|
+
*
|
|
11
|
+
* Extracted verbatim from DXField's inline logic (#135) — behaviour unchanged.
|
|
12
|
+
* `getField` is an accessor (props aren't destructurable without losing
|
|
13
|
+
* reactivity); `effectiveModel` is the model passed to the loader.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseAsyncOptionsReturn {
|
|
16
|
+
/** Options returned by the most recent successful load, or null if none yet. */
|
|
17
|
+
loadedOptions: Ref<FieldOption[] | null>;
|
|
18
|
+
/** Trigger a load (no-op when the field has no `optionsLoader`). */
|
|
19
|
+
loadOptions: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAsyncOptions(
|
|
23
|
+
getField: () => FieldDefinition,
|
|
24
|
+
effectiveModel: ComputedRef<any> | Ref<any>,
|
|
25
|
+
): UseAsyncOptionsReturn {
|
|
26
|
+
const loadedOptions = ref<FieldOption[] | null>(null);
|
|
27
|
+
|
|
28
|
+
// Monotonic token so out-of-order async responses can't clobber newer ones.
|
|
29
|
+
let optionsRequestToken = 0;
|
|
30
|
+
|
|
31
|
+
async function loadOptions(): Promise<void> {
|
|
32
|
+
const field = getField();
|
|
33
|
+
if (!field.optionsLoader) return;
|
|
34
|
+
const token = ++optionsRequestToken;
|
|
35
|
+
try {
|
|
36
|
+
const options = await field.optionsLoader(effectiveModel.value);
|
|
37
|
+
// Ignore a stale response superseded by a newer load.
|
|
38
|
+
if (token === optionsRequestToken) loadedOptions.value = options;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Swallow loader failures: keep the last successfully loaded options
|
|
41
|
+
// (or the static `field.options` fallback when none have loaded yet).
|
|
42
|
+
if (token === optionsRequestToken) {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.error(
|
|
45
|
+
`optionsLoader failed for field "${field.key}"`,
|
|
46
|
+
error,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
onMounted(() => {
|
|
53
|
+
void loadOptions();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (getField().reloadOptionsOnChange) {
|
|
57
|
+
watch(effectiveModel, () => void loadOptions(), { deep: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { loadedOptions, loadOptions };
|
|
61
|
+
}
|
|
@@ -2,6 +2,12 @@ import { computed, ref, type Ref } from "vue";
|
|
|
2
2
|
import { api } from "../utils/api";
|
|
3
3
|
import { useForm } from "./useForm";
|
|
4
4
|
import { useToast } from "./useToast";
|
|
5
|
+
import type { FieldDefinition } from "../types";
|
|
6
|
+
import {
|
|
7
|
+
resolveFieldDefault,
|
|
8
|
+
isFieldVisible as isFieldVisibleFor,
|
|
9
|
+
isSubmittableField,
|
|
10
|
+
} from "../utils/formSchema";
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* The subset of `DXTable`'s props the resource editor reads. Declared
|
|
@@ -11,7 +17,7 @@ import { useToast } from "./useToast";
|
|
|
11
17
|
*/
|
|
12
18
|
export interface ResourceEditorProps<T = any> {
|
|
13
19
|
/** Field definitions for the edit/create modal (enables edit-on-row-click). */
|
|
14
|
-
editFields?:
|
|
20
|
+
editFields?: FieldDefinition[];
|
|
15
21
|
/** Tab definitions — only their `key` is read here (DXForm consumes the rest). */
|
|
16
22
|
editTabs?: Array<{ key: string }>;
|
|
17
23
|
/** Modal title, or a function of the edited row. */
|
|
@@ -108,58 +114,38 @@ export function useResourceEditor<T = any>(
|
|
|
108
114
|
// and auto-switching to the first tab with a validation error.
|
|
109
115
|
|
|
110
116
|
/*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* too, so the create and edit paths agree about the same field.
|
|
118
|
-
*/
|
|
119
|
-
const defaultValueFor = (field: any): any =>
|
|
120
|
-
Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
|
|
121
|
-
|
|
122
|
-
/*
|
|
123
|
-
* Seeding an EDIT form from a row (#117).
|
|
124
|
-
*
|
|
125
|
-
* `row[key] ?? field.default` is wrong: it can't tell "the row has no such key"
|
|
126
|
-
* from "the row's value IS null", so an explicitly-null column gets overwritten
|
|
127
|
-
* by the field's default — a value the user never saw, on a field they may not
|
|
128
|
-
* even be able to see. Presence, not nullishness, decides.
|
|
117
|
+
* Seeding an EDIT form from a row (#117): use the row's own value when it
|
|
118
|
+
* HAS the key (presence, not nullishness — an explicitly-null column must
|
|
119
|
+
* not be overwritten by the field's default), else the field's own default.
|
|
120
|
+
* The default comes from the shared `resolveFieldDefault` rule, so create
|
|
121
|
+
* and edit seeding agree with defineForm/DXRepeater (#134) — this path is
|
|
122
|
+
* now type-aware (an array field falls back to `[]`, not `''`).
|
|
129
123
|
*/
|
|
130
124
|
const seedValueFor = (field: any, row: T): any => {
|
|
131
125
|
if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
|
|
132
126
|
return (row as any)[field.key];
|
|
133
127
|
}
|
|
134
|
-
return
|
|
128
|
+
return resolveFieldDefault(field);
|
|
135
129
|
};
|
|
136
130
|
|
|
137
131
|
/*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* Kept in step with DXForm.isFieldVisible.
|
|
132
|
+
* The model field/tab predicates evaluate against: the edited row widened
|
|
133
|
+
* with the live form data (context + data), same as DXForm's `model`.
|
|
141
134
|
*/
|
|
142
135
|
const editPredicateModel = computed(() => ({
|
|
143
136
|
...((selectedItem.value as any) ?? {}),
|
|
144
137
|
...(editForm.value?.data ?? {}),
|
|
145
138
|
}));
|
|
146
139
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
? true
|
|
152
|
-
: typeof when === 'function'
|
|
153
|
-
? when(editPredicateModel.value)
|
|
154
|
-
: when;
|
|
155
|
-
const showOk = field.show ? field.show() : true;
|
|
156
|
-
return whenOk && showOk;
|
|
157
|
-
};
|
|
140
|
+
// Visibility by the one shared rule (`when` + legacy `show`), against the
|
|
141
|
+
// edit model — see formSchema.isFieldVisible (#134).
|
|
142
|
+
const isEditFieldVisible = (field: any): boolean =>
|
|
143
|
+
isFieldVisibleFor(field, editPredicateModel.value);
|
|
158
144
|
|
|
159
145
|
// Fields whose value actually belongs in the payload. A presentational field
|
|
160
146
|
// (submit: false) renders but holds no data — see FieldDefinition.submit.
|
|
161
147
|
const submittableEditFields = computed(() =>
|
|
162
|
-
(props.editFields ?? []).filter(
|
|
148
|
+
(props.editFields ?? []).filter(isSubmittableField),
|
|
163
149
|
);
|
|
164
150
|
|
|
165
151
|
// Enforced at SUBMIT, not just at seeding: the modal still renders every field,
|
|
@@ -172,7 +158,7 @@ export function useResourceEditor<T = any>(
|
|
|
172
158
|
.filter(
|
|
173
159
|
(field) =>
|
|
174
160
|
// Presentational: lays the form out, holds no data (#110).
|
|
175
|
-
field
|
|
161
|
+
!isSubmittableField(field) ||
|
|
176
162
|
// Hidden by `when` at submit time (#117). Submitting a field
|
|
177
163
|
// the user cannot see is a silent write — and with `default`
|
|
178
164
|
// set it writes a value they never chose. Omitting the key
|
|
@@ -299,13 +285,13 @@ export function useResourceEditor<T = any>(
|
|
|
299
285
|
if (!editForm.value) {
|
|
300
286
|
const formData: Record<string, any> = {};
|
|
301
287
|
submittableEditFields.value.forEach(field => {
|
|
302
|
-
formData[field.key] =
|
|
288
|
+
formData[field.key] = resolveFieldDefault(field);
|
|
303
289
|
});
|
|
304
290
|
editForm.value = useForm(formData);
|
|
305
291
|
} else {
|
|
306
292
|
// Reset existing form to defaults
|
|
307
293
|
submittableEditFields.value.forEach(field => {
|
|
308
|
-
editForm.value.data[field.key] =
|
|
294
|
+
editForm.value.data[field.key] = resolveFieldDefault(field);
|
|
309
295
|
});
|
|
310
296
|
editForm.value.clearErrors();
|
|
311
297
|
}
|
package/resources/js/index.ts
CHANGED
|
@@ -113,6 +113,16 @@ export { useToast } from "./composables/useToast";
|
|
|
113
113
|
|
|
114
114
|
// Utils
|
|
115
115
|
export { api } from "./utils/api";
|
|
116
|
+
// Pure form-schema helpers (#134) — the single source of defaulting, cloning,
|
|
117
|
+
// predicate resolution, visibility and submission-eligibility.
|
|
118
|
+
export {
|
|
119
|
+
defaultValueForType,
|
|
120
|
+
cloneDefault,
|
|
121
|
+
resolveFieldDefault,
|
|
122
|
+
resolvePredicate,
|
|
123
|
+
isFieldVisible,
|
|
124
|
+
isSubmittableField,
|
|
125
|
+
} from "./utils/formSchema";
|
|
116
126
|
|
|
117
127
|
// Types
|
|
118
128
|
export type {
|
|
@@ -122,6 +132,27 @@ export type {
|
|
|
122
132
|
FormTab,
|
|
123
133
|
MaybeFn,
|
|
124
134
|
OptionsLoader,
|
|
135
|
+
// Strict, discriminated field types (#131) — opt-in; canonical in a future
|
|
136
|
+
// major. `FieldDef` is the union; the rest are its per-type members.
|
|
137
|
+
FieldDef,
|
|
138
|
+
BaseFieldDef,
|
|
139
|
+
TextFieldDef,
|
|
140
|
+
PasswordFieldDef,
|
|
141
|
+
NumberFieldDef,
|
|
142
|
+
DateFieldDef,
|
|
143
|
+
CurrencyFieldDef,
|
|
144
|
+
PercentageFieldDef,
|
|
145
|
+
TextareaFieldDef,
|
|
146
|
+
SelectFieldDef,
|
|
147
|
+
AutocompleteFieldDef,
|
|
148
|
+
CheckboxFieldDef,
|
|
149
|
+
CheckboxGroupFieldDef,
|
|
150
|
+
SwitchFieldDef,
|
|
151
|
+
SwitchListFieldDef,
|
|
152
|
+
RadioFieldDef,
|
|
153
|
+
FileFieldDef,
|
|
154
|
+
ComponentFieldDef,
|
|
155
|
+
RepeaterFieldDef,
|
|
125
156
|
} from "./types";
|
|
126
157
|
|
|
127
158
|
export type {
|
|
@@ -149,6 +180,8 @@ export type {
|
|
|
149
180
|
BTableProvider,
|
|
150
181
|
EditTab,
|
|
151
182
|
BTableProviderContext,
|
|
183
|
+
DXTableApiAdapter,
|
|
184
|
+
DXTableSource,
|
|
152
185
|
} from "./components/extended/DXTable.vue";
|
|
153
186
|
|
|
154
187
|
export type {
|
|
@@ -380,6 +380,237 @@ export interface FieldDefinition {
|
|
|
380
380
|
show?: () => boolean;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
384
|
+
// Strict, discriminated field types (#131)
|
|
385
|
+
//
|
|
386
|
+
// The permissive `FieldDefinition` above lets every option appear on every
|
|
387
|
+
// field type: `currencySymbol` compiles on a checkbox, `options` on a text
|
|
388
|
+
// input, `component` without `type: "component"`. The types below are the
|
|
389
|
+
// strict, `type`-keyed replacement — each variant permits only its own
|
|
390
|
+
// options, so the compiler catches an invalid field config at the call site.
|
|
391
|
+
//
|
|
392
|
+
// ADDITIVE for now: `FieldDefinition` stays canonical and these are opt-in
|
|
393
|
+
// (`import type { FieldDef } from "@omnitend/dashboard-for-laravel"`). A future
|
|
394
|
+
// major (#131 Phase 3) makes `FieldDef` canonical and removes the permissive
|
|
395
|
+
// interface with a codemod. **Until then, keep the two in step** — a prop added
|
|
396
|
+
// to `FieldDefinition` must be added to the matching variant(s) below. The doc
|
|
397
|
+
// comments live on `FieldDefinition`; variants stay terse to keep them aligned.
|
|
398
|
+
//
|
|
399
|
+
// Known limitation: the union rejects invalid FRESH object literals (excess
|
|
400
|
+
// property checking — the common `fields: [{ type: "checkbox", … }]` call site),
|
|
401
|
+
// but a PRE-DECLARED object with excess props (`const f = { type: "checkbox",
|
|
402
|
+
// currencySymbol: "£" }`) stays structurally assignable. Full exclusivity would
|
|
403
|
+
// need `never`-typed exclusion keys on every variant (or a StrictUnion helper) —
|
|
404
|
+
// heavy machinery with confusing errors for marginal gain on an opt-in type, so
|
|
405
|
+
// it's deliberately not done here.
|
|
406
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/** Options common to every field variant (the non-`type`-specific props). */
|
|
409
|
+
export interface BaseFieldDef {
|
|
410
|
+
key: string;
|
|
411
|
+
label?: MaybeFn<string>;
|
|
412
|
+
required?: boolean;
|
|
413
|
+
help?: string;
|
|
414
|
+
hint?: MaybeFn<string>;
|
|
415
|
+
info?: MaybeFn<string>;
|
|
416
|
+
class?: string;
|
|
417
|
+
inputProps?: Record<string, any>;
|
|
418
|
+
span?: boolean;
|
|
419
|
+
layout?: "vertical" | "horizontal";
|
|
420
|
+
labelCols?: LabelCols;
|
|
421
|
+
hideLabel?: boolean;
|
|
422
|
+
default?: any;
|
|
423
|
+
disabled?: MaybeFn<boolean>;
|
|
424
|
+
disabledWhen?: (model: any) => boolean;
|
|
425
|
+
readonly?: MaybeFn<boolean>;
|
|
426
|
+
plaintext?: MaybeFn<boolean>;
|
|
427
|
+
submit?: boolean;
|
|
428
|
+
when?: MaybeFn<boolean>;
|
|
429
|
+
show?: () => boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Mixins shared by several variants (not exported — internal composition). */
|
|
433
|
+
interface WithPlaceholder {
|
|
434
|
+
placeholder?: string;
|
|
435
|
+
}
|
|
436
|
+
interface WithNumericBounds {
|
|
437
|
+
step?: number | string;
|
|
438
|
+
min?: number | string;
|
|
439
|
+
max?: number | string;
|
|
440
|
+
}
|
|
441
|
+
interface WithSelectableOptions {
|
|
442
|
+
options?: FieldOption[];
|
|
443
|
+
optionsLoader?: OptionsLoader;
|
|
444
|
+
reloadOptionsOnChange?: boolean;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** `text` / `email` / `url` / `tel` — a plain text-like `<input>`. */
|
|
448
|
+
export interface TextFieldDef extends BaseFieldDef, WithPlaceholder {
|
|
449
|
+
type: "text" | "email" | "url" | "tel";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `password` — text input with an optional reveal (eye) toggle. */
|
|
453
|
+
export interface PasswordFieldDef extends BaseFieldDef, WithPlaceholder {
|
|
454
|
+
type: "password";
|
|
455
|
+
revealable?: boolean;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `number` — numeric input with optional bounds. */
|
|
459
|
+
export interface NumberFieldDef
|
|
460
|
+
extends BaseFieldDef,
|
|
461
|
+
WithPlaceholder,
|
|
462
|
+
WithNumericBounds {
|
|
463
|
+
type: "number";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** `date` / `datetime` / `datetime-local` / `time` — native date/time controls.
|
|
467
|
+
* `min`/`max`/`step` are valid on these inputs (e.g. `min="2024-01-01"`) and
|
|
468
|
+
* DXField forwards them, so the variant permits them. */
|
|
469
|
+
export interface DateFieldDef extends BaseFieldDef, WithNumericBounds {
|
|
470
|
+
type: "date" | "datetime" | "datetime-local" | "time";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** `currency` — numeric input with a symbol affix and minor-unit handling. */
|
|
474
|
+
export interface CurrencyFieldDef
|
|
475
|
+
extends BaseFieldDef,
|
|
476
|
+
WithPlaceholder,
|
|
477
|
+
WithNumericBounds {
|
|
478
|
+
type: "currency";
|
|
479
|
+
currencySymbol?: string;
|
|
480
|
+
decimals?: number;
|
|
481
|
+
minorUnits?: boolean;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** `percentage` — numeric input shown as a 0–100 percentage. */
|
|
485
|
+
export interface PercentageFieldDef
|
|
486
|
+
extends BaseFieldDef,
|
|
487
|
+
WithPlaceholder,
|
|
488
|
+
WithNumericBounds {
|
|
489
|
+
type: "percentage";
|
|
490
|
+
asFraction?: boolean;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `textarea` — multi-line text input. */
|
|
494
|
+
export interface TextareaFieldDef extends BaseFieldDef, WithPlaceholder {
|
|
495
|
+
type: "textarea";
|
|
496
|
+
rows?: number;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** `select` — dropdown; `searchable` upgrades it to a filterable select. */
|
|
500
|
+
export interface SelectFieldDef
|
|
501
|
+
extends BaseFieldDef,
|
|
502
|
+
WithPlaceholder,
|
|
503
|
+
WithSelectableOptions {
|
|
504
|
+
type: "select";
|
|
505
|
+
searchable?: boolean;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** `autocomplete` — free-text input with datalist suggestions. */
|
|
509
|
+
export interface AutocompleteFieldDef
|
|
510
|
+
extends BaseFieldDef,
|
|
511
|
+
WithPlaceholder,
|
|
512
|
+
WithSelectableOptions {
|
|
513
|
+
type: "autocomplete";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** `checkbox` — a single boolean checkbox. */
|
|
517
|
+
export interface CheckboxFieldDef extends BaseFieldDef {
|
|
518
|
+
type: "checkbox";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `checkbox-group` — multiple checkboxes; model is an array of values. */
|
|
522
|
+
export interface CheckboxGroupFieldDef
|
|
523
|
+
extends BaseFieldDef,
|
|
524
|
+
WithSelectableOptions {
|
|
525
|
+
type: "checkbox-group";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** `switch` — a toggle with contextual on/off text and on-state colour. */
|
|
529
|
+
export interface SwitchFieldDef extends BaseFieldDef {
|
|
530
|
+
type: "switch";
|
|
531
|
+
textWhenTrue?: MaybeFn<string>;
|
|
532
|
+
textWhenFalse?: MaybeFn<string>;
|
|
533
|
+
switchVariant?: "success" | "neutral";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** `switch-list` — a labelled toggle row per option; model is an array. */
|
|
537
|
+
export interface SwitchListFieldDef
|
|
538
|
+
extends BaseFieldDef,
|
|
539
|
+
WithSelectableOptions {
|
|
540
|
+
type: "switch-list";
|
|
541
|
+
switchVariant?: "success" | "neutral";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `radio` — a radio group. */
|
|
545
|
+
export interface RadioFieldDef extends BaseFieldDef, WithSelectableOptions {
|
|
546
|
+
type: "radio";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** `image` / `file` — file input (`image` also shows a preview). */
|
|
550
|
+
export interface FileFieldDef extends BaseFieldDef {
|
|
551
|
+
type: "image" | "file";
|
|
552
|
+
accept?: string;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** `component` — escape hatch rendering `field.component`. */
|
|
556
|
+
export interface ComponentFieldDef extends BaseFieldDef {
|
|
557
|
+
type: "component";
|
|
558
|
+
/** Required on the strict variant: a `component` field with no component
|
|
559
|
+
* falls through to the generic input branch, so the union enforces it. */
|
|
560
|
+
component: Component;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** `repeater` — a nested, repeatable sub-form driven by `fields`. */
|
|
564
|
+
export interface RepeaterFieldDef extends BaseFieldDef {
|
|
565
|
+
type: "repeater";
|
|
566
|
+
fields?: FieldDef[];
|
|
567
|
+
addLabel?: string;
|
|
568
|
+
minItems?: number;
|
|
569
|
+
maxItems?: number;
|
|
570
|
+
repeaterLayout?: "cards" | "table";
|
|
571
|
+
showRowIndex?: boolean;
|
|
572
|
+
softDeleteKey?: string;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Strict, discriminated replacement for `FieldDefinition`. Keyed on `type`,
|
|
577
|
+
* so each field only permits its own options. Opt-in today; canonical in a
|
|
578
|
+
* future major (#131). See the banner above.
|
|
579
|
+
*/
|
|
580
|
+
export type FieldDef =
|
|
581
|
+
| TextFieldDef
|
|
582
|
+
| PasswordFieldDef
|
|
583
|
+
| NumberFieldDef
|
|
584
|
+
| DateFieldDef
|
|
585
|
+
| CurrencyFieldDef
|
|
586
|
+
| PercentageFieldDef
|
|
587
|
+
| TextareaFieldDef
|
|
588
|
+
| SelectFieldDef
|
|
589
|
+
| AutocompleteFieldDef
|
|
590
|
+
| CheckboxFieldDef
|
|
591
|
+
| CheckboxGroupFieldDef
|
|
592
|
+
| SwitchFieldDef
|
|
593
|
+
| SwitchListFieldDef
|
|
594
|
+
| RadioFieldDef
|
|
595
|
+
| FileFieldDef
|
|
596
|
+
| ComponentFieldDef
|
|
597
|
+
| RepeaterFieldDef;
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Compile-time drift guard (#131): every strict variant must remain assignable
|
|
601
|
+
* to the permissive `FieldDefinition`. If a `FieldDef` variant grows a prop that
|
|
602
|
+
* `FieldDefinition` doesn't have (or types incompatibly), this fails to satisfy
|
|
603
|
+
* its `true` constraint and the build goes red. Purely type-level — no runtime.
|
|
604
|
+
* (The reverse direction — a prop added to `FieldDefinition` but forgotten on a
|
|
605
|
+
* variant — can't be asserted this way; the banner above is the reminder.)
|
|
606
|
+
*/
|
|
607
|
+
type _AssertTrue<T extends true> = T;
|
|
608
|
+
// Exported (under an internal underscore name, not re-exported by the barrel)
|
|
609
|
+
// only so `noUnusedLocals` treats it as used — its value is the constraint check.
|
|
610
|
+
export type _FieldDefStaysASubset = _AssertTrue<
|
|
611
|
+
FieldDef extends FieldDefinition ? true : false
|
|
612
|
+
>;
|
|
613
|
+
|
|
383
614
|
/**
|
|
384
615
|
* A tab in a tabbed form. Groups a subset of fields and can be shown
|
|
385
616
|
* conditionally or lazily mounted.
|