@omnitend/dashboard-for-laravel 0.26.0 → 0.27.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,5 +1,5 @@
1
1
  {
2
- "generated": "2026-07-13T10:49:19.086Z",
2
+ "generated": "2026-07-18T05:05:36.998Z",
3
3
  "package": {
4
4
  "name": "@omnitend/dashboard-for-laravel",
5
5
  "version": "0.26.0"
@@ -1,7 +1,7 @@
1
1
  # Documentation Map
2
2
 
3
3
  > Auto-generated hierarchical overview of all documentation
4
- > Last updated: 2026-07-13T10:49:19.148Z
4
+ > Last updated: 2026-07-18T05:05:37.057Z
5
5
 
6
6
  This file provides a complete map of all available documentation for AI agents and developers.
7
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Vue 3 dashboard components for Laravel with Bootstrap Vue Next",
5
5
  "type": "module",
6
6
  "main": "./dist/dashboard-for-laravel.umd.cjs",
@@ -7,19 +7,31 @@
7
7
  // The Sass module for `color.mix()` etc. `@use` must precede all other
8
8
  // statements (variable overrides, `@import`), so it stays at the very top.
9
9
  @use "sass:color";
10
+ @use "sass:map";
10
11
 
11
12
  // ----------------------------------------------------------------------------
12
13
  // Colors
13
14
  // ----------------------------------------------------------------------------
14
15
 
15
- $primary: #4f46e5; // Indigo-600 (8.6:1 with white)
16
- $secondary: #475569; // Slate-600 (7.0:1 with white) - darkened for better contrast
17
- $success: #10b981; // Emerald-500 (6.7:1 with white)
18
- $danger: #dc2626; // Red-600 (7.2:1 with white) - darkened for better contrast
19
- $warning: #f59e0b; // Amber-500 (7.7:1 with white)
20
- $info: #2563eb; // Blue-600 (7.3:1 with white)
16
+ // Base $theme-colors carry each hue's READABLE-ON-LIGHT identity (its "emphasis"
17
+ // shade) — this is what `.text-*`, `.link-*`, `.border-*` and outline buttons
18
+ // use, so every one must clear AA on white. Bold solid FILLS (primary/danger
19
+ // buttons) and SOFT tints (soft buttons, badges, alerts) are applied as explicit
20
+ // overrides after the Bootstrap import, from the $dx-variants map. See
21
+ // plans/2026-07-18-semantic-colour-system.md.
22
+ $primary: #151e2d; // Omni Tend brand navy (16.73:1 on white)
23
+ $secondary: #475569; // Slate-600 (7.58:1 on white)
24
+ $success: #4d7c0f; // Lime-700 emphasis (4.99:1 on white); soft/vivid tints in $dx-variants
25
+ $danger: #dc2626; // Red-600 (4.83:1 on white)
26
+ $warning: #b45309; // Amber-700 emphasis (5.02:1 on white)
27
+ $info: #2563eb; // Blue-600 (5.17:1 on white)
21
28
  $light: #f8fafc; // Slate-50 (uses dark text)
22
- $dark: #0f172a; // Slate-900 (uses white text)
29
+ $dark: #151e2d; // Brand navy — sidebar/high-contrast surfaces (was Slate-900 #0f172a)
30
+
31
+ // Links are the info-blue, independent of the near-black brand primary (a navy
32
+ // link would read as bold text, not a link).
33
+ $link-color: #2563eb;
34
+ $link-shade-percentage: 15%;
23
35
 
24
36
  // ----------------------------------------------------------------------------
25
37
  // Contrast Settings
@@ -154,6 +166,21 @@ $badge-padding-y: 0.25rem;
154
166
  $badge-padding-x: 0.625rem;
155
167
  $badge-font-weight: 500;
156
168
 
169
+ // The ONE source of truth for the semantic colour system. Each variant carries
170
+ // its SOLID fill + same-hue text (primary/danger buttons, solid fills), its SOFT
171
+ // tint pair (soft buttons, badges, alerts, status), its EMPHASIS shade (outline
172
+ // buttons, coloured links, .text-*), and whether its button is solid or soft.
173
+ // The overrides after the Bootstrap import loop over this map. All pairs are
174
+ // WCAG AA verified. See plans/2026-07-18-semantic-colour-system.md.
175
+ $dx-variants: (
176
+ "primary": (solid-bg: #151e2d, solid-text: #e9f0f8, soft-bg: #e9f0f8, soft-text: #151e2d, emphasis: #151e2d, button: "solid"),
177
+ "secondary": (solid-bg: #475569, solid-text: #e6ebf2, soft-bg: #e6ebf2, soft-text: #29374a, emphasis: #475569, button: "soft"),
178
+ "success": (solid-bg: #84cc16, solid-text: #203b0e, soft-bg: #cdf9b2, soft-text: #203b0e, emphasis: #4d7c0f, button: "soft"),
179
+ "danger": (solid-bg: #dc2626, solid-text: #ffffff, soft-bg: #f5dff1, soft-text: #59194a, emphasis: #dc2626, button: "solid"),
180
+ "warning": (solid-bg: #f59e0b, solid-text: #512d05, soft-bg: #fce5c4, soft-text: #512d05, emphasis: #b45309, button: "soft"),
181
+ "info": (solid-bg: #2563eb, solid-text: #eef4ff, soft-bg: #deebff, soft-text: #12376c, emphasis: #2563eb, button: "soft"),
182
+ );
183
+
157
184
  // ----------------------------------------------------------------------------
158
185
  // Dashboard
159
186
  // ----------------------------------------------------------------------------
@@ -420,6 +447,112 @@ $bootstrap-icons-font-src: url("bootstrap-icons/font/fonts/bootstrap-icons.woff2
420
447
  }
421
448
  }
422
449
 
450
+ // ----------------------------------------------------------------------------
451
+ // Semantic colour system (soft-first)
452
+ // ----------------------------------------------------------------------------
453
+ // One loop over $dx-variants applies the whole system on top of Bootstrap:
454
+ //
455
+ // - Buttons: primary/danger are SOLID (fill from the base colour; we only
456
+ // override the label to the same-hue tint, letting Bootstrap shade hover/
457
+ // active). secondary/success/warning/info are SOFT (light tint bg + dark
458
+ // text, fully overridden incl. hover/active).
459
+ // - Outline buttons: the emphasis shade for border/label, the soft tint on hover.
460
+ // - Badges: `.badge.text-bg-*` re-tinted soft — needs `!important` because
461
+ // Bootstrap's `.text-bg-*` helper sets its colours `!important` by design.
462
+ // - Alerts: the soft tint.
463
+ // - Links/text: `.link-*` and `.text-*` take the emphasis shade so they read
464
+ // on white (the base colours already carry emphasis for most variants; this
465
+ // pins the two — success/warning — whose base differs).
466
+ @each $name, $v in $dx-variants {
467
+ $solid-bg: map.get($v, "solid-bg");
468
+ $solid-text: map.get($v, "solid-text");
469
+ $soft-bg: map.get($v, "soft-bg");
470
+ $soft-text: map.get($v, "soft-text");
471
+ $emphasis: map.get($v, "emphasis");
472
+ $is-solid: map.get($v, "button") == "solid";
473
+
474
+ @if $is-solid {
475
+ // Solid button: keep Bootstrap's fill + hover/active shading (from the base
476
+ // colour), override only the LABEL to the same-hue tint.
477
+ .btn-#{$name} {
478
+ --bs-btn-color: #{$solid-text};
479
+ --bs-btn-hover-color: #{$solid-text};
480
+ --bs-btn-active-color: #{$solid-text};
481
+ --bs-btn-disabled-color: #{$solid-text};
482
+ }
483
+ } @else {
484
+ // Soft button: light tint bg + dark same-hue label; darken the tint slightly
485
+ // on hover/active for feedback.
486
+ .btn-#{$name} {
487
+ --bs-btn-color: #{$soft-text};
488
+ --bs-btn-bg: #{$soft-bg};
489
+ --bs-btn-border-color: #{$soft-bg};
490
+ --bs-btn-hover-color: #{$soft-text};
491
+ --bs-btn-hover-bg: #{color.scale($soft-bg, $lightness: -6%)};
492
+ --bs-btn-hover-border-color: #{color.scale($soft-bg, $lightness: -6%)};
493
+ --bs-btn-active-color: #{$soft-text};
494
+ --bs-btn-active-bg: #{color.scale($soft-bg, $lightness: -10%)};
495
+ --bs-btn-active-border-color: #{color.scale($soft-bg, $lightness: -10%)};
496
+ --bs-btn-disabled-color: #{$soft-text};
497
+ --bs-btn-disabled-bg: #{$soft-bg};
498
+ --bs-btn-disabled-border-color: #{$soft-bg};
499
+ }
500
+ }
501
+
502
+ // Outline button: emphasis border/label, fills to the soft tint on hover so it
503
+ // stays calm (not a sudden saturated block).
504
+ .btn-outline-#{$name} {
505
+ --bs-btn-color: #{$emphasis};
506
+ --bs-btn-border-color: #{$emphasis};
507
+ --bs-btn-hover-color: #{$soft-text};
508
+ --bs-btn-hover-bg: #{$soft-bg};
509
+ --bs-btn-hover-border-color: #{$emphasis};
510
+ --bs-btn-active-color: #{$soft-text};
511
+ --bs-btn-active-bg: #{$soft-bg};
512
+ --bs-btn-active-border-color: #{$emphasis};
513
+ }
514
+
515
+ // Badge — soft tint. `!important` beats Bootstrap's `.text-bg-*` helper.
516
+ .badge.text-bg-#{$name} {
517
+ background-color: #{$soft-bg} !important;
518
+ color: #{$soft-text} !important;
519
+ }
520
+
521
+ // Alert — soft tint, with the emphasis shade as the accent border.
522
+ .alert-#{$name} {
523
+ --bs-alert-bg: #{$soft-bg};
524
+ --bs-alert-color: #{$soft-text};
525
+ --bs-alert-border-color: #{color.scale($soft-bg, $lightness: -8%)};
526
+ --bs-alert-link-color: #{$emphasis};
527
+ }
528
+
529
+ // `.text-#{$name}` and `.link-#{$name}` need no override: each variant's base
530
+ // colour IS its emphasis shade, so Bootstrap already renders them legible.
531
+ }
532
+
533
+ // primary is near-black navy, so Bootstrap's darken-on-hover is imperceptible —
534
+ // the main action button would have no visible hover feedback. Lighten instead.
535
+ // (danger's red darkens visibly, so the generic solid path is fine for it.)
536
+ .btn-primary {
537
+ --bs-btn-hover-bg: #{color.scale($primary, $lightness: 20%)};
538
+ --bs-btn-hover-border-color: #{color.scale($primary, $lightness: 20%)};
539
+ --bs-btn-active-bg: #{color.scale($primary, $lightness: 12%)};
540
+ --bs-btn-active-border-color: #{color.scale($primary, $lightness: 12%)};
541
+ }
542
+
543
+ // Tertiary "ghost" button: a `link`-variant button restyled to read as a quiet
544
+ // action, not a hyperlink — body colour, no underline, faint hover surface.
545
+ .btn-link {
546
+ --bs-btn-color: var(--bs-body-color);
547
+ --bs-btn-hover-color: var(--bs-body-color);
548
+ --bs-btn-active-color: var(--bs-body-color);
549
+ text-decoration: none;
550
+
551
+ &:hover {
552
+ background-color: var(--bs-light);
553
+ }
554
+ }
555
+
423
556
  // ----------------------------------------------------------------------------
424
557
  // Autocomplete
425
558
  // ----------------------------------------------------------------------------
@@ -137,6 +137,17 @@
137
137
  </div>
138
138
  </template>
139
139
 
140
+ <!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
141
+ bootstrap-vue-next, in every payload shape, so DXTable resolves
142
+ those columns itself (#121). A consumer's own cell(<key>) slot
143
+ wins — `dottedCellFields` excludes it, and the forwarding below
144
+ would otherwise declare the same slot name twice. -->
145
+ <template
146
+ v-for="field in dottedCellFields($slots)"
147
+ :key="`dotted-cell-${field.key}`"
148
+ #[`cell(${field.key})`]="{ item }"
149
+ >{{ fieldValueOf(item, field.key) }}</template>
150
+
140
151
  <!-- Forward every slot the inner table understands: cell(<key>),
141
152
  foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
142
153
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
@@ -245,6 +256,17 @@
245
256
  </div>
246
257
  </template>
247
258
 
259
+ <!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
260
+ bootstrap-vue-next, in every payload shape, so DXTable resolves
261
+ those columns itself (#121). A consumer's own cell(<key>) slot
262
+ wins — `dottedCellFields` excludes it, and the forwarding below
263
+ would otherwise declare the same slot name twice. -->
264
+ <template
265
+ v-for="field in dottedCellFields($slots)"
266
+ :key="`dotted-cell-${field.key}`"
267
+ #[`cell(${field.key})`]="{ item }"
268
+ >{{ fieldValueOf(item, field.key) }}</template>
269
+
248
270
  <!-- Forward every slot the inner table understands: cell(<key>),
249
271
  foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
250
272
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
@@ -354,6 +376,17 @@
354
376
  </div>
355
377
  </template>
356
378
 
379
+ <!-- A dotted field key (`paid_by.card`) renders as an EMPTY cell in
380
+ bootstrap-vue-next, in every payload shape, so DXTable resolves
381
+ those columns itself (#121). A consumer's own cell(<key>) slot
382
+ wins — `dottedCellFields` excludes it, and the forwarding below
383
+ would otherwise declare the same slot name twice. -->
384
+ <template
385
+ v-for="field in dottedCellFields($slots)"
386
+ :key="`dotted-cell-${field.key}`"
387
+ #[`cell(${field.key})`]="{ item }"
388
+ >{{ fieldValueOf(item, field.key) }}</template>
389
+
357
390
  <!-- Forward every slot the inner table understands: cell(<key>),
358
391
  foot(<key>), custom-foot, empty, empty-filtered, row-expansion,
359
392
  table-busy/caption/colgroup, thead-sub, top-row, bottom-row.
@@ -677,6 +710,7 @@ import axios from "axios";
677
710
  import pluralize from "pluralize";
678
711
  import { useToast } from "../../composables/useToast";
679
712
  import { useForm } from "../../composables/useForm";
713
+ import { getByPath } from "../../utils/objectPath";
680
714
  import DContainer from "../base/DContainer.vue";
681
715
  import DRow from "../base/DRow.vue";
682
716
  import DCol from "../base/DCol.vue";
@@ -1222,10 +1256,11 @@ const clientSideFilteredItems = computed<T[]>(() => {
1222
1256
  // `customer_id`). Match on the filter key when the row has it,
1223
1257
  // otherwise fall back to the column's own value — which is also the
1224
1258
  // one the user can actually see and type against.
1259
+ // Both lookups are dot-path aware, so a nested payload filters (#121).
1225
1260
  const itemValue =
1226
- field && key in (item as any)
1227
- ? (item as any)[key]
1228
- : (item as any)[field?.key ?? key];
1261
+ field && hasFieldValue(item, key)
1262
+ ? fieldValueOf(item, key)
1263
+ : fieldValueOf(item, field?.key ?? key);
1229
1264
 
1230
1265
  // The "no value" option (e.g. Unassigned) is the one filter that
1231
1266
  // MATCHES an absent value rather than failing on it.
@@ -1279,13 +1314,16 @@ const clientSideSortedItems = computed<T[]>(() => {
1279
1314
  const direction = order === 'desc' ? -1 : 1;
1280
1315
 
1281
1316
  return items.sort((a, b) => {
1282
- const aVal = (a as any)[key];
1283
- const bVal = (b as any)[key];
1317
+ // Dot-path aware, so `paid_by.card` sorts a nested payload (#121).
1318
+ const aVal = fieldValueOf(a, key);
1319
+ const bVal = fieldValueOf(b, key);
1284
1320
 
1285
1321
  // Handle null/undefined
1286
- if (aVal == null && bVal == null) return 0;
1287
- if (aVal == null) return direction;
1288
- if (bVal == null) return -direction;
1322
+ const aEmpty = aVal === null || aVal === undefined;
1323
+ const bEmpty = bVal === null || bVal === undefined;
1324
+ if (aEmpty && bEmpty) return 0;
1325
+ if (aEmpty) return direction;
1326
+ if (bEmpty) return -direction;
1289
1327
 
1290
1328
  // Numeric comparison
1291
1329
  if (typeof aVal === 'number' && typeof bVal === 'number') {
@@ -1300,6 +1338,54 @@ const clientSideSortedItems = computed<T[]>(() => {
1300
1338
  // API mode filter values (extracted from responses)
1301
1339
  const apiFilterValues = ref<Record<string, string[]>>({});
1302
1340
 
1341
+ /*
1342
+ * Resolving a field's value from a row, dot-path aware (#121).
1343
+ *
1344
+ * A key containing a dot (`paid_by.card`) was unusable. Two layers disagreed:
1345
+ * bootstrap-vue-next's `mapItem` UN-flattens any item key containing a dot
1346
+ * (`{'paid_by.card': v}` becomes `{paid_by: {card: v}}`) and then its cell reads
1347
+ * `item['paid_by.card']` FLAT and gets undefined — so bvn renders a dotted key
1348
+ * as an empty cell in BOTH payload shapes. Meanwhile our own client-side sort
1349
+ * and filter also read flat, which a NESTED payload (what Laravel serialises for
1350
+ * a relation) never satisfies. Consumers worked around it by flattening every
1351
+ * nested key to an `_x` alias in each client-side port.
1352
+ *
1353
+ * LITERAL key first, then dot-path. That single order covers every shape:
1354
+ * an ordinary key (`name`), a dots-flat payload (the literal `paid_by.card`
1355
+ * key), and a nested payload (walk `paid_by` -> `card`). It also means bvn's
1356
+ * un-flattening is harmless rather than hostile — mapItem normalises flat to
1357
+ * nested, and the path read finds it either way.
1358
+ */
1359
+ const fieldValueOf = (item: unknown, key: string): any => {
1360
+ if (item === null || item === undefined) return undefined;
1361
+ if (Object.prototype.hasOwnProperty.call(item, key)) {
1362
+ return (item as Record<string, any>)[key];
1363
+ }
1364
+ return getByPath(item as Record<string, any>, key);
1365
+ };
1366
+
1367
+ /*
1368
+ * Whether a row carries a value for `key` at all — presence, not truthiness.
1369
+ * Mirrors the `key in item` check this replaced, extended to dot paths.
1370
+ */
1371
+ const hasFieldValue = (item: unknown, key: string): boolean => {
1372
+ if (item === null || item === undefined) return false;
1373
+ if (Object.prototype.hasOwnProperty.call(item, key)) return true;
1374
+ return getByPath(item as Record<string, any>, key) !== undefined;
1375
+ };
1376
+
1377
+ /*
1378
+ * Dotted-key columns DXTable must render itself, because bvn renders them empty
1379
+ * (see `fieldValueOf`). A consumer's own `cell(<key>)` slot always wins — it is
1380
+ * forwarded separately, and declaring the same slot name twice would clobber it.
1381
+ *
1382
+ * A FUNCTION, not a computed, for the same reason as `forwardableSlotNames`:
1383
+ * `useSlots()` returns an object Vue mutates in place with nothing to track, so
1384
+ * a computed over its keys is captured on first render and never updates (#114).
1385
+ */
1386
+ const dottedCellFields = (slots: Record<string, unknown>): TableField[] =>
1387
+ props.fields.filter((field) => field.key.includes('.') && !slots[`cell(${field.key})`]);
1388
+
1303
1389
  // Computed: Get effective filter options for a field
1304
1390
  // The key a column's filter is SENT under, which is not always the column's own
1305
1391
  // key: a display column can filter on a different server param via `filterKey`.
@@ -1855,6 +1941,7 @@ const handleFilterChange = (fieldKey: string, value: string) => {
1855
1941
  {
1856
1942
  page: 1, // Reset to first page when filtering
1857
1943
  filters: newFilters,
1944
+ perPage: effectivePerPage.value,
1858
1945
  ...sortParams(effectiveSortBy.value),
1859
1946
  },
1860
1947
  { preserveState: true }
@@ -1929,6 +2016,18 @@ try {
1929
2016
  // Forward only the keyed edit slots the consumer actually provided, so
1930
2017
  // DXForm doesn't mistake an always-present (but empty) wrapper for
1931
2018
  // a real custom-value override.
2019
+ /*
2020
+ * A field's own starting value, used to seed a CREATE form and as the fallback
2021
+ * when a row lacks the key (#122).
2022
+ *
2023
+ * `field.default ?? ''` can't express a NULL default: a select whose "none"
2024
+ * option is `value: null` — which is what the column stores — seeds '' instead,
2025
+ * matches no option, and renders blank. Presence, not nullishness, decides here
2026
+ * too, so the create and edit paths agree about the same field.
2027
+ */
2028
+ const defaultValueFor = (field: any): any =>
2029
+ Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : '';
2030
+
1932
2031
  /*
1933
2032
  * Seeding an EDIT form from a row (#117).
1934
2033
  *
@@ -1941,7 +2040,7 @@ const seedValueFor = (field: any, row: T): any => {
1941
2040
  if (Object.prototype.hasOwnProperty.call(row as any, field.key)) {
1942
2041
  return (row as any)[field.key];
1943
2042
  }
1944
- return field.default ?? '';
2043
+ return defaultValueFor(field);
1945
2044
  };
1946
2045
 
1947
2046
  /*
@@ -2063,8 +2162,17 @@ const isTableSlot = (name: string) =>
2063
2162
  const forwardableSlotNames = (slots: Record<string, unknown>): string[] =>
2064
2163
  Object.keys(slots).filter((name) => isTableSlot(name));
2065
2164
 
2066
- const tableSlotSignature = (slots: Record<string, unknown>): string =>
2067
- Object.keys(slots).filter(isTableSlot).sort().join('|');
2165
+ const tableSlotSignature = (slots: Record<string, unknown>): string => {
2166
+ // DXTable's OWN dotted-cell slots count too (#121). bvn captures its cell
2167
+ // slot set at mount (#114), so a dotted-key column that appears LATER —
2168
+ // data-driven columns resolved after a fetch — adds a `cell(...)` slot bvn
2169
+ // ignores, and the cell renders empty. Folding the dotted-cell field keys
2170
+ // into the signature remounts the inner table when that set changes, exactly
2171
+ // as it does for a consumer's late `cell(...)` slot.
2172
+ const consumerSlots = Object.keys(slots).filter(isTableSlot);
2173
+ const dottedCellSlots = dottedCellFields(slots).map((field) => `cell(${field.key})`);
2174
+ return [...consumerSlots, ...dottedCellSlots].sort().join('|');
2175
+ };
2068
2176
 
2069
2177
  /*
2070
2178
  * The template iterates `$slots` DIRECTLY rather than a computed over
@@ -2245,13 +2353,13 @@ const handleCreateNew = () => {
2245
2353
  if (!editForm.value) {
2246
2354
  const formData: Record<string, any> = {};
2247
2355
  submittableEditFields.value.forEach(field => {
2248
- formData[field.key] = field.default ?? '';
2356
+ formData[field.key] = defaultValueFor(field);
2249
2357
  });
2250
2358
  editForm.value = useForm(formData);
2251
2359
  } else {
2252
2360
  // Reset existing form to defaults
2253
2361
  submittableEditFields.value.forEach(field => {
2254
- editForm.value.data[field.key] = field.default ?? '';
2362
+ editForm.value.data[field.key] = defaultValueFor(field);
2255
2363
  });
2256
2364
  editForm.value.clearErrors();
2257
2365
  }
@@ -65,10 +65,22 @@ export function defineForm<const TFields extends readonly FormFieldDefinition[]>
65
65
  ): DefineFormReturn<InferFormData<TFields>> {
66
66
  type FormData = InferFormData<TFields>;
67
67
 
68
- // Extract initial data from field definitions
68
+ // Extract initial data from field definitions.
69
+ //
70
+ // #125: `field.default ?? getDefaultValueForType(type)` cannot express a
71
+ // *null* default — a select whose "none" option is `value: null` (which is
72
+ // what the column stores) seeded '' (the type default), matched no option,
73
+ // and rendered blank. This is the same bug #122 fixed in DXTable. Decide on
74
+ // definedness, not nullishness, so null survives; an absent (or explicitly
75
+ // `undefined`) default still falls back to the type default rather than
76
+ // seeding `undefined`, which would drop the key from the JSON payload. This
77
+ // matches DXRepeater's `!== undefined` rule, so all seeding sites agree.
69
78
  const initialData = fieldDefinitions.reduce(
70
79
  (acc, field) => {
71
- acc[field.key] = field.default ?? getDefaultValueForType(field.type);
80
+ acc[field.key] =
81
+ field.default !== undefined
82
+ ? field.default
83
+ : getDefaultValueForType(field.type);
72
84
  return acc;
73
85
  },
74
86
  {} as Record<string, any>,