@omnitend/dashboard-for-laravel 0.16.4 → 0.18.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.
@@ -5,7 +5,157 @@
5
5
  field's `minItems`/`maxItems` limits.
6
6
  -->
7
7
  <template>
8
- <div class="dx-repeater">
8
+ <!-- `repeaterLayout: "table"` is a PREFERENCE, not a guarantee: the table
9
+ needs real horizontal room for its columns, and the same viewport
10
+ width can put this repeater in a wide standalone form or a narrow
11
+ sidebar column — a viewport breakpoint can't tell those apart. A
12
+ ResizeObserver on THIS OUTER wrapper measures the space actually
13
+ available and falls back to the cards layout when it's not enough,
14
+ scaling to this repeater's own column count (see
15
+ `neededTableWidth`/`showTable` below). This outer wrapper is never
16
+ hidden (an element measures 0×0 once `display:none`, which would
17
+ leave a hidden table with no way to detect regained space and
18
+ un-hide itself) — but its two children below ARE mutually exclusive
19
+ via `v-if`/`v-else`, not `v-show`: only ever ONE of table/cards is
20
+ actually mounted, so a field with side effects (e.g. an async
21
+ `optionsLoader`) doesn't run twice forever just because table mode
22
+ is configured. -->
23
+ <div v-if="wantsTableLayout" ref="containerRef" class="dx-repeater-container">
24
+ <div v-if="showTable" class="dx-repeater-table-wrapper">
25
+ <table class="dx-repeater-table table">
26
+ <thead>
27
+ <tr>
28
+ <th v-for="subField in subFields" :key="subField.key">
29
+ {{ headerLabel(subField) }}
30
+ </th>
31
+ <th class="dx-repeater-table-remove-col"></th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <tr
36
+ v-for="(entry, position) in visibleRows"
37
+ :key="rowKey(entry.row, position)"
38
+ >
39
+ <!--
40
+ @slot Custom layout for a single repeater row, replacing the default sub-field stack (including the built-in Remove control — call `remove()` yourself). In table layout, rendered as the `<tr>`'s children: supply one `<td>` per sub-field plus a trailing `<td>` to match the header's column count (the header always includes an empty column for the built-in remove button).
41
+ @binding {object} row The current row's data object.
42
+ @binding {number} index The row's zero-based position among visible rows.
43
+ @binding {FieldDefinition[]} fields The sub-field definitions for the row.
44
+ @binding {function} remove Removes this row (respects `minItems`; soft-deletes instead of splicing when `field.softDeleteKey` is set and the row has an `id`).
45
+ @binding {string} path The dot path into `form.data` for this row (e.g. `lines.0`).
46
+ -->
47
+ <slot
48
+ v-if="$slots.row"
49
+ name="row"
50
+ :row="entry.row"
51
+ :index="position"
52
+ :fields="subFields"
53
+ :remove="() => removeRow(entry.index)"
54
+ :path="rowPath(entry.index)"
55
+ />
56
+
57
+ <template v-else>
58
+ <td v-for="subField in subFields" :key="subField.key">
59
+ <DXField
60
+ :field="subField"
61
+ :form="form"
62
+ :model="entry.row"
63
+ :key-path="`${rowPath(entry.index)}.${subField.key}`"
64
+ :error-key="`${rowPath(entry.index)}.${subField.key}`"
65
+ hide-label
66
+ />
67
+ </td>
68
+ <td class="dx-repeater-table-remove-col">
69
+ <DButton
70
+ variant="outline-danger"
71
+ class="dx-repeater-table-remove-btn"
72
+ :disabled="visibleRows.length <= minItems"
73
+ :aria-label="`Remove row ${position + 1}`"
74
+ @click="removeRow(entry.index)"
75
+ >
76
+ <i-lucide-trash-2 aria-hidden="true" />
77
+ </DButton>
78
+ </td>
79
+ </template>
80
+ </tr>
81
+ </tbody>
82
+ </table>
83
+
84
+ <DButton
85
+ variant="outline-primary"
86
+ size="sm"
87
+ :disabled="maxItems !== undefined && visibleRows.length >= maxItems"
88
+ @click="addRow"
89
+ >
90
+ {{ field.addLabel || "Add" }}
91
+ </DButton>
92
+ </div>
93
+
94
+ <!-- Cards fallback: identical to the default cards markup below,
95
+ shown instead of the table when there isn't enough room for it.
96
+ `v-else` (not `v-show`) — only the OUTER wrapper above needs to
97
+ stay mounted for measurement; unmounting whichever of these two
98
+ isn't current avoids permanently double-running every DXField
99
+ in both layouts at once (double async optionsLoader fetches,
100
+ double watchers, double everything else a field might do). -->
101
+ <div v-else class="dx-repeater">
102
+ <div
103
+ v-for="(entry, position) in visibleRows"
104
+ :key="rowKey(entry.row, position)"
105
+ class="dx-repeater-row"
106
+ >
107
+ <slot
108
+ v-if="$slots.row"
109
+ name="row"
110
+ :row="entry.row"
111
+ :index="position"
112
+ :fields="subFields"
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">
9
159
  <div
