@omnitend/dashboard-for-laravel 0.34.0 → 0.35.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.
@@ -163,6 +163,35 @@
163
163
  <div class="fw-semibold">{{ headerLabel(field, label) }}</div>
164
164
  <small v-if="field.hint" class="text-muted d-block" style="font-weight: normal;">{{ field.hint }}</small>
165
165
  </div>
166
+
167
+ <!-- ADDITIVE per-column header content (#99), rendered
168
+ INSIDE DXTable's own header cell rather than as a
169
+ `head(<key>)` override — an override would silently
170
+ drop the sort indicator and the field hint, which is
171
+ exactly why `head(...)` is not forwarded (see
172
+ `isTableSlot`). Placed after the label block and
173
+ BEFORE the sort indicator so a period total sits at
174
+ the top-right of the text area without displacing the
175
+ sort arrows, and lands in the same place whether or
176
+ not the column happens to be sortable. The wrapper is
177
+ only emitted when the slot exists, so a table without
178
+ one renders exactly as before. -->
179
+ <div
180
+ v-if="$slots[`head-end(${field.key})`]"
181
+ class="dx-head-end flex-shrink-0 text-end"
182
+ >
183
+ <!--
184
+ @slot head-end(<fieldKey>) Additive content at the end of a column's own header — a period total above a numeric column, a small badge — keeping DXTable's sort indicator and field hint. Not a `head()` override.
185
+ @binding {object} field The column's field definition.
186
+ @binding {string} label The column's resolved header label.
187
+ -->
188
+ <slot
189
+ :name="`head-end(${field.key})`"
190
+ :field="field"
191
+ :label="headerLabel(field, label)"
192
+ />
193
+ </div>
194
+
166
195
  <div v-if="field.sortable" class="sort-indicator text-muted flex-shrink-0" style="font-size: 0.75rem; line-height: 1.1; display: flex; flex-direction: column; align-items: center;">
167
196
  <span :style="{ opacity: getFieldSortState(field.key) === 'asc' ? 1 : 0.3 }">▲</span>
168
197
  <span :style="{ opacity: getFieldSortState(field.key) === 'desc' ? 1 : 0.3 }">▼</span>
@@ -359,6 +388,22 @@ export interface TableField {
359
388
  filterOptions?: FilterOption[];
360
389
  filterPlaceholder?: string;
361
390
 
391
+ /**
392
+ * For a `select` filter on a **client-side** table: derive the dropdown's
393
+ * options from the distinct values present in the loaded rows. Defaults to
394
+ * `true` — with no `filterOptions` and no server `filterValues` the
395
+ * alternative is an empty dropdown, which can only ever be a bug.
396
+ *
397
+ * Set `false` to opt out (e.g. a column whose raw values are opaque ids you
398
+ * would rather label yourself via `filterOptions`).
399
+ *
400
+ * Explicit `filterOptions` and server-supplied `filterValues` still WIN, in
401
+ * that order. Provider/API and Inertia modes ignore this flag: they only
402
+ * ever hold one page of rows, so there is no complete value set to derive
403
+ * from (see `getFieldFilterOptions`).
404
+ */
405
+ deriveFilterOptions?: boolean;
406
+
362
407
  /**
363
408
  * Fixed width for this column, applied via a `<colgroup>` `<col>`. A number
364
409
  * is treated as pixels (`200` → `200px`); a string is used verbatim
@@ -559,6 +604,19 @@ export interface Props<TItem = any> {
559
604
  * borderless variant — the same header, filter row, table and pagination
560
605
  * rendered directly on the page background (no card border/shadow), for
561
606
  * data-heavy admin index pages that read as busy inside a card.
607
+ *
608
+ * The table renders FLUSH to the card border (rows and stripes reach the
609
+ * edge) while the header and pagination stay padded, which requires the card
610
+ * to clip to its radius (`overflow: hidden`).
611
+ *
612
+ * **Consequence for slot content:** anything you render into a slot that
613
+ * positions itself OUTSIDE the card and does not teleport is clipped at the
614
+ * card edge. Library components are unaffected — `DDropdown` teleports to
615
+ * `<body>` by default, and the column-filter menus position themselves
616
+ * `fixed` — so this only bites a hand-rolled `position: absolute` overlay
617
+ * (a custom popover in a `cell(<key>)` or `#header` slot). Teleport it, or
618
+ * use `:card="false"`. Narrowing the clip to the table region is tracked
619
+ * as #166.
562
620
  */
563
621
  card?: boolean;
564
622
 
@@ -585,8 +643,13 @@ export interface Props<TItem = any> {
585
643
  * **"horizontal"** to match the Omni Tend form convention (a raw modal form
586
644
  * previously fell back to DXForm's vertical default, which looked out of
587
645
  * place next to horizontal page forms). Pass `"vertical"` to opt out.
646
+ *
647
+ * `"auto"` hands the decision to `DXForm`'s container-width observer, which
648
+ * measures the FORM rather than the window — so a modal narrowed by its own
649
+ * breakpoint stacks to vertical even on a wide viewport, where a media query
650
+ * would still report "plenty of room". See `useContainerWidth`.
588
651
  */
589
- editLayout?: 'vertical' | 'horizontal';
652
+ editLayout?: 'vertical' | 'horizontal' | 'auto';
590
653
 
591
654
  /**
592
655
  * Label column width for the edit modal's horizontal layout, forwarded to
@@ -1020,16 +1083,7 @@ const clientSideFilteredItems = computed<T[]>(() => {
1020
1083
  return filterKeys.every(key => {
1021
1084
  // Resolve by the FILTER key, which may differ from the column's key.
1022
1085
  const field = props.fields.find(f => filterKeyFor(f) === key);
1023
- // `filterKey` names the param the SERVER filters on; a local row may
1024
- // not carry it at all (a `customer_name` row filtered on
1025
- // `customer_id`). Match on the filter key when the row has it,
1026
- // otherwise fall back to the column's own value — which is also the
1027
- // one the user can actually see and type against.
1028
- // Both lookups are dot-path aware, so a nested payload filters (#121).
1029
- const itemValue =
1030
- field && hasFieldValue(item, key)
1031
- ? fieldValueOf(item, key)
1032
- : fieldValueOf(item, field?.key ?? key);
1086
+ const itemValue = clientSideFilterValueOf(item, field, key);
1033
1087
 
1034
1088
  const matchesCandidate = (candidate: string): boolean => {
1035
1089
  // The "no value" option (e.g. Unassigned) is the one filter that
@@ -1151,6 +1205,28 @@ const hasFieldValue = (item: unknown, key: string): boolean => {
1151
1205
  return getByPath(item as Record<string, any>, key) !== undefined;
1152
1206
  };
1153
1207
 
1208
+ /*
1209
+ * The value a client-side filter compares against, for one row and one column.
1210
+ *
1211
+ * `filterKey` names the param the SERVER filters on; a local row may not carry
1212
+ * it at all (a `customer_name` row filtered on `customer_id`). Read the filter
1213
+ * key when the row has it, otherwise fall back to the column's own value —
1214
+ * which is also the one the user can actually see and type against. Both
1215
+ * lookups are dot-path aware, so a nested payload filters (#121).
1216
+ *
1217
+ * Extracted so the client-side row filter and the DERIVED select options (see
1218
+ * `derivedFilterOptions`) resolve values through exactly the same rule — a
1219
+ * derived option must never be a value the filter then fails to match.
1220
+ */
1221
+ const clientSideFilterValueOf = (
1222
+ item: unknown,
1223
+ field: TableField | undefined,
1224
+ filterKey: string,
1225
+ ): any =>
1226
+ field && hasFieldValue(item, filterKey)
1227
+ ? fieldValueOf(item, filterKey)
1228
+ : fieldValueOf(item, field?.key ?? filterKey);
1229
+
1154
1230
  /*
1155
1231
  * Dotted-key columns DXTable must render itself, because bvn renders them empty
1156
1232
  * (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
@@ -1235,6 +1311,115 @@ const filterAllLabelFor = (field: TableField): string =>
1235
1311
  const headerLabel = (field: TableField, slotLabel?: string): string =>
1236
1312
  field.label !== undefined ? field.label : (slotLabel || field.key);
1237
1313
 
1314
+ /**
1315
+ * A select filter option's LABEL, honouring the column's own value→label
1316
+ * formatting so the dropdown reads the way the column does (a `formatter`
1317
+ * turning `19.99` into `$19.99`). The option's `value` stays RAW — that is what
1318
+ * the client-side filter compares against (`String(itemValue) === candidate`),
1319
+ * so formatting must never leak into it.
1320
+ *
1321
+ * `formatter` is bootstrap-vue-next's field formatter, `(value, key, item)`, and
1322
+ * its first argument is by contract the value of `field.key` — the DISPLAYED
1323
+ * column value. That matters when `filterKey` names a different property: the
1324
+ * option's value must stay the FILTER value (that is what the row filter
1325
+ * compares), but the formatter must still be handed the display value it was
1326
+ * written against. Passing the filter value here fed a `customer` column's
1327
+ * `(customer) => customer.name` formatter a numeric `customer_id`, which
1328
+ * mislabels at best and throws at worst.
1329
+ */
1330
+ const formattedFilterLabel = (field: TableField, item: unknown): string | null => {
1331
+ if (typeof field.formatter !== 'function') return null;
1332
+ const formatted = field.formatter(fieldValueOf(item, field.key), field.key, item);
1333
+ if (formatted === null || formatted === undefined) return null;
1334
+ return String(formatted);
1335
+ };
1336
+
1337
+ /**
1338
+ * Client-side auto-derived `filter: "select"` options, keyed by field key.
1339
+ *
1340
+ * Without this a client-side select column with no `filterOptions` and no
1341
+ * server `filterValues` rendered an EMPTY dropdown — dead UI that can only be a
1342
+ * bug — so this is on by default (opt out per column with
1343
+ * `deriveFilterOptions: false`).
1344
+ *
1345
+ * It derives from `resolvedItems` (the FULL loaded row set) and deliberately
1346
+ * NOT from `clientSideFilteredItems` or `clientSidePaginatedItems`:
1347
+ * - filtered set → picking a value would collapse the dropdown to that one
1348
+ * value, and the user could never get back to any other one;
1349
+ * - current page → the offered values would silently change as you page.
1350
+ * Being a computed over `props.fields` + `resolvedItems` only, it is also
1351
+ * structurally incapable of depending on the active filters — the cache is the
1352
+ * guard, not just the comment.
1353
+ */
1354
+ const derivedFilterOptions = computed<Record<string, FilterOption[]>>(() => {
1355
+ // Only client-side mode has the complete row set. Provider/API and Inertia
1356
+ // hold one page, so they take their options from the server instead.
1357
+ if (!isClientSideMode.value) return {};
1358
+
1359
+ const rows = resolvedItems.value;
1360
+ if (!rows || rows.length === 0) return {};
1361
+
1362
+ const optionsByFieldKey: Record<string, FilterOption[]> = {};
1363
+
1364
+ for (const field of props.fields) {
1365
+ if (field.filter !== 'select') continue;
1366
+ if (field.deriveFilterOptions === false) continue;
1367
+ // Explicit options win outright (see `getFieldFilterOptions`), so don't
1368
+ // spend a pass over every row producing a list nothing will read.
1369
+ if (field.filterOptions && field.filterOptions.length > 0) continue;
1370
+
1371
+ const filterKey = filterKeyFor(field);
1372
+
1373
+ // Distinct values keyed by their STRING form, because that is exactly
1374
+ // what the client-side select filter compares — so `1` and `"1"` are one
1375
+ // option, and every option offered is one the filter can match. The
1376
+ // first row carrying a value is kept alongside it, as the `item`
1377
+ // argument the column's formatter expects.
1378
+ const firstRowByValue = new Map<string, { rawValue: any; item: unknown }>();
1379
+
1380
+ // Candidates the filter reads as CONTROL values rather than data, so a
1381
+ // row whose value collides with one can never be matched by selecting it
1382
+ // (`handleSelectFilterChange` turns the "all" sentinel back into "no
1383
+ // filter", and the null sentinel is intercepted upstream of the select's
1384
+ // exact-match arm, where it means "has no value"). Offering such an
1385
+ // option would be a dropdown entry that returns zero rows, and the null
1386
+ // one would also duplicate the `filterNullText` entry's value.
1387
+ const reservedCandidates = new Set<string>([FILTER_ALL_VALUE]);
1388
+ if (field.filterNullText) reservedCandidates.add(filterNullValueFor(field));
1389
+
1390
+ for (const item of rows) {
1391
+ const value = clientSideFilterValueOf(item, field, filterKey);
1392
+ // null / undefined / '' are skipped deliberately: "has no value" is
1393
+ // already a first-class, LABELLED option via `filterNullText`, and
1394
+ // an unlabelled blank row in the list would be unusable.
1395
+ if (value === null || value === undefined || value === '') continue;
1396
+ const stringValue = String(value);
1397
+ if (reservedCandidates.has(stringValue)) continue;
1398
+ if (!firstRowByValue.has(stringValue)) {
1399
+ firstRowByValue.set(stringValue, { rawValue: value, item });
1400
+ }
1401
+ }
1402
+
1403
+ const entries = [...firstRowByValue.entries()];
1404
+
1405
+ // Stable, human ordering: numeric when both values are numbers,
1406
+ // otherwise a locale compare — the same rule the client-side sort uses,
1407
+ // with `numeric: true` so "Item 2" precedes "Item 10".
1408
+ entries.sort(([leftText, left], [rightText, right]) =>
1409
+ typeof left.rawValue === 'number' && typeof right.rawValue === 'number'
1410
+ ? left.rawValue - right.rawValue
1411
+ : leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }),
1412
+ );
1413
+
1414
+ optionsByFieldKey[field.key] = entries.map(([stringValue, { item }]) => ({
1415
+ value: stringValue,
1416
+ text: formattedFilterLabel(field, item) ?? stringValue,
1417
+ }));
1418
+ }
1419
+
1420
+ return optionsByFieldKey;
1421
+ });
1422
+
1238
1423
  const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1239
1424
  const options: FilterOption[] = [];
1240
1425
 
@@ -1274,6 +1459,15 @@ const getFieldFilterOptions = (field: TableField): FilterOption[] => {
1274
1459
  return [...options, ...serverValues.map(value => ({ value, text: value }))];
1275
1460
  }
1276
1461
 
1462
+ // Last resort, CLIENT-SIDE only: the distinct values present in the loaded
1463
+ // rows. Precedence is deliberate — explicit `filterOptions`, then the
1464
+ // server's `filterValues`, then what the data itself says. See
1465
+ // `derivedFilterOptions` for why it derives from the unfiltered row set.
1466
+ const derived = derivedFilterOptions.value[field.key];
1467
+ if (derived && derived.length > 0) {
1468
+ return [...options, ...derived];
1469
+ }
1470
+
1277
1471
  // No values to choose from — don't offer a lone "All x" that filters nothing.
1278
1472
  return [];
1279
1473
  };
