@omnitend/dashboard-for-laravel 0.19.1 → 0.19.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnitend/dashboard-for-laravel",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
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",
@@ -1,14 +1,112 @@
1
- <script setup lang="ts">
1
+ /**
2
+ * @component
3
+ * Type-safe wrapper around Bootstrap Vue Next BFormSelect.
4
+ *
5
+ * Adds one behaviour BVN lacks: a `select` option whose value is `null`
6
+ * round-trips correctly. BVN renders each option's value as a plain HTML
7
+ * `value` attribute, so when that value is `null` Vue omits the attribute and
8
+ * the browser falls back to using the option's *text* as its DOM value — a
9
+ * model bound to `null` then matches no option and the select renders blank.
10
+ *
11
+ * We map a `null` option value (and a `null` model) to a private string
12
+ * sentinel so BVN renders a real value attribute, while keeping the consumer's
13
+ * model and emitted value as `null`. Legacy Bootstrap-Vue (BS4) handled this;
14
+ * BVN does not. (Radios likely need the same treatment — see #81.)
15
+ */
16
+ <script setup lang="ts" generic="T = unknown">
2
17
  import { BFormSelect } from "bootstrap-vue-next";
3
- import { useSlots } from "vue";
18
+ import { computed, useSlots } from "vue";
19
+
20
+ // Mirrors the shapes BVN's BFormSelect accepts per option (primitive, object, or
21
+ // group). Only object options can carry a `null` value.
22
+ export type OptionItem = string | number | boolean | Record<string, unknown>;
23
+
24
+ export interface Props<TValue = unknown> {
25
+ modelValue?: TValue | TValue[] | null;
26
+ options?: readonly OptionItem[];
27
+ /** Key on object options that holds the value (BVN default: "value"). */
28
+ valueField?: string;
29
+ /** Key on object options that holds nested options / groups (BVN default: "options"). */
30
+ optionsField?: string;
31
+ }
32
+
33
+ // A stable private value that stands in for a `null` option/model. Chosen to be
34
+ // vanishingly unlikely to collide with a real option value.
35
+ const NULL_SENTINEL = "__dfl_null_option_sentinel__";
36
+
37
+ const props = withDefaults(defineProps<Props<T>>(), {
38
+ valueField: "value",
39
+ optionsField: "options",
40
+ });
41
+
42
+ const emit = defineEmits<{
43
+ "update:modelValue": [value: T | T[] | null];
44
+ }>();
4
45
 
5
46
  const slots = useSlots();
47
+
48
+ const encodeValue = (value: unknown): unknown =>
49
+ value === null ? NULL_SENTINEL : value;
50
+
51
+ const decodeValue = (value: unknown): unknown =>
52
+ value === NULL_SENTINEL ? null : value;
53
+
54
+ const isPlainOptionObject = (option: unknown): option is Record<string, unknown> =>
55
+ typeof option === "object" && option !== null && !Array.isArray(option);
56
+
57
+ // Replace any option whose resolved value is `null` with the sentinel. Recurses
58
+ // into option groups. Primitive options (string/number/boolean) can't be null.
59
+ const encodeOptions = (options: readonly OptionItem[]): OptionItem[] =>
60
+ options.map((option) => {
61
+ if (!isPlainOptionObject(option)) {
62
+ return option;
63
+ }
64
+
65
+ const nested = option[props.optionsField];
66
+ if (Array.isArray(nested)) {
67
+ return { ...option, [props.optionsField]: encodeOptions(nested) };
68
+ }
69
+
70
+ if (option[props.valueField] === null) {
71
+ return { ...option, [props.valueField]: NULL_SENTINEL };
72
+ }
73
+
74
+ return option;
75
+ });
76
+
77
+ const encodedOptions = computed<OptionItem[] | undefined>(() =>
78
+ props.options === undefined ? undefined : encodeOptions(props.options),
79
+ );
80
+
81
+ const encodedModel = computed<unknown>(() => {
82
+ const model = props.modelValue;
83
+ if (Array.isArray(model)) {
84
+ return model.map(encodeValue);
85
+ }
86
+ return encodeValue(model);
87
+ });
88
+
89
+ const onUpdateModelValue = (value: unknown): void => {
90
+ const decoded = Array.isArray(value) ? value.map(decodeValue) : decodeValue(value);
91
+ emit("update:modelValue", decoded as T | T[] | null);
92
+ };
93
+
94
+ defineOptions({
95
+ inheritAttrs: false,
96
+ });
6
97
  </script>
