bitboss-ui 3.0.0-beta.64 → 3.0.0-beta.65
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/ai/changelog.json +82 -4
- package/dist/ai/components.json +2 -2
- package/dist/ai/composables/useBbConfig.md +8 -1
- package/dist/ai/guides/inertia-helpers.md +1127 -275
- package/dist/ai/guides/migration/v2-to-v3.md +48 -0
- package/dist/ai/guides/passthrough.md +4 -2
- package/dist/ai/helpers/inertia/manifest.json +145 -62
- package/dist/ai/helpers/inertia/messages.ts +33 -0
- package/dist/ai/helpers/inertia/useConfirmOnRouteLeave.ts +116 -16
- package/dist/ai/helpers/inertia/useHasChanged.ts +34 -16
- package/dist/ai/helpers/inertia/useLazyProp.ts +102 -36
- package/dist/ai/helpers/inertia/useLazyPropGetter.ts +3 -1
- package/dist/ai/helpers/inertia/usePaginatedResponseContext.ts +129 -24
- package/dist/ai/helpers/inertia/usePromiseForm.ts +121 -14
- package/dist/ai/helpers/inertia/useQuery.ts +133 -11
- package/dist/ai/helpers/inertia/useQueryObject.ts +74 -67
- package/dist/ai/helpers/inertia/useResourceContext.ts +60 -14
- package/dist/ai/helpers/inertia/useRouteQuery.ts +29 -24
- package/dist/ai/helpers/inertia/useScreenSizes.ts +9 -7
- package/dist/ai/helpers/inertia/useTableContext.ts +5 -2
- package/dist/ai/helpers/inertia/utils.ts +14 -0
- package/dist/ai/manifest/index.json +2 -2
- package/dist/ai/recipes/inertia/approvals-inbox.md +1 -1
- package/dist/ai/recipes/inertia/inline-edit-workspace.md +1 -1
- package/dist/ai/recipes/inertia/ownership-atlas.md +1 -1
- package/dist/ai/recipes/inertia/records-workspace.md +1 -1
- package/dist/ai/recipes/inertia/upload-center.md +1 -1
- package/dist/ai/source/BbSelectPopover.md +25 -1
- package/dist/components/BbSelectPopover/BbSelectPopover.vue_vue_type_script_setup_true_lang.js +306 -294
- package/dist/composables/useBbConfig.d.ts +1 -0
- package/dist/composables/useBbConfig.js +11 -7
- package/dist/llms-full.txt +1192 -283
- package/dist/styles.css +1 -1
- package/dist/utils/versionCheck.js +1 -1
- package/package.json +3 -2
- package/scripts/lib/helpers-add.mjs +26 -2
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
// inertia-helpers/usePaginatedResponseContext.ts
|
|
2
|
-
|
|
2
|
+
// requires: npm i query-string
|
|
3
|
+
import { currentUrl, visitUrl, withQuery } from './useQuery';
|
|
3
4
|
import type { PaginatedResponse } from './types';
|
|
4
|
-
import { isNotNil } from './utils';
|
|
5
|
-
import type { PageProps } from '@inertiajs/core';
|
|
6
|
-
import { usePage } from '@inertiajs/vue3';
|
|
5
|
+
import { cloneDeep, isEqual, isNotNil } from './utils';
|
|
6
|
+
import type { PageProps, VisitOptions } from '@inertiajs/core';
|
|
7
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
7
8
|
import { injectLocal, provideLocal } from '@vueuse/core';
|
|
8
9
|
import {
|
|
9
10
|
computed,
|
|
11
|
+
onMounted,
|
|
12
|
+
onScopeDispose,
|
|
10
13
|
ref,
|
|
14
|
+
shallowRef,
|
|
15
|
+
toValue,
|
|
11
16
|
watch,
|
|
12
17
|
watchEffect,
|
|
13
18
|
type ComputedRef,
|
|
19
|
+
type MaybeRefOrGetter,
|
|
14
20
|
type Ref,
|
|
15
21
|
} from 'vue';
|
|
16
22
|
|
|
@@ -19,10 +25,21 @@ export type PaginatedResponseContext<T> = {
|
|
|
19
25
|
perPage: Ref<number>;
|
|
20
26
|
page: Ref<number>;
|
|
21
27
|
totalPages: ComputedRef<number>;
|
|
22
|
-
/** Current page items
|
|
28
|
+
/** Current page items, a deep copy: edit it freely; resets on a new answer. */
|
|
23
29
|
items: Ref<T[]>;
|
|
24
30
|
};
|
|
25
31
|
|
|
32
|
+
export type PaginatedResponseOptions = {
|
|
33
|
+
/** The query key the server reads the page from (Laravel's `pageName`). Default `'page'`. */
|
|
34
|
+
pageQueryKey?: string;
|
|
35
|
+
/** The query key the server reads the page size from. Default `'per_page'`. */
|
|
36
|
+
perPageQueryKey?: string;
|
|
37
|
+
/** Merged over `{ preserveState, preserveScroll }` and the history default (a page pushes, a size replaces). */
|
|
38
|
+
visitOptions?: MaybeRefOrGetter<
|
|
39
|
+
Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
|
|
40
|
+
>;
|
|
41
|
+
};
|
|
42
|
+
|
|
26
43
|
const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
27
44
|
|
|
28
45
|
/**
|
|
@@ -30,30 +47,40 @@ const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
|
30
47
|
* fields under `meta` (a resource collection, a hand-built shape) or at the
|
|
31
48
|
* top level (`->paginate()`, `->simplePaginate()`). `simplePaginate()` knows
|
|
32
49
|
* no total: the count is the rows so far, and the last page is the next one
|
|
33
|
-
* while a next page exists.
|
|
50
|
+
* while a next page exists, or the one before an empty page past the end.
|
|
34
51
|
*/
|
|
35
52
|
export const readPagination = (paginated: PaginatedResponse<unknown>) => {
|
|
36
53
|
const fields = paginated.meta ?? paginated;
|
|
37
54
|
const current = fields.current_page ?? 1;
|
|
38
55
|
const perPage = fields.per_page ?? 10;
|
|
56
|
+
const rows = paginated.data?.length ?? 0;
|
|
39
57
|
const links = paginated.links as { next?: string | null } | undefined;
|
|
40
58
|
const hasNext = !!(
|
|
41
59
|
fields.next_page_url ?? (Array.isArray(links) ? null : links?.next)
|
|
42
60
|
);
|
|
61
|
+
const pastEnd = !rows && !hasNext && current > 1;
|
|
43
62
|
return {
|
|
44
63
|
current,
|
|
45
64
|
perPage,
|
|
46
|
-
lastPage:
|
|
65
|
+
lastPage:
|
|
66
|
+
fields.last_page ??
|
|
67
|
+
(hasNext ? current + 1 : pastEnd ? current - 1 : current),
|
|
47
68
|
total:
|
|
48
69
|
fields.total ??
|
|
49
|
-
(current - 1) * perPage +
|
|
50
|
-
(paginated.data?.length ?? 0) +
|
|
51
|
-
(hasNext ? 1 : 0),
|
|
70
|
+
(pastEnd ? 0 : (current - 1) * perPage + rows + (hasNext ? 1 : 0)),
|
|
52
71
|
};
|
|
53
72
|
};
|
|
54
73
|
|
|
74
|
+
const isCount = (value: number): boolean =>
|
|
75
|
+
Number.isInteger(value) && value > 0;
|
|
76
|
+
|
|
55
77
|
export const usePaginatedResponseContext = <T = object>(
|
|
56
|
-
dataKey: string
|
|
78
|
+
dataKey: string,
|
|
79
|
+
{
|
|
80
|
+
pageQueryKey = 'page',
|
|
81
|
+
perPageQueryKey = 'per_page',
|
|
82
|
+
visitOptions,
|
|
83
|
+
}: PaginatedResponseOptions = {}
|
|
57
84
|
): PaginatedResponseContext<T> => {
|
|
58
85
|
const existing = injectLocal<PaginatedResponseContext<T> | null>(
|
|
59
86
|
toContextKey(dataKey),
|
|
@@ -72,18 +99,46 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
72
99
|
const inertiaPage = usePage<
|
|
73
100
|
PageProps & Record<string, PaginatedResponse<T>>
|
|
74
101
|
>();
|
|
102
|
+
// A deferred, optional or not-yet-reloaded prop reads as an empty page.
|
|
75
103
|
const paginated = computed(
|
|
76
|
-
() => inertiaPage.props[dataKey]
|
|
104
|
+
(): PaginatedResponse<T> => inertiaPage.props[dataKey] ?? { data: [] }
|
|
77
105
|
);
|
|
78
106
|
|
|
79
107
|
const pagination = computed(() => readPagination(paginated.value));
|
|
80
|
-
|
|
108
|
+
// A deep copy: inline edits (`v-model` on a row's field) stay off the page
|
|
109
|
+
// prop, so the "rows really changed" check below compares two things.
|
|
110
|
+
const items = ref<T[]>(cloneDeep(paginated.value.data ?? [])) as Ref<T[]>;
|
|
81
111
|
const totalItems = computed(() => pagination.value.total);
|
|
82
112
|
const totalPages = computed(() => pagination.value.lastPage);
|
|
83
113
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
114
|
+
// Server visits in flight that ask for this prop (a pager click, a filter,
|
|
115
|
+
// `router.reload({ only: [dataKey] })`).
|
|
116
|
+
const asking = new Set<unknown>();
|
|
117
|
+
const asks = ({ only, except }: { only: string[]; except: string[] }) =>
|
|
118
|
+
only.length ? only.includes(dataKey) : !except.includes(dataKey);
|
|
119
|
+
const unbindStart = router.on('start', ({ detail: { visit } }) => {
|
|
120
|
+
if (asks(visit)) asking.add(visit);
|
|
121
|
+
});
|
|
122
|
+
const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
|
|
123
|
+
asking.delete(visit);
|
|
124
|
+
});
|
|
125
|
+
onScopeDispose(() => {
|
|
126
|
+
unbindStart();
|
|
127
|
+
unbindFinish();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// A visit that asked for this prop is a new answer: fresh rows, the edits
|
|
131
|
+
// made to `items` go. Anything else that swaps the prop (`appendToProp` on
|
|
132
|
+
// another prop rebuilds them all, Back / Forward) keeps the edits unless the
|
|
133
|
+
// rows really changed. `sync`: the answer lands before its visit finishes.
|
|
134
|
+
watch(
|
|
135
|
+
paginated,
|
|
136
|
+
(fresh, old) => {
|
|
137
|
+
if (!asking.size && isEqual(fresh.data, old.data)) return;
|
|
138
|
+
items.value = cloneDeep(fresh.data ?? []);
|
|
139
|
+
},
|
|
140
|
+
{ flush: 'sync' }
|
|
141
|
+
);
|
|
87
142
|
|
|
88
143
|
// The server's default page size is what it answers a URL without
|
|
89
144
|
// `per_page`. Until such an answer arrives it is unknown (`''` matches no
|
|
@@ -92,24 +147,74 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
92
147
|
const defaultPerPage = ref('');
|
|
93
148
|
watchEffect(() => {
|
|
94
149
|
const [, queryString = ''] = inertiaPage.url.split('?');
|
|
95
|
-
if (!new URLSearchParams(queryString).has(
|
|
150
|
+
if (!new URLSearchParams(queryString).has(perPageQueryKey))
|
|
96
151
|
defaultPerPage.value = `${pagination.value.perPage}`;
|
|
97
152
|
});
|
|
98
|
-
|
|
99
|
-
|
|
153
|
+
|
|
154
|
+
// Where the last pager write is going, while its visit is in flight: a
|
|
155
|
+
// second click before the answer builds on it ("next" twice is page 3).
|
|
156
|
+
const target = shallowRef<{ url: string; page: number; perPage: number }>();
|
|
157
|
+
const inFlight = () => {
|
|
158
|
+
const write = target.value;
|
|
159
|
+
return write?.url === currentUrl() && write.url !== inertiaPage.url
|
|
160
|
+
? write
|
|
161
|
+
: undefined;
|
|
162
|
+
};
|
|
163
|
+
const go = (to: number, size?: number, extra?: { replace: true }) => {
|
|
164
|
+
const url = withQuery(currentUrl(), {
|
|
165
|
+
[pageQueryKey]: to === 1 ? null : to,
|
|
166
|
+
...(isNotNil(size) && {
|
|
167
|
+
[perPageQueryKey]: `${size}` === defaultPerPage.value ? null : size,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
target.value = { url, page: to, perPage: size ?? perPage.value };
|
|
171
|
+
visitUrl(url, {
|
|
172
|
+
preserveScroll: true,
|
|
173
|
+
preserveState: true,
|
|
174
|
+
// A page is a history entry (Back goes to the previous page); a new
|
|
175
|
+
// size is a setting and replaces.
|
|
176
|
+
replace: isNotNil(size),
|
|
177
|
+
...toValue(visitOptions),
|
|
178
|
+
...extra,
|
|
179
|
+
});
|
|
180
|
+
};
|
|
100
181
|
|
|
101
182
|
// Both read the server's answer, not the URL: a clamped `?page=99`, a
|
|
102
183
|
// capped `?per_page=500` or a redirect to the bare URL shows what the
|
|
103
|
-
// server sent. Writing one visits
|
|
184
|
+
// server sent. Writing one visits when it changes what they show; a size
|
|
185
|
+
// starts again from page 1. Junk (0, NaN, 2.5, past a known last page) is
|
|
186
|
+
// ignored.
|
|
104
187
|
const page = computed({
|
|
105
|
-
get: () => pagination.value.current,
|
|
106
|
-
set: (next: number) =>
|
|
188
|
+
get: () => inFlight()?.page ?? pagination.value.current,
|
|
189
|
+
set: (next: number) => {
|
|
190
|
+
const last = (paginated.value.meta ?? paginated.value).last_page;
|
|
191
|
+
if (!isCount(next) || (isNotNil(last) && next > last)) return;
|
|
192
|
+
if (next !== page.value) go(next);
|
|
193
|
+
},
|
|
107
194
|
});
|
|
108
195
|
const perPage = computed({
|
|
109
|
-
get: () => pagination.value.perPage,
|
|
110
|
-
set: (next: number) =>
|
|
196
|
+
get: () => inFlight()?.perPage ?? pagination.value.perPage,
|
|
197
|
+
set: (next: number) => {
|
|
198
|
+
if (isCount(next) && next !== perPage.value) go(1, next);
|
|
199
|
+
},
|
|
111
200
|
});
|
|
112
201
|
|
|
202
|
+
// Laravel's `paginate()` does not clamp: `?page=3` after page 3's last row
|
|
203
|
+
// went answers an empty page 3 of 2. Go to the last page, once per answer.
|
|
204
|
+
let clampedFrom = '';
|
|
205
|
+
onMounted(() =>
|
|
206
|
+
watch(
|
|
207
|
+
pagination,
|
|
208
|
+
({ current, lastPage }) => {
|
|
209
|
+
if (current <= lastPage || lastPage < 1) return;
|
|
210
|
+
if (clampedFrom === inertiaPage.url) return;
|
|
211
|
+
clampedFrom = inertiaPage.url;
|
|
212
|
+
go(lastPage, undefined, { replace: true });
|
|
213
|
+
},
|
|
214
|
+
{ immediate: true }
|
|
215
|
+
)
|
|
216
|
+
);
|
|
217
|
+
|
|
113
218
|
const context: PaginatedResponseContext<T> = {
|
|
114
219
|
totalItems,
|
|
115
220
|
perPage,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// inertia-helpers/usePromiseForm.ts
|
|
2
2
|
import { useLoading } from './useLoading';
|
|
3
3
|
import {
|
|
4
|
+
router,
|
|
4
5
|
useForm,
|
|
5
6
|
type InertiaForm,
|
|
6
7
|
type InertiaFormProps,
|
|
@@ -18,6 +19,17 @@ type FormValue =
|
|
|
18
19
|
|
|
19
20
|
type Options = Parameters<InertiaFormProps<object>['get']>[1];
|
|
20
21
|
|
|
22
|
+
// Inertia 3's per-visit failure callbacks. Inertia 2 has none (it ignores
|
|
23
|
+
// them), only the global `invalid` / `exception` events, heard below.
|
|
24
|
+
type FailureCallbacks = {
|
|
25
|
+
onHttpException?: (response: { status: number }) => boolean | void;
|
|
26
|
+
onNetworkError?: (error: unknown) => boolean | void;
|
|
27
|
+
};
|
|
28
|
+
const onRouterEvent = (
|
|
29
|
+
name: string,
|
|
30
|
+
listener: (event: CustomEvent) => void
|
|
31
|
+
): (() => void) => router.on(name as 'start', listener as never);
|
|
32
|
+
|
|
21
33
|
export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
22
34
|
initialData: T
|
|
23
35
|
) => {
|
|
@@ -32,34 +44,129 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
|
32
44
|
): Promise<boolean> =>
|
|
33
45
|
// true: the server answered and the save went through. false: the
|
|
34
46
|
// visit was cancelled or interrupted, nothing to report. Rejects with
|
|
35
|
-
// the error bag, or for a response that is not Inertia's.
|
|
36
|
-
new Promise<boolean>((
|
|
37
|
-
const {
|
|
47
|
+
// the error bag, or for an error page / a response that is not Inertia's.
|
|
48
|
+
new Promise<boolean>((resolvePromise, rejectPromise) => {
|
|
49
|
+
const {
|
|
50
|
+
onBefore,
|
|
51
|
+
onSuccess,
|
|
52
|
+
onError,
|
|
53
|
+
onHttpException,
|
|
54
|
+
onNetworkError,
|
|
55
|
+
onFinish,
|
|
56
|
+
...visitOptions
|
|
57
|
+
} = options as NonNullable<Options> & FailureCallbacks;
|
|
58
|
+
// Visits sent with these callbacks, and how many finished: a save
|
|
59
|
+
// answered with a 409 redirect (a redirect with a #fragment) is
|
|
60
|
+
// re-sent by Inertia itself with the same callbacks, and that
|
|
61
|
+
// follow-up interrupts the first visit. The last one settles.
|
|
62
|
+
let sent = 0;
|
|
63
|
+
let finished = 0;
|
|
64
|
+
let awaitingBefore = false;
|
|
65
|
+
let failedStatus: number | null = null;
|
|
66
|
+
// Inertia 2: a non-Inertia response or a network error is a global
|
|
67
|
+
// event only, fired before the visit finishes. Heard while this
|
|
68
|
+
// promise is open (an async reload failing meanwhile counts too).
|
|
69
|
+
const unbindFailures = [
|
|
70
|
+
onRouterEvent('invalid', ({ detail }) => {
|
|
71
|
+
failedStatus = (detail as { response: { status: number } }).response
|
|
72
|
+
.status;
|
|
73
|
+
}),
|
|
74
|
+
onRouterEvent('exception', () => void (failedStatus = 0)),
|
|
75
|
+
];
|
|
76
|
+
const settle = () => {
|
|
77
|
+
unbind();
|
|
78
|
+
unbindFailures.forEach((unbindFailure) => unbindFailure());
|
|
79
|
+
};
|
|
80
|
+
const resolve = (value: boolean) => {
|
|
81
|
+
settle();
|
|
82
|
+
resolvePromise(value);
|
|
83
|
+
};
|
|
84
|
+
const reject = (error: unknown) => {
|
|
85
|
+
settle();
|
|
86
|
+
rejectPromise(error);
|
|
87
|
+
};
|
|
88
|
+
// A visit stopped before it starts (its own onBefore returning
|
|
89
|
+
// false, below, or a `before` listener that refuses it) fires no
|
|
90
|
+
// callback at all: no save, resolve false. Registered last, this
|
|
91
|
+
// listener sees what the ones before it decided.
|
|
92
|
+
const unbind = router.on('before', (event) => {
|
|
93
|
+
if (!awaitingBefore) return;
|
|
94
|
+
awaitingBefore = false;
|
|
95
|
+
if (!event.defaultPrevented) return;
|
|
96
|
+
// Held by useConfirmOnRouteLeave's dialog: "Discard changes"
|
|
97
|
+
// re-sends it with these callbacks, "Keep editing" finishes it
|
|
98
|
+
// as cancelled. Either one settles this promise.
|
|
99
|
+
if (
|
|
100
|
+
(event.detail.visit as { awaitingConfirm?: boolean })
|
|
101
|
+
.awaitingConfirm
|
|
102
|
+
)
|
|
103
|
+
return;
|
|
104
|
+
if (--sent === finished) resolve(false);
|
|
105
|
+
});
|
|
38
106
|
|
|
39
107
|
form[method](url, {
|
|
40
108
|
...visitOptions,
|
|
41
|
-
|
|
42
|
-
|
|
109
|
+
onBefore: (visit) => {
|
|
110
|
+
const answer = onBefore?.(visit);
|
|
111
|
+
if (answer === false) {
|
|
112
|
+
if (sent === finished) resolve(false);
|
|
113
|
+
} else {
|
|
114
|
+
sent++;
|
|
115
|
+
awaitingBefore = true;
|
|
116
|
+
}
|
|
117
|
+
return answer;
|
|
118
|
+
},
|
|
119
|
+
onSuccess: (page) => {
|
|
120
|
+
onSuccess?.(page);
|
|
121
|
+
// An error page Inertia renders (a 403 / 500 with `x-inertia`)
|
|
122
|
+
// lands like a success: it is not a save.
|
|
123
|
+
if (failedStatus === null) resolve(true);
|
|
124
|
+
else reject(new Error(`Inertia visit answered ${failedStatus}.`));
|
|
125
|
+
},
|
|
126
|
+
onError: (errors) => {
|
|
127
|
+
onError?.(errors);
|
|
128
|
+
reject(errors);
|
|
129
|
+
},
|
|
130
|
+
...({
|
|
131
|
+
onHttpException: (response) => {
|
|
132
|
+
failedStatus = response.status;
|
|
133
|
+
return onHttpException?.(response);
|
|
134
|
+
},
|
|
135
|
+
onNetworkError: (error) => {
|
|
136
|
+
failedStatus = 0;
|
|
137
|
+
return onNetworkError?.(error);
|
|
138
|
+
},
|
|
139
|
+
} satisfies FailureCallbacks),
|
|
43
140
|
onFinish: (
|
|
44
141
|
...args: Parameters<NonNullable<NonNullable<Options>['onFinish']>>
|
|
45
142
|
) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
143
|
+
onFinish?.(...args);
|
|
144
|
+
finished++;
|
|
145
|
+
// A cancelled visit (`cancel()`) or one a newer visit
|
|
49
146
|
// interrupted is no failure, and no save either: it reaches
|
|
50
|
-
// only onFinish. Resolve false
|
|
147
|
+
// only onFinish. Resolve false, unless it is Inertia's own
|
|
148
|
+
// 409 follow-up that interrupted it: that one settles.
|
|
51
149
|
const [visit] = args as unknown as [
|
|
52
150
|
{ cancelled?: boolean; interrupted?: boolean } | undefined,
|
|
53
151
|
];
|
|
54
152
|
if (visit?.cancelled || visit?.interrupted) {
|
|
55
|
-
resolve(false);
|
|
153
|
+
if (sent <= finished) resolve(false);
|
|
56
154
|
return;
|
|
57
155
|
}
|
|
58
156
|
// A non-Inertia response (e.g. a 423 entity lock or a 419
|
|
59
|
-
// CSRF mismatch) fires neither onSuccess
|
|
60
|
-
// onFinish. Settle here so isSubmitting is
|
|
61
|
-
// of hanging the form forever. No-op once
|
|
62
|
-
|
|
157
|
+
// CSRF mismatch) or a network error fires neither onSuccess
|
|
158
|
+
// nor onError, only onFinish. Settle here so isSubmitting is
|
|
159
|
+
// released instead of hanging the form forever. No-op once
|
|
160
|
+
// already settled.
|
|
161
|
+
if (failedStatus !== null) {
|
|
162
|
+
reject(
|
|
163
|
+
new Error('Inertia visit did not return a valid response.')
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// `Inertia::location()` (an external redirect after the save)
|
|
168
|
+
// fires only onFinish too, while the browser leaves.
|
|
169
|
+
resolve(true);
|
|
63
170
|
},
|
|
64
171
|
});
|
|
65
172
|
})
|
|
@@ -1,19 +1,141 @@
|
|
|
1
1
|
// inertia-helpers/useQuery.ts
|
|
2
2
|
// requires: npm i query-string
|
|
3
|
-
import {
|
|
3
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
4
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
4
5
|
import qs, { type StringifyOptions } from 'query-string';
|
|
6
|
+
import { shallowRef } from 'vue';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How arrays sit in the query string, for every helper that reads or writes
|
|
10
|
+
* it: `tags=a,b`, what Spatie QueryBuilder parses. Change it here, once —
|
|
11
|
+
* a reader and a writer that disagree turn an array into a string.
|
|
12
|
+
*/
|
|
13
|
+
export const QUERY_ARRAY_FORMAT = 'comma';
|
|
14
|
+
|
|
15
|
+
/** A URL's parts. The first `?` starts the query; a `#` ends it. */
|
|
16
|
+
export const splitUrl = (url: string) => {
|
|
17
|
+
const hashAt = url.indexOf('#');
|
|
18
|
+
const hash = hashAt < 0 ? '' : url.slice(hashAt);
|
|
19
|
+
const rest = hashAt < 0 ? url : url.slice(0, hashAt);
|
|
20
|
+
const queryAt = rest.indexOf('?');
|
|
21
|
+
return queryAt < 0
|
|
22
|
+
? { path: rest, query: '', hash }
|
|
23
|
+
: { path: rest.slice(0, queryAt), query: rest.slice(queryAt + 1), hash };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The URL the last write is heading to, while its visit is in flight. Every
|
|
28
|
+
* reader and writer builds on it, so two writes before the server answers
|
|
29
|
+
* both land, and a write reads back at once. It counts only while `page.url`
|
|
30
|
+
* is still the one it was built on: any visit that lands supersedes it.
|
|
31
|
+
*/
|
|
32
|
+
const pending = shallowRef<{ from: string; url: string } | null>(null);
|
|
33
|
+
|
|
34
|
+
/** `page.url`, or where the write in flight is taking it. */
|
|
35
|
+
export const currentUrl = (): string => {
|
|
36
|
+
const { url } = usePage();
|
|
37
|
+
return pending.value?.from === url ? pending.value.url : url;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const decodeKey = (raw: string): string => {
|
|
41
|
+
try {
|
|
42
|
+
return decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
43
|
+
} catch {
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `url` with `changes` written in. A nullish, empty-string or empty-array
|
|
50
|
+
* value removes its key. Every other parameter is kept as it was written, in
|
|
51
|
+
* its place: a write never re-encodes or re-orders what it does not own.
|
|
52
|
+
*/
|
|
53
|
+
export const withQuery = (
|
|
54
|
+
url: string,
|
|
55
|
+
changes: Record<string, unknown>
|
|
56
|
+
): string => {
|
|
57
|
+
const { path, query, hash } = splitUrl(url);
|
|
58
|
+
const encoded = (key: string): string[] => {
|
|
59
|
+
const value = changes[key];
|
|
60
|
+
if (value == null || value === '') return [];
|
|
61
|
+
if (Array.isArray(value) && value.length === 0) return [];
|
|
62
|
+
return [
|
|
63
|
+
qs.stringify({ [key]: value }, { arrayFormat: QUERY_ARRAY_FORMAT }),
|
|
64
|
+
];
|
|
65
|
+
};
|
|
66
|
+
const written = new Set<string>();
|
|
67
|
+
const parts = query
|
|
68
|
+
.split('&')
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.flatMap((part) => {
|
|
71
|
+
const key = decodeKey(part.split('=')[0] ?? '');
|
|
72
|
+
if (!(key in changes)) return [part];
|
|
73
|
+
if (written.has(key)) return [];
|
|
74
|
+
written.add(key);
|
|
75
|
+
return encoded(key);
|
|
76
|
+
});
|
|
77
|
+
Object.keys(changes)
|
|
78
|
+
.filter((key) => !written.has(key))
|
|
79
|
+
.forEach((key) => parts.push(...encoded(key)));
|
|
80
|
+
return `${path}${parts.length ? `?${parts.join('&')}` : ''}${hash}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Takes the page to `url`: an Inertia visit, or with `server: false` a URL
|
|
85
|
+
* write with no request. Readers see `url` from now until the visit finishes.
|
|
86
|
+
*/
|
|
87
|
+
export const visitUrl = (
|
|
88
|
+
url: string,
|
|
89
|
+
options: Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>,
|
|
90
|
+
server = true
|
|
91
|
+
): void => {
|
|
92
|
+
const from = usePage().url;
|
|
93
|
+
const write = { from, url };
|
|
94
|
+
pending.value = write;
|
|
95
|
+
const settle = () => {
|
|
96
|
+
if (pending.value === write) pending.value = null;
|
|
97
|
+
};
|
|
98
|
+
if (server) {
|
|
99
|
+
let started = false;
|
|
100
|
+
router.visit(url, {
|
|
101
|
+
...options,
|
|
102
|
+
onStart: (visit) => {
|
|
103
|
+
started = true;
|
|
104
|
+
options.onStart?.(visit);
|
|
105
|
+
},
|
|
106
|
+
onFinish: (visit) => {
|
|
107
|
+
settle();
|
|
108
|
+
options.onFinish?.(visit);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
// A visit a `before` listener refused never starts, and never finishes.
|
|
112
|
+
if (!started) settle();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const { preserveState, preserveScroll, replace } = options;
|
|
116
|
+
const client = { url, preserveState, preserveScroll, onFinish: settle };
|
|
117
|
+
if (replace) router.replace(client);
|
|
118
|
+
else router.push(client);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** The query of `url` as an object, the way the server reads it. */
|
|
122
|
+
export const parseQuery = (
|
|
123
|
+
url: string,
|
|
124
|
+
arrayFormat: StringifyOptions['arrayFormat'] = QUERY_ARRAY_FORMAT
|
|
125
|
+
): Record<string, unknown> => {
|
|
126
|
+
const { query } = splitUrl(url);
|
|
127
|
+
if (!query) return {};
|
|
128
|
+
// `qs.parse` decodes once. An encoded comma separates too: Laravel's pager
|
|
129
|
+
// links write `a%2Cb`, which PHP decodes to `a,b` before Spatie splits it.
|
|
130
|
+
return qs.parse(
|
|
131
|
+
arrayFormat === 'comma' ? query.replace(/%2C/gi, ',') : query,
|
|
132
|
+
{ arrayFormat }
|
|
133
|
+
);
|
|
134
|
+
};
|
|
5
135
|
|
|
6
136
|
export const useQuery = ({
|
|
7
|
-
arrayFormat =
|
|
137
|
+
arrayFormat = QUERY_ARRAY_FORMAT,
|
|
8
138
|
}: { arrayFormat?: StringifyOptions['arrayFormat'] } = {}): Record<
|
|
9
139
|
string,
|
|
10
140
|
unknown
|
|
11
|
-
> =>
|
|
12
|
-
const { url } = usePage();
|
|
13
|
-
const [, queryString] = url.split('?');
|
|
14
|
-
if (!queryString) return {};
|
|
15
|
-
return qs.parse(decodeURIComponent(queryString), {
|
|
16
|
-
arrayFormat,
|
|
17
|
-
decode: true,
|
|
18
|
-
});
|
|
19
|
-
};
|
|
141
|
+
> => parseQuery(currentUrl(), arrayFormat);
|