@@ -1549,7 +1743,12 @@ const shouldShowPerPageSelector = computed(() => {
1549
1743
  return total >= smallestOption;
1550
1744
  });
1551
1745
 
1552
- // Detect which fields need server filter values
1746
+ // Detect which fields need server filter values.
1747
+ //
1748
+ // Client-side derived options (`derivedFilterOptions`) deliberately do NOT
1749
+ // shrink this list: it is only ever read by `internalProvider`, i.e. API mode,
1750
+ // and `isProviderMode` requires `!resolvedClientSide` — the two modes are
1751
+ // mutually exclusive, so a client-side derived column can never appear here.
1553
1752
  const fieldsNeedingFilterValues = computed(() => {
1554
1753
  return props.fields
1555
1754
  .filter(field => field.filter === 'select' && (!field.filterOptions || field.filterOptions.length === 0))
@@ -1956,6 +2155,12 @@ const refresh = () => {
1956
2155
  * - `head(<key>)` — DXTable draws its own column headers (sort indicators,
1957
2156
  * field hints). Forwarding a consumer's would silently drop those.
1958
2157
  * - `thead-top` — DXTable renders the inline filter row there.
2158
+ *
2159
+ * `head-end(<key>)` is DXTable's OWN additive header slot (#99) and is likewise
2160
+ * not forwarded: the inner table has no such slot, and the prefixes below are
2161
+ * matched with `startsWith`, so it must not begin with `cell(`/`foot(` either —
2162
+ * `head-end(` satisfies both. It is also excluded from `tableSlotSignature`
2163
+ * (which filters on `isTableSlot`), so adding one never remounts the table.
1959
2164
  */
1960
2165
  const TABLE_SLOT_PREFIXES = ['cell(', 'foot('];
1961
2166
  const TABLE_SLOT_NAMES = new Set([
@@ -208,8 +208,13 @@ interface Props {
208
208
  pendingAction: 'save' | 'delete' | null;
209
209
  /** Delete endpoint — gates the Delete button. */
210
210
  deleteUrl?: string;
211
- /** Field layout forwarded to the modal's DXForm (defaults handled upstream). */
212
- layout?: 'vertical' | 'horizontal';
211
+ /**
212
+ * Field layout forwarded to the modal's DXForm (defaults handled upstream).
213
+ * Includes `"auto"`, which lets the form stack to vertical when the MODAL is
214
+ * narrow even though the viewport is wide — the container-query case a
215
+ * viewport media query cannot see.
216
+ */
217
+ layout?: 'vertical' | 'horizontal' | 'auto';
213
218
  /** Label column width forwarded to DXForm (horizontal layout). */
214
219
  labelCols?: LabelCols;
215
220
  /** Wrap the modal form in a card (opt-in — off by default). */
@@ -135,7 +135,7 @@ const emit = defineEmits<{
135
135
  // except a single-page gap which shows the page itself (an ellipsis hiding one
136
136
  // page is silly). Client-computed from current + last page so it works in every
137
137
  // DXTable mode (provider / client-side / inertia) — not just where a server
138
- // `links` array exists. Reproduces custard's `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
138
+ // `links` array exists. Reproduces the house style: `1 2 … 8 9 10 [11] 12 13 14 … 44 45`.
139
139
  const BOUNDARY = 2;
140
140
  const AROUND = 3;
141
141
 
@@ -192,9 +192,25 @@ const goToPage = (page: number) => {
192
192
  </script>
193
193
 
194
194
  <style scoped>
195
+ /*
196
+ In `DXTable`'s card mode the card is rendered `no-body` so the table can sit
197
+ flush against the card border, which makes this footer a direct child of
198
+ `.card` — it no longer inherits any `.card-body` padding. Re-instate the inset
199
+ the card body used to give it, so the pager lines up with the padded card
200
+ header above it and keeps a bottom margin inside the (clipped) card.
201
+
202
+ Top padding is deliberately 0: the `mt-3` on the root already separates the
203
+ footer from the table above. `.dx-table-card` is DXTableShell's element and
204
+ needs no scope id — a scoped selector only stamps its LAST compound selector,
205
+ which here is this component's own root.
206
+ */
207
+ .dx-table-card > .dx-table-pagination {
208
+ padding: 0 var(--bs-card-spacer-x) var(--bs-card-spacer-y);
209
+ }
210
+
195
211
  /* Windowed pager (#155). The buttons are DButtons; give the numbered ones a
196
- consistent min-width so they read as an even row of keys (like the house
197
- custard pager), and let the whole row wrap on narrow widths rather than
212
+ consistent min-width so they read as an even row of keys (the house pager
213
+ style), and let the whole row wrap on narrow widths rather than
198
214
  overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
199
215
  scope-id has a deterministic element to attach to. */
200
216
  .dx-pager :deep(.btn) {
@@ -9,9 +9,27 @@
9
9
  - `#header` — the title / action row. In card mode it fills `DCard`'s header;
10
10
  in plain mode it renders above the table with a subtle bottom rule.
11
11
  - default — the table body (spinner/error/table/pagination).
12
+
13
+ Card mode renders `DCard` in **`no-body`** mode so the table sits FLUSH against
14
+ the card border. With a `.card-body` in between, a striped table's rows were
15
+ inset by `--bs-card-spacer-x` (24px) and left a white gutter between the
16
+ stripes and the border. Without it, DXTable's children — error alert, loading
17
+ block, table, pagination — become direct children of `.card`, which is already
18
+ a `flex-direction: column` container, so they still stack in order:
19
+
20
+ .card (overflow: hidden)
21
+ ├─ .card-header (padded by Bootstrap's card-cap padding)
22
+ ├─ .table-responsive ← FLUSH
23
+ │ └─ table
24
+ └─ .dx-table-pagination (pads itself — see DXTablePagination.vue)
25
+
26
+ Bootstrap 5 has no `.card > .table` rounding rules (that was BS4), so the
27
+ clipping is done with `overflow: hidden` on the card: the flush table's square
28
+ top corners then follow the card's radius whether or not a header is present.
29
+ bvn's popups position `fixed` by default, so they are not clipped by it.
12
30
  -->
13
31
  <template>
14
- <DCard v-if="card">
32
+ <DCard v-if="card" no-body class="dx-table-card">
15
33
  <template v-if="$slots.header" #header>
16
34
  <slot name="header" />
17
35
  </template>
@@ -52,3 +70,54 @@ withDefaults(defineProps<Props>(), {
52
70
  border-bottom: 1px solid var(--bs-border-color);
53
71
  }
54
72
  </style>
73
+
74
+ <style>
75
+ /*
76
+ GLOBAL, not scoped, on purpose: these rules target slot content supplied by
77
+ `DXTable`, which is compiled in *DXTable's* scope. A scoped selector here would
78
+ get this component's `data-v-*` appended to its last compound selector and so
79
+ would match nothing — the same silent-inertness trap documented in CLAUDE.md.
80
+ Every selector is namespaced under `.dx-table-card`, a class only this
81
+ component applies, so nothing leaks into a consumer's own cards.
82
+ */
83
+
84
+ /*
85
+ Clip the card to its radius so the flush table's square corners follow the
86
+ border. Kept in this global block with the rest of the card-mode layout: a
87
+ scoped rule WOULD reach DCard's root (the renderer walks the component-root
88
+ chain), but that forwarding is the exact thing CLAUDE.md records as unreliable
89
+ across build pipelines, and a silently-unclipped card is invisible in review.
90
+ The lower specificity also leaves consumers free to override it.
91
+
92
+ KNOWN LIMITATION (#166): this clips the WHOLE card, so a consumer's own
93
+ non-teleported `position: absolute` overlay in a slot is cut off at the card
94
+ edge. dfl's own components are fine — DDropdown teleports to <body>, the
95
+ filter menus are `fixed` — so the residual case is a hand-rolled popover.
96
+ The fix is to move the radius clipping onto the flush table region, which
97
+ `.table-responsive`'s `overflow-x: auto` complicates; see the issue.
98
+ */
99
+ .dx-table-card {
100
+ overflow: hidden;
101
+ }
102
+
103
+ /*
104
+ The error alert is a full-width child of the card now that there is no
105
+ `.card-body`. Give it back the inset the card body used to provide, so it
106
+ doesn't bleed into the card's rounded corners.
107
+ */
108
+ .dx-table-card > .alert {
109
+ margin: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
110
+ }
111
+
112
+ /*
113
+ Nothing may leave a trailing margin at the bottom of the card. bvn already
114
+ zeroes `.table-responsive > .table`, but the responsive wrapper itself carries
115
+ `margin-bottom: 1rem` (and an un-responsive `.table` carries Bootstrap's own),
116
+ which used to be absorbed by the card body and now shows as a white band
117
+ inside the clipped card whenever the table is the last child (no pager). The
118
+ gap ABOVE the pager is unaffected — that margin only drops on the last child.
119
+ */
120
+ .dx-table-card > :last-child {
121
+ margin-bottom: 0;
122
+ }
123
+ </style>