10
160
  v-for="(entry, position) in visibleRows"
11
161
  :key="rowKey(entry.row, position)"
@@ -32,7 +182,7 @@
32
182
  <!-- Default row: stack each sub-field -->
33
183
  <template v-else>
34
184
  <div class="dx-repeater-row-header">
35
- <span class="dx-repeater-row-index">{{ position + 1 }}</span>
185
+ <span v-if="field.showRowIndex" class="dx-repeater-row-index">{{ position + 1 }}</span>
36
186
  <DButton
37
187
  variant="outline-danger"
38
188
  size="sm"
@@ -66,7 +216,7 @@
66
216
  </template>
67
217
 
68
218
  <script setup lang="ts">
69
- import { computed } from "vue";
219
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
70
220
  import DButton from "../base/DButton.vue";
71
221
  import DXField from "./DXField.vue";
72
222
  import type { UseFormReturn } from "../../composables/useForm";
@@ -99,6 +249,79 @@ const minItems = computed(() => props.field.minItems ?? 0);
99
249
  const maxItems = computed(() => props.field.maxItems);
100
250
  const softDeleteKey = computed(() => props.field.softDeleteKey);
101
251
 
252
+ /** Column header text for a sub-field. No specific row context exists for a
253
+ * header, so a function-valued label resolves against the outer model. */
254
+ function headerLabel(subField: FieldDefinition): string {
255
+ const label = subField.label;
256
+ if (typeof label === "function") return label(props.model ?? {});
257
+ return label || subField.key;
258
+ }
259
+
260
+ // ————————————————— table/cards responsive fallback (#68 follow-up)
261
+
262
+ const wantsTableLayout = computed(() => props.field.repeaterLayout === "table");
263
+
264
+ const containerRef = ref<HTMLElement | null>(null);
265
+ const containerWidth = ref<number | null>(null);
266
+
267
+ // Heuristic minimum width for the table to stay legible: per-sub-field
268
+ // column widths (not a pixel-measured fit, just a reasonable estimate —
269
+ // `currency`/`percentage` get extra room for their `£`/`%` affix, which
270
+ // otherwise crowds out the number itself) plus the trailing icon-only
271
+ // remove column. Scales with this repeater's own column count/types,
272
+ // unlike a single fixed viewport breakpoint.
273
+ const DEFAULT_COLUMN_WIDTH = 130;
274
+ const AFFIX_COLUMN_WIDTH = 160;
275
+ const REMOVE_COLUMN_WIDTH = 70;
276
+
277
+ function columnWidthFor(subField: FieldDefinition): number {
278
+ if (subField.type === "currency" || subField.type === "percentage") {
279
+ return AFFIX_COLUMN_WIDTH;
280
+ }
281
+ return DEFAULT_COLUMN_WIDTH;
282
+ }
283
+
284
+ const neededTableWidth = computed(
285
+ () =>
286
+ subFields.value.reduce((total, subField) => total + columnWidthFor(subField), 0) +
287
+ REMOVE_COLUMN_WIDTH,
288
+ );
289
+
290
+ // `null` (not yet measured) defaults to showing the table — the configured
291
+ // preference — rather than flashing cards first. Resolves as soon as the
292
+ // watcher below fires (see its comment for why that isn't simply onMounted).
293
+ const showTable = computed(
294
+ () => containerWidth.value === null || containerWidth.value >= neededTableWidth.value,
295
+ );
296
+
297
+ let resizeObserver: ResizeObserver | null = null;
298
+
299
+ // A `watch` on the ref itself, not `onMounted`: DXRepeater is loaded via
300
+ // `defineAsyncComponent` (DXField imports it lazily to break a circular
301
+ // import), and for an async-resolved component whose root element also
302
+ // carries `v-if`, the template ref is not always populated yet by the time
303
+ // `onMounted` fires — observed live as `containerRef.value === null` inside
304
+ // `onMounted` despite the element existing in the DOM at that point. `watch`
305
+ // instead reacts whenever the ref actually receives (or loses, e.g. if
306
+ // `wantsTableLayout` ever toggles false) its element, regardless of when
307
+ // that happens relative to lifecycle hooks.
308
+ watch(containerRef, (el) => {
309
+ resizeObserver?.disconnect();
310
+ resizeObserver = null;
311
+ if (!el || typeof ResizeObserver === "undefined") return;
312
+ containerWidth.value = el.getBoundingClientRect().width;
313
+ resizeObserver = new ResizeObserver((entries) => {
314
+ const entry = entries[0];
315
+ if (entry) containerWidth.value = entry.contentRect.width;
316
+ });
317
+ resizeObserver.observe(el);
318
+ });
319
+
320
+ onBeforeUnmount(() => {
321
+ resizeObserver?.disconnect();
322
+ resizeObserver = null;
323
+ });
324
+
102
325
  function isSoftDeleted(row: any): boolean {
103
326
  return Boolean(
104
327
  softDeleteKey.value &&
@@ -254,12 +477,55 @@ function removeRow(index: number): void {
254
477
  .dx-repeater-row-header {
255
478
  display: flex;
256
479
  align-items: center;
257
- justify-content: space-between;
258
480
  margin-bottom: 0.75rem;
259
481
  }
260
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
+
261
491
  .dx-repeater-row-index {
262
492
  font-weight: 600;
263
493
  color: var(--bs-secondary-color);
264
494
  }
495
+
496
+ .dx-repeater-table td {
497
+ vertical-align: middle;
498
+ }
499
+
500
+ /* Sub-fields render with their default mb-3 (matching the standalone-field
501
+ default), which is unwanted spacing inside an already-compact table cell —
502
+ and leaves the field vertically off-centre against the trailing remove
503
+ button, since that residual margin-bottom is inside the td's own
504
+ vertical-align:middle box. Bootstrap's spacing utilities are `!important`
505
+ by design (same as the toast-variant override elsewhere in this project),
506
+ so overriding one requires `!important` too — a plain declaration silently
507
+ loses and the margin survives. */
508
+ .dx-repeater-table td :deep(.mb-3) {
509
+ margin-bottom: 0 !important;
510
+ }
511
+
512
+ .dx-repeater-table-remove-col {
513
+ width: 1%;
514
+ white-space: nowrap;
515
+ text-align: center;
516
+ }
517
+
518
+ /* Match the row's text-input height (same var the switch field uses).
519
+ `--dx-input-height`'s `em` term resolves against the CONSUMING element's
520
+ own font-size, not the input's — so this only lines up if the button's
521
+ font-size equals .form-control's (both derive from Bootstrap's base
522
+ font-size var, but a `size="sm"` DButton sets a smaller one). The button
523
+ is deliberately NOT `size="sm"` here for that reason; min-height still
524
+ keeps it visually compact regardless. */
525
+ .dx-repeater-table-remove-btn {
526
+ display: inline-flex;
527
+ align-items: center;
528
+ justify-content: center;
529
+ min-height: var(--dx-input-height, calc(1.5em + 0.75rem + 2px));
530
+ }
265
531
  </style>
@@ -599,10 +599,12 @@
599
599
  <DButton
600
600
  v-if="deleteUrl && !isCreateMode"
601
601
  variant="danger"
602
+ :loading="pendingAction === 'delete'"
603
+ loading-text="Deleting..."
602
604
  :disabled="editForm?.processing || editLoading"
603
605
  @click="handleDelete"
604
606
  >
605
- {{ pendingAction === 'delete' ? 'Deleting...' : 'Delete' }}
607
+ Delete
606
608
  </DButton>
607
609
  </div>
608
610
  <div class="d-flex gap-2">
@@ -611,15 +613,12 @@
611
613
  </DButton>
612
614
  <DButton
613
615
  variant="primary"
616
+ :loading="pendingAction === 'save'"
617
+ :loading-text="isCreateMode ? 'Creating...' : 'Saving...'"
614
618
  :disabled="editForm?.processing || editLoading"
615
619
  @click="handleEditSave"
616
620
  >
617
- <template v-if="isCreateMode">
618
- {{ pendingAction === 'save' ? 'Creating...' : 'Create' }}
619
- </template>
620
- <template v-else>
621
- {{ pendingAction === 'save' ? 'Saving...' : 'Save Changes' }}
622
- </template>
621
+ {{ isCreateMode ? 'Create' : 'Save Changes' }}
623
622
  </DButton>
624
623
  </div>
625
624
  </div>
@@ -61,6 +61,23 @@ export interface FieldOption extends Record<string, unknown> {
61
61
  */
62
62
  export type OptionsLoader = (model: any) => Promise<FieldOption[]>;
63
63
 
64
+ /**
65
+ * Label column width for horizontal layout, mirroring Bootstrap Vue Next's
66
+ * `BFormGroup` `labelCols`/`labelCols*` props: either a single width applied
67
+ * at all breakpoints, or a per-breakpoint object (omitted breakpoints fall
68
+ * back to BFormGroup's own defaults, which is how "collapse to vertical
69
+ * below a breakpoint" comes for free — e.g. `{ md: 3 }` stacks below `md`).
70
+ *
71
+ * Deliberately excludes `boolean` (which BFormGroup's own `labelCols` prop
72
+ * accepts): Vue's type-based `defineProps` auto-defaults an omitted prop to
73
+ * `false` (not `undefined`) whenever its declared type includes `boolean`,
74
+ * which would silently break the `field.labelCols ?? formLabelCols ?? 3`
75
+ * fallback chain used to resolve this value.
76
+ */
77
+ export type LabelCols =
78
+ | number
79
+ | { sm?: number; md?: number; lg?: number; xl?: number };
80
+
64
81
  /**
65
82
  * Field definition shared by every form renderer.
66
83
  *
@@ -172,10 +189,24 @@ export interface FieldDefinition {
172
189
 
173
190
  /**
174
191
  * Render the field full-width with no label wrapper, delegating its
175
- * content to the `#span(<key>)` slot. Useful for custom blocks.
192
+ * content to the `#span(<key>)` slot. Useful for custom blocks. Also
193
+ * doubles as the "fill both columns" escape hatch in horizontal layout —
194
+ * a span field always bypasses the label/content column split.
176
195
  */
177
196
  span?: boolean;
178
197
 
198
+ /**
199
+ * Per-field override of DXForm's `layout` prop ("vertical" | "horizontal").
200
+ * Falls back to the form-level `layout` when omitted.
201
+ */
202
+ layout?: "vertical" | "horizontal";
203
+
204
+ /**
205
+ * Per-field override of DXForm's `labelCols` prop, for horizontal
206
+ * layout. Falls back to the form-level `labelCols` when omitted.
207
+ */
208
+ labelCols?: LabelCols;
209
+
179
210
  /**
180
211
  * Component rendered for `type: "component"` fields. Receives
181
212
  * `modelValue`, `field`, `model` props and emits `update:modelValue`.
@@ -199,6 +230,26 @@ export interface FieldDefinition {
199
230
  minItems?: number;
200
231
  maxItems?: number;
201
232
 
233
+ /**
234
+ * For `repeater` fields: `"cards"` (default) stacks each row as its own
235
+ * card with sub-fields listed vertically — best for rows with several
236
+ * or complex sub-fields. `"table"` renders one row per item with
237
+ * sub-fields as columns — far more compact for simple child-row lists
238
+ * (e.g. 2-3 short sub-fields). Named `repeaterLayout` (not `layout`) to
239
+ * avoid colliding with the unrelated form-level `layout` prop
240
+ * ("vertical" | "horizontal") every field type accepts.
241
+ */
242
+ repeaterLayout?: "cards" | "table";
243
+
244
+ /**
245
+ * For `repeater` fields in `cards` layout: show each row's 1-based
246
+ * position (e.g. "1", "2") next to its Remove button. Off by default —
247
+ * only meaningful once a consumer explicitly wants it (e.g. rows the
248
+ * user thinks of as numbered/ordered); most repeaters don't need it.
249
+ * Not applicable to `table` layout, which has no equivalent column.
250
+ */
251
+ showRowIndex?: boolean;
252
+
202
253
  /**
203
254
  * For `repeater` fields backed by an upsert-children API contract (rows
204
255
  * carrying a persisted `id` are only deleted server-side when submitted