@omnitend/dashboard-for-laravel 0.4.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,137 @@
1
+ <!--
2
+ DXForm — the canonical form renderer.
3
+
4
+ Driven by field definitions, with optional tabs. Renders every field
5
+ through DXField (the single field engine), so flat and tabbed forms share
6
+ one code path. Supports conditional fields/tabs, per-field slot overrides,
7
+ async options, nested repeaters, and auto-switching to the first tab
8
+ containing a validation error.
9
+
10
+ Accepts either a `useForm` return or a `defineForm` return; with the latter
11
+ `fields` may be omitted. Works with the fetch-based `useForm` composable
12
+ (no Inertia required).
13
+ -->
1
14
  <template>
2
- <OBasicForm
3
- :form="form.form"
4
- :fields="form.fields"
5
- :submit-text="submitText"
6
- :submit-loading-text="submitLoadingText"
7
- :show-submit="showSubmit"
8
- @submit="emit('submit')"
9
- >
10
- <!-- Pass through all slots -->
11
- <template v-for="(_, name) in $slots" #[name]="slotProps">
12
- <slot :name="name" v-bind="slotProps" />
15
+ <BForm @submit.prevent="handleSubmit">
16
+ <!-- Form-level error message -->
17
+ <DAlert
18
+ v-if="resolvedForm.shouldShowMessage"
19
+ :model-value="resolvedForm.shouldShowMessage"
20
+ variant="danger"
21
+ class="mb-3"
22
+ >
23
+ {{ resolvedForm.message }}
24
+ </DAlert>
25
+
26
+ <!-- Tabbed layout. BTabs exposes the active *index* via v-model:index
27
+ (plain v-model is the active tab id), which is what we track. -->
28
+ <DTabs v-if="hasTabs" v-model:index="activeTab">
29
+ <DTab
30
+ v-for="(tab, index) in visibleTabs"
31
+ :key="tab.key"
32
+ :title="tab.label || tab.key"
33
+ :lazy="tab.lazy"
34
+ :active="index === 0"
35
+ >
36
+ <!-- Replace the entire tab body -->
37
+ <slot
38
+ v-if="$slots[`tab-content(${tab.key})`]"
39
+ :name="`tab-content(${tab.key})`"
40
+ :tab="tab"
41
+ :model="model"
42
+ />
43
+
44
+ <div v-else class="pt-3">
45
+ <slot :name="`tab-before(${tab.key})`" :tab="tab" :model="model" />
46
+
47
+ <DXField
48
+ v-for="field in visibleFieldsFor(tab)"
49
+ :key="field.key"
50
+ :field="field"
51
+ :form="resolvedForm"
52
+ :model="model"
53
+ >
54
+ <template
55
+ v-for="(slotName, target) in fieldSlotMap(field.key)"
56
+ :key="target"
57
+ #[target]="slotProps"
58
+ >
59
+ <slot :name="slotName" v-bind="slotProps" />
60
+ </template>
61
+ </DXField>
62
+
63
+ <slot :name="`tab-after(${tab.key})`" :tab="tab" :model="model" />
64
+ </div>
65
+ </DTab>
66
+ </DTabs>
67
+
68
+ <!-- Flat layout (no tabs) -->
69
+ <template v-else>
70
+ <DXField
71
+ v-for="field in visibleFlatFields"
72
+ :key="field.key"
73
+ :field="field"
74
+ :form="resolvedForm"
75
+ :model="model"
76
+ >
77
+ <template
78
+ v-for="(slotName, target) in fieldSlotMap(field.key)"
79
+ :key="target"
80
+ #[target]="slotProps"
81
+ >
82
+ <slot :name="slotName" v-bind="slotProps" />
83
+ </template>
84
+ </DXField>
13
85
  </template>
14
- </OBasicForm>
86
+
87
+ <!-- Submit button -->
88
+ <DButton
89
+ v-if="showSubmit"
90
+ type="submit"
91
+ variant="primary"
92
+ :disabled="resolvedForm.processing"
93
+ class="w-100 mt-3"
94
+ >
95
+ <span v-if="resolvedForm.processing">{{ submitLoadingText }}</span>
96
+ <span v-else>{{ submitText }}</span>
97
+ </DButton>
98
+
99
+ <slot name="footer" :form="resolvedForm" />
100
+ </BForm>
15
101
  </template>