7
98
 
8
99
  <template>
9
- <BFormSelect v-bind="$attrs">
10
- <template v-for="(_, name) in slots" :key="name" #[name]>
11
- <slot :name="name" />
100
+ <BFormSelect
101
+ v-bind="$attrs"
102
+ :model-value="encodedModel"
103
+ :options="encodedOptions"
104
+ :value-field="valueField"
105
+ :options-field="optionsField"
106
+ @update:model-value="onUpdateModelValue"
107
+ >
108
+ <template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
109
+ <slot :name="name" v-bind="slotProps" />
12
110
  </template>
13
111
  </BFormSelect>
14
112
  </template>
@@ -1126,14 +1126,27 @@ watch(() => props.filters, (newFilters, oldFilters) => {
1126
1126
  }
1127
1127
  }, { deep: true });
1128
1128
 
1129
- // Watch apiUrl changes to reset filter cache (prevents stale dropdown options)
1129
+ // Watch apiUrl changes to reset filter cache and refetch. The api-url isn't part
1130
+ // of BTable's provider context, so an api-url-only change doesn't re-invoke the
1131
+ // provider on its own — without this the table keeps showing the previous url's
1132
+ // rows until some other trigger (sort, per-page, create/edit/delete) fires.
1130
1133
  watch(() => props.apiUrl, (newUrl, oldUrl) => {
1131
1134
  if (newUrl !== oldUrl && isProviderMode.value) {
1132
- // Clear cached filter values and pagination when API endpoint changes
1135
+ // Clear cached filter values and pagination when API endpoint changes.
1136
+ // The next provider call will request fresh filter values.
1133
1137
  apiFilterValues.value = {};
1134
1138
  apiPaginationMeta.value = null;
1135
1139
  apiError.value = null;
1136
- // Next provider call will request fresh filter values
1140
+ // A url change is a fresh dataset — go back to page 1. If we were past
1141
+ // page 1, resetting the page re-invokes the provider on its own (so the
1142
+ // out-of-range page can't linger); if we were already on page 1 the prop
1143
+ // doesn't change, so refresh() forces the refetch. Exactly one fetch
1144
+ // either way.
1145
+ const wasOnFirstPage = apiCurrentPage.value === 1;
1146
+ apiCurrentPage.value = 1;
1147
+ if (wasOnFirstPage) {
1148
+ refresh();
1149
+ }
1137
1150
  }
1138
1151
  });
1139
1152
 
@@ -1382,8 +1395,14 @@ const handleBusyChange = (busy: boolean) => {
1382
1395
  // Debounce timer for filter changes
1383
1396
  let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null;
1384
1397
 
1385
- const handlePerPageChange = (newPerPage: number | string) => {
1386
- const perPageNum = typeof newPerPage === 'string' ? parseInt(newPerPage, 10) : newPerPage;
1398
+ // DFormSelect's model can be null/array (it mirrors BVN's BFormSelect), but the
1399
+ // per-page selector only ever emits a numeric-string scalar; narrow defensively.
1400
+ const handlePerPageChange = (newPerPage: number | string | null | (number | string)[]) => {
1401
+ const rawPerPage = Array.isArray(newPerPage) ? newPerPage[0] : newPerPage;
1402
+ if (rawPerPage === null || rawPerPage === undefined) {
1403
+ return;
1404
+ }
1405
+ const perPageNum = typeof rawPerPage === 'string' ? parseInt(rawPerPage, 10) : rawPerPage;
1387
1406
 
1388
1407
  // Update internal state if not using external perPage
1389
1408
  if (props.perPage === undefined) {