@omnitend/dashboard-for-laravel 0.31.0 → 0.33.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.
- package/dist/components/extended/DXCurrencyInput.vue.d.ts +27 -0
- package/dist/components/extended/DXField.vue.d.ts +30 -15
- package/dist/components/extended/DXTable.vue.d.ts +17 -5
- package/dist/composables/defineForm.d.ts +8 -2
- package/dist/dashboard-for-laravel.js +9191 -8984
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +21 -9
- package/docs/public/api-reference.json +79 -5
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +1 -5
- package/resources/css/theme.scss +34 -0
- package/resources/js/components/extended/DXCurrencyInput.vue +143 -0
- package/resources/js/components/extended/DXDashboardSidebar.vue +21 -16
- package/resources/js/components/extended/DXField.vue +142 -71
- package/resources/js/components/extended/DXFormField.vue +1 -0
- package/resources/js/components/extended/DXTable.vue +116 -48
- package/resources/js/components/extended/DXTablePagination.vue +119 -25
- package/resources/js/composables/defineForm.ts +12 -2
- package/resources/js/composables/useResourceEditor.ts +3 -5
- package/resources/js/index.ts +1 -0
- package/resources/js/types/index.ts +22 -8
- package/resources/js/utils/api.ts +13 -5
|
@@ -19,16 +19,40 @@
|
|
|
19
19
|
<template>
|
|
20
20
|
<div class="dx-table-pagination mt-3">
|
|
21
21
|
<!-- Top row: Pagination and Per-page selector -->
|
|
22
|
-
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
23
|
-
<!--
|
|
24
|
-
|
|
22
|
+
<div class="dx-pager-row d-flex justify-content-between align-items-center gap-2 mb-2">
|
|
23
|
+
<!-- Windowed pager (#155): «Previous / leading / … / window / … /
|
|
24
|
+
trailing / Next». Client-computed from current + last page, so it
|
|
25
|
+
works in every DXTable mode. Only shown when >1 page. -->
|
|
26
|
+
<nav
|
|
25
27
|
v-if="showPagination && pagination.total > pagination.per_page"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
class="dx-pager d-flex flex-wrap align-items-center gap-1"
|
|
29
|
+
aria-label="Pagination"
|
|
30
|
+
>
|
|
31
|
+
<DButton
|
|
32
|
+
variant="outline-secondary"
|
|
33
|
+
size="sm"
|
|
34
|
+
:disabled="!canPrev"
|
|
35
|
+
@click="goToPage(currentPage - 1)"
|
|
36
|
+
>« Previous</DButton>
|
|
37
|
+
|
|
38
|
+
<template v-for="(item, index) in pageItems" :key="item.type === 'page' ? `p${item.page}` : `e${index}`">
|
|
39
|
+
<DButton
|
|
40
|
+
v-if="item.type === 'page'"
|
|
41
|
+
size="sm"
|
|
42
|
+
:variant="item.active ? 'primary' : 'outline-secondary'"
|
|
43
|
+
:aria-current="item.active ? 'page' : undefined"
|
|
44
|
+
@click="goToPage(item.page)"
|
|
45
|
+
>{{ item.page }}</DButton>
|
|
46
|
+
<span v-else class="dx-pager-ellipsis" aria-hidden="true">…</span>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<DButton
|
|
50
|
+
variant="outline-secondary"
|
|
51
|
+
size="sm"
|
|
52
|
+
:disabled="!canNext"
|
|
53
|
+
@click="goToPage(currentPage + 1)"
|
|
54
|
+
>Next »</DButton>
|
|
55
|
+
</nav>
|
|
32
56
|
<div v-else></div>
|
|
33
57
|
|
|
34
58
|
<!-- Per-page selector -->
|
|
@@ -67,7 +91,8 @@
|
|
|
67
91
|
</template>
|
|
68
92
|
|
|
69
93
|
<script setup lang="ts">
|
|
70
|
-
import
|
|
94
|
+
import { computed } from "vue";
|
|
95
|
+
import DButton from "../base/DButton.vue";
|
|
71
96
|
import DFormSelect from "../base/DFormSelect.vue";
|
|
72
97
|
import type { PaginationData } from "./DXTable.vue";
|
|
73
98
|
|
|
@@ -92,7 +117,7 @@ interface Props {
|
|
|
92
117
|
hasActiveFilters: boolean;
|
|
93
118
|
}
|
|
94
119
|
|
|
95
|
-
defineProps<Props>();
|
|
120
|
+
const props = defineProps<Props>();
|
|
96
121
|
|
|
97
122
|
const emit = defineEmits<{
|
|
98
123
|
/** The user picked a page. Payload is the new 1-based page number. */
|
|
@@ -104,26 +129,95 @@ const emit = defineEmits<{
|
|
|
104
129
|
*/
|
|
105
130
|
'per-page-change': [value: number | string | null | (number | string)[]];
|
|
106
131
|
}>();
|
|
132
|
+
|
|
133
|
+
// Windowed pager (#155). Always show the first/last BOUNDARY pages and a window
|
|
134
|
+
// of AROUND pages each side of the current page; fill gaps with an ellipsis,
|
|
135
|
+
// except a single-page gap which shows the page itself (an ellipsis hiding one
|
|
136
|
+
// page is silly). Client-computed from current + last page so it works in every
|
|
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`.
|
|
139
|
+
const BOUNDARY = 2;
|
|
140
|
+
const AROUND = 3;
|
|
141
|
+
|
|
142
|
+
type PageItem = { type: "page"; page: number; active: boolean } | { type: "ellipsis" };
|
|
143
|
+
|
|
144
|
+
const currentPage = computed(() => props.pagination.current_page);
|
|
145
|
+
const lastPage = computed(() =>
|
|
146
|
+
Math.max(1, Math.ceil(props.pagination.total / props.pagination.per_page)),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const pageItems = computed<PageItem[]>(() => {
|
|
150
|
+
const last = lastPage.value;
|
|
151
|
+
const current = currentPage.value;
|
|
152
|
+
|
|
153
|
+
const shown = new Set<number>();
|
|
154
|
+
// Leading + trailing boundary pages (1,2 … last-1,last).
|
|
155
|
+
for (let page = 1; page <= Math.min(BOUNDARY, last); page += 1) shown.add(page);
|
|
156
|
+
for (let page = Math.max(1, last - BOUNDARY + 1); page <= last; page += 1) shown.add(page);
|
|
157
|
+
// A window around the current page. When it clamps at an edge, push the other
|
|
158
|
+
// edge out by the shortfall so the row stays a full width near the start/end
|
|
159
|
+
// (a fuller run of numbers, like the house pager) instead of going sparse.
|
|
160
|
+
let windowLow = current - AROUND;
|
|
161
|
+
let windowHigh = current + AROUND;
|
|
162
|
+
const shortfall = 2 * AROUND + 1 - (Math.min(last, windowHigh) - Math.max(1, windowLow) + 1);
|
|
163
|
+
if (shortfall > 0 && windowLow < 1) windowHigh += shortfall;
|
|
164
|
+
else if (shortfall > 0 && windowHigh > last) windowLow -= shortfall;
|
|
165
|
+
for (let page = Math.max(1, windowLow); page <= Math.min(last, windowHigh); page += 1) {
|
|
166
|
+
shown.add(page);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sorted = [...shown].sort((a, b) => a - b);
|
|
170
|
+
const items: PageItem[] = [];
|
|
171
|
+
let previous = 0;
|
|
172
|
+
for (const page of sorted) {
|
|
173
|
+
if (page - previous === 2) {
|
|
174
|
+
// Exactly one page missing — show it rather than an ellipsis.
|
|
175
|
+
items.push({ type: "page", page: previous + 1, active: previous + 1 === current });
|
|
176
|
+
} else if (page - previous > 2) {
|
|
177
|
+
items.push({ type: "ellipsis" });
|
|
178
|
+
}
|
|
179
|
+
items.push({ type: "page", page, active: page === current });
|
|
180
|
+
previous = page;
|
|
181
|
+
}
|
|
182
|
+
return items;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const canPrev = computed(() => currentPage.value > 1);
|
|
186
|
+
const canNext = computed(() => currentPage.value < lastPage.value);
|
|
187
|
+
|
|
188
|
+
const goToPage = (page: number) => {
|
|
189
|
+
if (page < 1 || page > lastPage.value || page === currentPage.value) return;
|
|
190
|
+
emit("page-change", page);
|
|
191
|
+
};
|
|
107
192
|
</script>
|
|
108
193
|
|
|
109
194
|
<style scoped>
|
|
110
|
-
/*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
195
|
+
/* 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
|
|
198
|
+
overflow. `:deep(.btn)` is anchored on the plain `.dx-pager` host, so the
|
|
199
|
+
scope-id has a deterministic element to attach to. */
|
|
200
|
+
.dx-pager :deep(.btn) {
|
|
201
|
+
min-width: 2.5rem;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Previous / Next carry text, so they shouldn't be forced square. */
|
|
205
|
+
.dx-pager :deep(.btn):first-child,
|
|
206
|
+
.dx-pager :deep(.btn):last-child {
|
|
207
|
+
min-width: auto;
|
|
116
208
|
}
|
|
117
209
|
|
|
118
|
-
.dx-
|
|
119
|
-
|
|
120
|
-
|
|
210
|
+
.dx-pager-ellipsis {
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: flex-end;
|
|
213
|
+
padding: 0 0.15rem;
|
|
214
|
+
color: var(--bs-secondary-color);
|
|
215
|
+
user-select: none;
|
|
121
216
|
}
|
|
122
217
|
|
|
123
|
-
/*
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
opacity: 0.3;
|
|
218
|
+
/* On narrow screens the row can get long; allow it to scroll horizontally as a
|
|
219
|
+
fallback so it never blows out the table width. */
|
|
220
|
+
.dx-pager-row {
|
|
221
|
+
flex-wrap: wrap;
|
|
128
222
|
}
|
|
129
223
|
</style>
|
|
@@ -5,8 +5,14 @@ import type { FieldDefinition, FieldType } from "../types";
|
|
|
5
5
|
* Extended field definition that includes default value
|
|
6
6
|
*/
|
|
7
7
|
export interface FormFieldDefinition<TValue = any> extends FieldDefinition {
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Default/initial value for the field. Optional: when omitted, the field
|
|
10
|
+
* seeds from its type (`false` for checkbox/switch, `[]` for
|
|
11
|
+
* checkbox-group/switch-list/repeater, `0` for numerics, `""` otherwise) —
|
|
12
|
+
* previously the type required this even though the runtime had a
|
|
13
|
+
* fallback, so typed consumers could never use it.
|
|
14
|
+
*/
|
|
15
|
+
default?: TValue;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/**
|
|
@@ -37,6 +43,10 @@ function getDefaultValueForType(type: FieldType): any {
|
|
|
37
43
|
case "percentage":
|
|
38
44
|
return 0;
|
|
39
45
|
case "repeater":
|
|
46
|
+
// Array-valued selections: checkbox-group previously fell through to
|
|
47
|
+
// the "" default, which is the wrong shape for an array model.
|
|
48
|
+
case "checkbox-group":
|
|
49
|
+
case "switch-list":
|
|
40
50
|
return [];
|
|
41
51
|
case "image":
|
|
42
52
|
case "file":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { computed, ref, type Ref } from "vue";
|
|
2
|
-
import
|
|
2
|
+
import { api } from "../utils/api";
|
|
3
3
|
import { useForm } from "./useForm";
|
|
4
4
|
import { useToast } from "./useToast";
|
|
5
5
|
|
|
@@ -254,7 +254,7 @@ export function useResourceEditor<T = any>(
|
|
|
254
254
|
editLoading.value = true;
|
|
255
255
|
try {
|
|
256
256
|
const url = props.showUrl!.replace(':id', String(itemId));
|
|
257
|
-
const response = await
|
|
257
|
+
const response = await api.get<any>(url);
|
|
258
258
|
// Superseded by a newer row-open — discard.
|
|
259
259
|
if (token !== editFetchToken) return;
|
|
260
260
|
|
|
@@ -271,9 +271,7 @@ export function useResourceEditor<T = any>(
|
|
|
271
271
|
} catch (error: any) {
|
|
272
272
|
if (token !== editFetchToken) return;
|
|
273
273
|
const message =
|
|
274
|
-
error?.
|
|
275
|
-
error?.message ??
|
|
276
|
-
'Failed to load the full record.';
|
|
274
|
+
error?.message ?? 'Failed to load the full record.';
|
|
277
275
|
createToast?.({
|
|
278
276
|
title: 'Error',
|
|
279
277
|
body: message,
|
package/resources/js/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { default as DXForm } from "./components/extended/DXForm.vue";
|
|
|
10
10
|
export { default as DXField } from "./components/extended/DXField.vue";
|
|
11
11
|
export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
|
|
12
12
|
export { default as DXSwitch } from "./components/extended/DXSwitch.vue";
|
|
13
|
+
export { default as DXCurrencyInput } from "./components/extended/DXCurrencyInput.vue";
|
|
13
14
|
export { default as DXStatCard } from "./components/extended/DXStatCard.vue";
|
|
14
15
|
// Chart components moved to the `@omnitend/dashboard-for-laravel/charts` entry
|
|
15
16
|
// (#142) so chart.js / vue-chartjs stay genuinely optional peers — the main
|
|
@@ -33,6 +33,7 @@ export type FieldType =
|
|
|
33
33
|
| "checkbox"
|
|
34
34
|
| "checkbox-group"
|
|
35
35
|
| "switch"
|
|
36
|
+
| "switch-list"
|
|
36
37
|
| "radio"
|
|
37
38
|
| "image"
|
|
38
39
|
| "file"
|
|
@@ -103,9 +104,11 @@ export interface FieldDefinition {
|
|
|
103
104
|
required?: boolean;
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* Options for `select`, `radio`
|
|
107
|
-
* `autocomplete` these are the datalist
|
|
108
|
-
* accepts free text not present in the list.
|
|
107
|
+
* Options for `select`, `radio`, `checkbox-group`, `switch-list` and
|
|
108
|
+
* `autocomplete` fields. For `autocomplete` these are the datalist
|
|
109
|
+
* suggestions; the field still accepts free text not present in the list.
|
|
110
|
+
* For `switch-list` each option renders a labelled toggle row
|
|
111
|
+
* (`description` becomes the row label's tooltip).
|
|
109
112
|
*/
|
|
110
113
|
options?: FieldOption[];
|
|
111
114
|
|
|
@@ -152,6 +155,15 @@ export interface FieldDefinition {
|
|
|
152
155
|
*/
|
|
153
156
|
decimals?: number;
|
|
154
157
|
|
|
158
|
+
/**
|
|
159
|
+
* For `currency` fields: the model stores integer MINOR units (pence) but
|
|
160
|
+
* the input shows/edits MAJOR units (pounds) — display = value / 10^decimals,
|
|
161
|
+
* submit = round(input × 10^decimals). `min`/`max`/`step` stay in major
|
|
162
|
+
* units. Off by default (the model is the major-unit amount). Use for
|
|
163
|
+
* legacy pence-integer columns (#116).
|
|
164
|
+
*/
|
|
165
|
+
minorUnits?: boolean;
|
|
166
|
+
|
|
155
167
|
/**
|
|
156
168
|
* For `percentage` fields: treat the underlying model value as a 0–1
|
|
157
169
|
* fraction while showing/editing it as a 0–100 percentage. The model keeps
|
|
@@ -197,11 +209,13 @@ export interface FieldDefinition {
|
|
|
197
209
|
textWhenFalse?: MaybeFn<string>;
|
|
198
210
|
|
|
199
211
|
/**
|
|
200
|
-
* For `switch` fields: the on-state colour. `"success"`
|
|
201
|
-
* house green-on / red-off style for an
|
|
202
|
-
* `"neutral"` (brand primary on / grey off) is
|
|
203
|
-
* switches that shouldn't imply good/bad ("contains
|
|
204
|
-
* toggle, "hidden on web shop").
|
|
212
|
+
* For `switch` and `switch-list` fields: the on-state colour. `"success"`
|
|
213
|
+
* (default) is the house green-on / red-off style for an
|
|
214
|
+
* active/enabled/good switch; `"neutral"` (brand primary on / grey off) is
|
|
215
|
+
* for semantically-mixed switches that shouldn't imply good/bad ("contains
|
|
216
|
+
* alcohol", an allergen toggle, "hidden on web shop"). A `switch-list`
|
|
217
|
+
* usually wants `"neutral"` — a wall of red-off toggles over-signals when
|
|
218
|
+
* off simply means "not present". See DXSwitch `onVariant` (#158).
|
|
205
219
|
*/
|
|
206
220
|
switchVariant?: "success" | "neutral";
|
|
207
221
|
|
|
@@ -53,8 +53,7 @@ class ApiClient {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
55
|
for (const [k, v] of Object.entries(params)) append(k, v);
|
|
56
|
-
|
|
57
|
-
return qs ? `?${qs}` : "";
|
|
56
|
+
return search.toString();
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
private handleError(response: Response, data: any): never {
|
|
@@ -92,18 +91,24 @@ class ApiClient {
|
|
|
92
91
|
): Promise<ApiResponse<T>> {
|
|
93
92
|
const isFormData =
|
|
94
93
|
typeof FormData !== "undefined" && options.body instanceof FormData;
|
|
94
|
+
// GET/HEAD carry no body (nothing for Content-Type to describe) and
|
|
95
|
+
// change no state (no CSRF exposure). Both headers are also non-simple
|
|
96
|
+
// for CORS, so sending them would force a preflight on cross-origin
|
|
97
|
+
// apiUrl/showUrl endpoints that axios's plain GETs never needed (#132).
|
|
98
|
+
const method = (options.method ?? "GET").toUpperCase();
|
|
99
|
+
const isBodyless = method === "GET" || method === "HEAD";
|
|
95
100
|
|
|
96
101
|
const headers: Record<string, string> = {
|
|
97
102
|
...this.defaultHeaders,
|
|
98
103
|
...(isFormData
|
|
99
104
|
? {}
|
|
100
105
|
: { "Content-Type": this.defaultHeaders["Content-Type"] }),
|
|
101
|
-
"X-CSRF-TOKEN": this.getCsrfToken(),
|
|
106
|
+
...(isBodyless ? {} : { "X-CSRF-TOKEN": this.getCsrfToken() }),
|
|
102
107
|
...options.headers,
|
|
103
108
|
};
|
|
104
109
|
|
|
105
110
|
// Let fetch set the multipart boundary if FormData
|
|
106
|
-
if (isFormData) delete headers["Content-Type"];
|
|
111
|
+
if (isFormData || isBodyless) delete headers["Content-Type"];
|
|
107
112
|
|
|
108
113
|
const config: RequestOptions = {
|
|
109
114
|
credentials: "same-origin",
|
|
@@ -140,7 +145,10 @@ class ApiClient {
|
|
|
140
145
|
params: Record<string, unknown> = {},
|
|
141
146
|
options: Omit<RequestOptions, "method"> = {},
|
|
142
147
|
) {
|
|
143
|
-
|
|
148
|
+
// Merge into an existing query string (`/things?scope=x` + params) with
|
|
149
|
+
// `&` — a blind `?` append corrupts the URL and silently drops params.
|
|
150
|
+
const qs = this.toQuery(params);
|
|
151
|
+
const full = qs ? `${url}${url.includes("?") ? "&" : "?"}${qs}` : url;
|
|
144
152
|
return this.request<T>(full, { ...options, method: "GET" });
|
|
145
153
|
}
|
|
146
154
|
|