16
102
 
17
103
  <script setup lang="ts">
18
- import OBasicForm from "./DXBasicForm.vue";
104
+ import { computed, watch } from "vue";
105
+ import { BForm } from "bootstrap-vue-next";
106
+ import DAlert from "../base/DAlert.vue";
107
+ import DButton from "../base/DButton.vue";
108
+ import DTabs from "../base/DTabs.vue";
109
+ import DTab from "../base/DTab.vue";
110
+ import DXField from "./DXField.vue";
111
+ import type { UseFormReturn } from "../../composables/useForm";
19
112
  import type { DefineFormReturn } from "../../composables/defineForm";
113
+ import type { FieldDefinition, FormTab, MaybeFn } from "../../types";
20
114
 
21
115
  interface Props {
22
- /** Form object from defineForm */
23
- form: DefineFormReturn<any>;
116
+ /**
117
+ * Form instance — either a raw `useForm` return or a `defineForm`
118
+ * return (`{ form, fields }`). With the latter, `fields` may be
119
+ * omitted and is taken from the form object.
120
+ */
121
+ form: UseFormReturn<any> | DefineFormReturn<any>;
122
+
123
+ /** Field definitions (optional when `form` is a defineForm return). */
124
+ fields?: FieldDefinition[];
125
+
126
+ /** Tab definitions. When omitted, a flat single-column form renders. */
127
+ tabs?: FormTab[];
128
+
129
+ /**
130
+ * Extra context merged under the live form data when evaluating
131
+ * predicates (label/hint/when/disabled). E.g. a table passes the
132
+ * original row so predicates can read non-edited columns.
133
+ */
134
+ context?: Record<string, any>;
24
135
 
25
136
  /** Submit button text */
26
137
  submitText?: string;
@@ -28,17 +139,171 @@ interface Props {
28
139
  /** Submit button loading text */
29
140
  submitLoadingText?: string;
30
141
 
31
- /** Show submit button */
142
+ /** Show the submit button */
32
143
  showSubmit?: boolean;
144
+
145
+ /** Auto-switch to the first tab containing a validation error. */
146
+ autoErrorTab?: boolean;
33
147
  }
34
148
 
35
- withDefaults(defineProps<Props>(), {
149
+ const props = withDefaults(defineProps<Props>(), {
36
150
  submitText: "Submit",
37
151
  submitLoadingText: "Submitting...",
38
152
  showSubmit: true,
153
+ autoErrorTab: true,
39
154
  });
40
155
 
41
156
  const emit = defineEmits<{
42
157
  submit: [];
43
158
  }>();
159
+
160
+ const slots = defineSlots<Record<string, (props: any) => any>>();
161
+
162
+ /** v-model for the active tab index. */
163
+ const activeTab = defineModel<number>("activeTab", { default: 0 });
164
+
165
+ // ————————————————— resolve form / fields (accept useForm or defineForm)
166
+
167
+ function isDefineForm(
168
+ value: Props["form"],
169
+ ): value is DefineFormReturn<any> {
170
+ return (
171
+ !!value &&
172
+ typeof value === "object" &&
173
+ "form" in value &&
174
+ "fields" in value &&
175
+ !("data" in value)
176
+ );
177
+ }
178
+
179
+ const resolvedForm = computed<UseFormReturn<any>>(() =>
180
+ isDefineForm(props.form) ? props.form.form : props.form,
181
+ );
182
+
183
+ const resolvedFields = computed<FieldDefinition[]>(() => {
184
+ if (props.fields) return props.fields;
185
+ if (isDefineForm(props.form)) return props.form.fields;
186
+ return [];
187
+ });
188
+
189
+ const fieldByKey = computed<Record<string, FieldDefinition>>(() => {
190
+ const map: Record<string, FieldDefinition> = {};
191
+ for (const field of resolvedFields.value) map[field.key] = field;
192
+ return map;
193
+ });
194
+
195
+ // ————————————————— model for predicates (live form data + context)
196
+
197
+ const model = computed(() => ({
198
+ ...(props.context ?? {}),
199
+ ...resolvedForm.value.data,
200
+ }));
201
+
202
+ function resolvePredicate(
203
+ when: MaybeFn<boolean> | undefined,
204
+ fallback: boolean,
205
+ ): boolean {
206
+ if (when === undefined) return fallback;
207
+ return typeof when === "function" ? when(model.value) : when;
208
+ }
209
+
210
+ function isFieldVisible(field: FieldDefinition): boolean {
211
+ const whenOk = resolvePredicate(field.when, true);
212
+ const showOk = field.show ? field.show() : true;
213
+ return whenOk && showOk;
214
+ }
215
+
216
+ // ————————————————— tabs
217
+
218
+ const hasTabs = computed(
219
+ () => !!props.tabs && props.tabs.length > 0,
220
+ );
221
+
222
+ function visibleFieldsFor(tab: FormTab): FieldDefinition[] {
223
+ return tab.fieldKeys
224
+ .map((key) => fieldByKey.value[key])
225
+ .filter((field): field is FieldDefinition => !!field)
226
+ .filter(isFieldVisible);
227
+ }
228
+
229
+ /** A tab with a custom body/before/after slot has content even with no fields. */
230
+ function hasTabSlot(key: string): boolean {
231
+ return !!(
232
+ slots[`tab-content(${key})`] ||
233
+ slots[`tab-before(${key})`] ||
234
+ slots[`tab-after(${key})`]
235
+ );
236
+ }
237
+
238
+ const visibleTabs = computed<FormTab[]>(() => {
239
+ if (!props.tabs) return [];
240
+ return props.tabs.filter((tab) => {
241
+ if (!resolvePredicate(tab.when, true)) return false;
242
+ // Hide tabs with no visible fields, unless the consumer supplies a
243
+ // custom tab-content/before/after slot for that tab.
244
+ return visibleFieldsFor(tab).length > 0 || hasTabSlot(tab.key);
245
+ });
246
+ });
247
+
248
+ const visibleFlatFields = computed<FieldDefinition[]>(() =>
249
+ resolvedFields.value.filter(isFieldVisible),
250
+ );
251
+
252
+ // ————————————————— per-field slot forwarding
253
+
254
+ /** Map a DXField slot name to the parent's keyed slot, when present. */
255
+ function fieldSlotMap(key: string): Record<string, string> {
256
+ const map: Record<string, string> = {};
257
+ const candidates: Array<[string, string]> = [
258
+ ["value", `value(${key})`],
259
+ ["span", `span(${key})`],
260
+ ["info", `info(${key})`],
261
+ ["hint", `hint(${key})`],
262
+ ["repeater-row", `repeater-row(${key})`],
263
+ ];
264
+ for (const [target, source] of candidates) {
265
+ if (slots[source]) map[target] = source;
266
+ }
267
+ return map;
268
+ }
269
+
270
+ // ————————————————— auto-switch to the first error tab
271
+
272
+ /** Keys that currently carry at least one validation error. */
273
+ const erroredKeys = computed<string[]>(() =>
274
+ Object.keys(resolvedForm.value.errors).filter(
275
+ (key) => (resolvedForm.value.errors[key]?.length ?? 0) > 0,
276
+ ),
277
+ );
278
+
279
+ function goToErrorTab(): void {
280
+ if (!hasTabs.value || erroredKeys.value.length === 0) return;
281
+ const tabIndex = visibleTabs.value.findIndex((tab) =>
282
+ tab.fieldKeys.some((key) =>
283
+ erroredKeys.value.some(
284
+ // Match exact keys and nested (repeater) error keys.
285
+ (errorKey) => errorKey === key || errorKey.startsWith(`${key}.`),
286
+ ),
287
+ ),
288
+ );
289
+ if (tabIndex !== -1) activeTab.value = tabIndex;
290
+ }
291
+
292
+ // Watch a primitive derived from the error keys so the effect reliably
293
+ // re-runs when errors are set (deep-watching the stable reactive object
294
+ // reference does not fire on key additions). `immediate` handles errors
295
+ // already present on the form before mount.
296
+ watch(
297
+ () => erroredKeys.value.join("|"),
298
+ (joined) => {
299
+ if (props.autoErrorTab && joined) goToErrorTab();
300
+ },
301
+ { immediate: true },
302
+ );
303
+
304
+ function handleSubmit(): void {
305
+ emit("submit");
306
+ }
307
+
308
+ defineExpose({ goToErrorTab });
44
309
  </script>
@@ -0,0 +1,216 @@
1
+ <template>
2
+ <div class="dx-repeater">
3
+ <div
4
+ v-for="(row, index) in rows"
5
+ :key="rowKey(index)"
6
+ class="dx-repeater-row"
7
+ >
8
+ <!-- Custom row layout escape hatch -->
9
+ <slot
10
+ v-if="$slots.row"
11
+ name="row"
12
+ :row="row"
13
+ :index="index"
14
+ :fields="subFields"
15
+ :remove="() => removeRow(index)"
16
+ :path="rowPath(index)"
17
+ />
18
+
19
+ <!-- Default row: stack each sub-field -->
20
+ <template v-else>
21
+ <div class="dx-repeater-row-header">
22
+ <span class="dx-repeater-row-index">{{ index + 1 }}</span>
23
+ <DButton
24
+ variant="outline-danger"
25
+ size="sm"
26
+ :disabled="rows.length <= minItems"
27
+ @click="removeRow(index)"
28
+ >
29
+ Remove
30
+ </DButton>
31
+ </div>
32
+ <DXField
33
+ v-for="subField in subFields"
34
+ :key="subField.key"
35
+ :field="subField"
36
+ :form="form"
37
+ :model="row"
38
+ :key-path="`${rowPath(index)}.${subField.key}`"
39
+ :error-key="`${rowPath(index)}.${subField.key}`"
40
+ />
41
+ </template>
42
+ </div>
43
+
44
+ <DButton
45
+ variant="outline-primary"
46
+ size="sm"
47
+ :disabled="maxItems !== undefined && rows.length >= maxItems"
48
+ @click="addRow"
49
+ >
50
+ {{ field.addLabel || "Add" }}
51
+ </DButton>
52
+ </div>
53
+ </template>
54
+
55
+ <script setup lang="ts">
56
+ import { computed } from "vue";
57
+ import DButton from "../base/DButton.vue";
58
+ import DXField from "./DXField.vue";
59
+ import type { UseFormReturn } from "../../composables/useForm";
60
+ import type { FieldDefinition, FieldType } from "../../types";
61
+ import { getByPath, setByPath } from "../../utils/objectPath";
62
+
63
+ interface Props {
64
+ /** Form instance owning the repeater array */
65
+ form: UseFormReturn<any>;
66
+
67
+ /** The repeater field definition (provides sub-fields, limits, labels) */
68
+ field: FieldDefinition;
69
+
70
+ /** Dot path into form.data for the array (defaults to field.key) */
71
+ keyPath?: string;
72
+
73
+ /** Model passed to predicates from the parent context */
74
+ model?: any;
75
+ }
76
+
77
+ const props = defineProps<Props>();
78
+
79
+ // The array key is a dot PATH: rows nest into it (`lines.0.price`), and that
80
+ // path is also the validation-error key Laravel returns. So a repeater (or
81
+ // nested sub-field) key is a path segment, not a literal — avoid literal dots
82
+ // in repeater/sub-field keys. (Leaf DXField keys, by contrast, are literal.)
83
+ const arrayPath = computed(() => props.keyPath ?? props.field.key);
84
+ const subFields = computed<FieldDefinition[]>(() => props.field.fields ?? []);
85
+ const minItems = computed(() => props.field.minItems ?? 0);
86
+ const maxItems = computed(() => props.field.maxItems);
87
+
88
+ /**
89
+ * The live rows array. Ensures form.data holds an array at the path so
90
+ * push/splice are reactive even when the form was seeded without one.
91
+ */
92
+ const rows = computed<any[]>(() => {
93
+ const existing = getByPath(props.form.data, arrayPath.value);
94
+ if (Array.isArray(existing)) return existing;
95
+ return [];
96
+ });
97
+
98
+ const rowPath = (index: number): string => `${arrayPath.value}.${index}`;
99
+
100
+ // Stable v-for keys tied to row object identity, so removing a middle row
101
+ // preserves each surviving row's DOM/component state (focus, file inputs)
102
+ // instead of shifting it down with the index. A WeakMap keeps the key off
103
+ // form.data, so it never leaks into the submitted payload. A persisted row
104
+ // id (when present) is preferred as it survives reloads.
105
+ const generatedKeys = new WeakMap<object, number>();
106
+ let nextGeneratedKey = 0;
107
+
108
+ const rowKey = (index: number): string | number => {
109
+ const row = rows.value[index];
110
+ if (row && typeof row === "object") {
111
+ if (row.id !== undefined && row.id !== null) return row.id;
112
+ let key = generatedKeys.get(row);
113
+ if (key === undefined) {
114
+ key = nextGeneratedKey;
115
+ nextGeneratedKey += 1;
116
+ generatedKeys.set(row, key);
117
+ }
118
+ return `gen-${key}`;
119
+ }
120
+ return index;
121
+ };
122
+
123
+ /** Default value for a freshly added row's sub-field. */
124
+ function defaultForType(type: FieldType): any {
125
+ switch (type) {
126
+ case "checkbox":
127
+ return false;
128
+ case "number":
129
+ case "currency":
130
+ case "percentage":
131
+ return 0;
132
+ case "repeater":
133
+ return [];
134
+ case "image":
135
+ case "file":
136
+ return null;
137
+ default:
138
+ return "";
139
+ }
140
+ }
141
+
142
+ /** Deep-clone a sub-field default so object/array defaults aren't shared
143
+ * (by reference) across rows. Uses JSON (not structuredClone) because the
144
+ * default may be a Vue reactive proxy — which structuredClone can't clone —
145
+ * and form defaults are JSON-serializable by construction. */
146
+ function cloneDefault(value: any): any {
147
+ if (value === null || typeof value !== "object") return value;
148
+ return JSON.parse(JSON.stringify(value));
149
+ }
150
+
151
+ function buildRow(): Record<string, any> {
152
+ const row: Record<string, any> = {};
153
+ for (const subField of subFields.value) {
154
+ row[subField.key] =
155
+ subField.default !== undefined
156
+ ? cloneDefault(subField.default)
157
+ : defaultForType(subField.type);
158
+ }
159
+ return row;
160
+ }
161
+
162
+ /**
163
+ * Clear any validation errors keyed under this array (e.g. `lines.1.price`).
164
+ * Row add/remove shifts indices, so stale per-index errors would otherwise
165
+ * attach to the wrong row. They're re-populated on the next submit.
166
+ */
167
+ function clearArrayErrors(): void {
168
+ const prefix = `${arrayPath.value}.`;
169
+ for (const key of Object.keys(props.form.errors)) {
170
+ if (key === arrayPath.value || key.startsWith(prefix)) {
171
+ props.form.clearError(key);
172
+ }
173
+ }
174
+ }
175
+
176
+ function addRow(): void {
177
+ if (maxItems.value !== undefined && rows.value.length >= maxItems.value) {
178
+ return;
179
+ }
180
+ // Ensure the array exists on form.data before pushing (path may be
181
+ // nested for repeaters inside repeater rows).
182
+ const existing = getByPath(props.form.data, arrayPath.value);
183
+ if (!Array.isArray(existing)) {
184
+ setByPath(props.form.data, arrayPath.value, []);
185
+ }
186
+ getByPath(props.form.data, arrayPath.value).push(buildRow());
187
+ clearArrayErrors();
188
+ }
189
+
190
+ function removeRow(index: number): void {
191
+ if (rows.value.length <= minItems.value) return;
192
+ getByPath(props.form.data, arrayPath.value).splice(index, 1);
193
+ clearArrayErrors();
194
+ }
195
+ </script>
196
+
197
+ <style scoped>
198
+ .dx-repeater-row {
199
+ border: 1px solid var(--bs-border-color);
200
+ border-radius: var(--bs-border-radius);
201
+ padding: 1rem;
202
+ margin-bottom: 0.75rem;
203
+ }
204
+
205
+ .dx-repeater-row-header {
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: space-between;
209
+ margin-bottom: 0.75rem;
210
+ }
211
+
212
+ .dx-repeater-row-index {
213
+ font-weight: 600;
214
+ color: var(--bs-secondary-color);
215
+ }
216
+ </style>