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
|
@@ -76,6 +76,12 @@ nothing imports them. Your own `@/utils` and `@/types` stay yours. If the app
|
|
|
76
76
|
auto-imports `composables/**`, exclude one of the two trees until the old
|
|
77
77
|
copies are gone, or both export `useQuery`.
|
|
78
78
|
|
|
79
|
+
The helpers run and type-check on Inertia 2 and 3 (`@inertiajs/vue3` 2.x or
|
|
80
|
+
3.x). On Inertia 2, `useLazyProp`'s reload cannot keep the page's validation
|
|
81
|
+
errors (Inertia 2 has no `preserveErrors`), and `usePromiseForm` hears a failed
|
|
82
|
+
save through Inertia's global `invalid` / `exception` events, so an async reload
|
|
83
|
+
that fails while a save is out counts as that save failing.
|
|
84
|
+
|
|
79
85
|
The helpers need these npm packages beyond the app's own
|
|
80
86
|
`@inertiajs/vue3`/`vue` (`add` prints the ones a helper needs). Install them
|
|
81
87
|
explicitly rather than relying on them being hoisted through `bitboss-ui`'s own
|
|
@@ -91,10 +97,12 @@ npm i -D @inertiajs/core
|
|
|
91
97
|
`usePageShellContext`) and `useDeleteCmd`/`useScreenSizes`; `object-code`
|
|
92
98
|
powers the `isEqual` hash-comparison utility below; `query-string` backs
|
|
93
99
|
`useQuery`/`useRouteQuery`/`useQueryObject`; `@inertiajs/core` is a type-only
|
|
94
|
-
import (`PageProps` in
|
|
95
|
-
does not re-export, so a strict pnpm app fails to type-check without it.
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
import (`PageProps`, `VisitOptions`, in the URL and context helpers) that `@inertiajs/vue3`
|
|
101
|
+
does not re-export, so a strict pnpm app fails to type-check without it. Install
|
|
102
|
+
the version your `@inertiajs/vue3` runs on (`add` prints it pinned, e.g.
|
|
103
|
+
`@inertiajs/core@3.1.1`): a newer core's visit types do not match. The
|
|
104
|
+
helpers' shared types, tiny utilities and words are three files of the
|
|
105
|
+
folder, `types.ts`, `utils.ts` and `messages.ts`, below.
|
|
98
106
|
|
|
99
107
|
## Shared types and utilities
|
|
100
108
|
|
|
@@ -158,10 +166,76 @@ export const isPlainObject = (
|
|
|
158
166
|
typeof value === 'object' &&
|
|
159
167
|
value !== null &&
|
|
160
168
|
Object.getPrototypeOf(value) === Object.prototype;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Deep copy of server data (arrays and plain objects; anything else, e.g. a
|
|
172
|
+
* Date an app put there, is shared). Reads through Vue proxies, which
|
|
173
|
+
* `structuredClone` refuses.
|
|
174
|
+
*/
|
|
175
|
+
export const cloneDeep = <T>(value: T): T =>
|
|
176
|
+
(Array.isArray(value)
|
|
177
|
+
? value.map(cloneDeep)
|
|
178
|
+
: isPlainObject(value)
|
|
179
|
+
? Object.fromEntries(
|
|
180
|
+
Object.entries(value).map(([key, item]) => [key, cloneDeep(item)])
|
|
181
|
+
)
|
|
182
|
+
: value) as T;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`messages.ts` holds the words a helper shows (today the leave guard's dialog),
|
|
186
|
+
by the library's `locale` (`useBbConfig().locale`): `it-IT`, else `it`, else
|
|
187
|
+
`en`. It is the one helper file meant to be edited: add your locales next to
|
|
188
|
+
`en`. `helpers update` keeps your copy; when a release adds words here, it
|
|
189
|
+
holds back this file and the helpers that read it until you merge them
|
|
190
|
+
(`npx bitboss-ui helpers diff messages`).
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// inertia-helpers/messages.ts
|
|
194
|
+
// The words the helpers show, by the library's `locale`. Yours to edit: add
|
|
195
|
+
// your app's locales next to `en` (keyed as you set `locale`: `it`, or `it-IT`
|
|
196
|
+
// to tell regions apart). `helpers update` keeps your copy; if a release
|
|
197
|
+
// adds words here, `npx bitboss-ui helpers diff messages` shows what to merge.
|
|
198
|
+
import { useBbConfig } from 'bitboss-ui';
|
|
199
|
+
import { computed } from 'vue';
|
|
200
|
+
|
|
201
|
+
export type HelperMessages = {
|
|
202
|
+
leave: { title: string; text: string; yes: string; no: string };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const messages: Record<string, HelperMessages> = {
|
|
206
|
+
en: {
|
|
207
|
+
leave: {
|
|
208
|
+
title: 'Unsaved changes',
|
|
209
|
+
text: 'You have unsaved changes. Leave without saving?',
|
|
210
|
+
yes: 'Discard changes',
|
|
211
|
+
no: 'Keep editing',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** The messages for the current locale: `it-IT`, else `it`, else `en`. */
|
|
217
|
+
export const useHelperMessages = () => {
|
|
218
|
+
const { locale } = useBbConfig();
|
|
219
|
+
return computed(
|
|
220
|
+
() =>
|
|
221
|
+
messages[locale.value] ??
|
|
222
|
+
messages[locale.value.split('-')[0]!.toLowerCase()] ??
|
|
223
|
+
messages.en!
|
|
224
|
+
);
|
|
225
|
+
};
|
|
161
226
|
```
|
|
162
227
|
|
|
163
228
|
---
|
|
164
229
|
|
|
230
|
+
**Unit-testing code that uses the helpers.** A test that mocks
|
|
231
|
+
`@inertiajs/vue3` has to follow Inertia's own contract, or the helpers read the
|
|
232
|
+
mock as a refused or broken visit: `router.visit` / `reload` call `onStart`
|
|
233
|
+
synchronously (a visit with no `onStart` counts as refused before it started);
|
|
234
|
+
`onFinish` receives the visit with `cancelled` / `interrupted` / `completed`;
|
|
235
|
+
`router.on` returns its unbind function. `usePaginatedResponseContext` clamps a
|
|
236
|
+
page past the end in `onMounted`, so call it from a mounted component, not a
|
|
237
|
+
bare `effectScope`.
|
|
238
|
+
|
|
165
239
|
## Page props
|
|
166
240
|
|
|
167
241
|
### useProp / useOptionalProp
|
|
@@ -254,9 +328,17 @@ when the data lands. `loading`/`loaded`/`error` expose the fetch state;
|
|
|
254
328
|
```ts
|
|
255
329
|
// inertia-helpers/useLazyProp.ts
|
|
256
330
|
import { isEqual, isNil, isNotNil } from './utils';
|
|
331
|
+
import type { PageProps } from '@inertiajs/core';
|
|
257
332
|
import { router, usePage } from '@inertiajs/vue3';
|
|
258
333
|
import type { Ref } from 'vue';
|
|
259
|
-
import {
|
|
334
|
+
import {
|
|
335
|
+
customRef,
|
|
336
|
+
nextTick,
|
|
337
|
+
onBeforeUnmount,
|
|
338
|
+
readonly,
|
|
339
|
+
ref,
|
|
340
|
+
watch,
|
|
341
|
+
} from 'vue';
|
|
260
342
|
|
|
261
343
|
export type UseLazyPropOptions = {
|
|
262
344
|
/** Keep the last non-nil value across visits that reset the prop. Default true. */
|
|
@@ -288,7 +370,9 @@ export function useLazyProp<T>(
|
|
|
288
370
|
initialValue?: T,
|
|
289
371
|
options: UseLazyPropOptions = {}
|
|
290
372
|
): UseLazyProp<T | undefined> {
|
|
291
|
-
|
|
373
|
+
// `PageProps &` keeps usePage's constraint satisfied in an app that
|
|
374
|
+
// augments Inertia's `PageProps` (TS2344 otherwise).
|
|
375
|
+
const page = usePage<PageProps & { [key: string]: T | undefined }>();
|
|
292
376
|
const { cache = true } = options;
|
|
293
377
|
const loading = ref(false);
|
|
294
378
|
const loaded = ref(page.props[propName] !== undefined);
|
|
@@ -296,50 +380,107 @@ export function useLazyProp<T>(
|
|
|
296
380
|
let inFlightPromise: Promise<void> | null = null;
|
|
297
381
|
let isUnmounted = false;
|
|
298
382
|
let cached = page.props[propName];
|
|
383
|
+
// Settles a reload a navigation cancelled, once that navigation is over.
|
|
384
|
+
let resumeCancelled: (() => void) | null = null;
|
|
299
385
|
|
|
300
386
|
onBeforeUnmount(() => {
|
|
301
387
|
isUnmounted = true;
|
|
388
|
+
resumeCancelled?.();
|
|
302
389
|
});
|
|
303
390
|
|
|
304
391
|
const loadProp = (onSuccess: () => void): Promise<void> => {
|
|
305
392
|
if (inFlightPromise) return inFlightPromise;
|
|
306
393
|
|
|
307
394
|
error.value = null;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
395
|
+
let started = false;
|
|
396
|
+
let succeeded = false;
|
|
397
|
+
let settle!: () => void;
|
|
398
|
+
const promise = new Promise<void>((resolve) => {
|
|
399
|
+
settle = () => {
|
|
400
|
+
if (!isUnmounted) loading.value = false;
|
|
401
|
+
if (inFlightPromise === promise) inFlightPromise = null;
|
|
402
|
+
resolve();
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
inFlightPromise = promise;
|
|
406
|
+
|
|
407
|
+
router.reload({
|
|
408
|
+
only: [propName],
|
|
409
|
+
replace: true,
|
|
410
|
+
// Like Inertia's own deferred-props loader: keep the page's
|
|
411
|
+
// validation errors (Inertia 3; Inertia 2 has no such option).
|
|
412
|
+
...({ preserveErrors: true } as object),
|
|
413
|
+
...(options.headers ? { headers: options.headers } : {}),
|
|
414
|
+
onStart: () => {
|
|
415
|
+
started = true;
|
|
416
|
+
loading.value = true;
|
|
417
|
+
},
|
|
418
|
+
onSuccess: () => {
|
|
419
|
+
succeeded = true;
|
|
420
|
+
if (!isUnmounted) {
|
|
421
|
+
loaded.value = true;
|
|
422
|
+
cached = page.props[propName];
|
|
423
|
+
onSuccess();
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
onError: (errors) => {
|
|
427
|
+
if (!isUnmounted) {
|
|
428
|
+
error.value =
|
|
429
|
+
errors instanceof Error
|
|
430
|
+
? errors
|
|
431
|
+
: new Error(`Failed to load lazy prop "${propName}"`);
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
onFinish: (visit) => {
|
|
435
|
+
// A visit to another page cancels this reload: no failure. Stay
|
|
436
|
+
// loading (a reader must not re-fire it at the outgoing URL) until
|
|
437
|
+
// that navigation is over, then retry if this page is still
|
|
438
|
+
// mounted (a same-component visit) or settle if it unmounted.
|
|
439
|
+
if (visit.cancelled) {
|
|
440
|
+
const cancelledOn = page.url;
|
|
441
|
+
const unbindFinish = router.on('finish', (event) => {
|
|
442
|
+
const next = event.detail.visit;
|
|
443
|
+
if (next.cancelled || next.interrupted) return;
|
|
444
|
+
// An async visit is over too (a mutation answered on another
|
|
445
|
+
// URL redirects back with no other visit), unless it is one
|
|
446
|
+
// of this page's own reloads (a poll, another lazy prop).
|
|
447
|
+
if (
|
|
448
|
+
next.async &&
|
|
449
|
+
next.url.href === new URL(cancelledOn, next.url).href
|
|
450
|
+
)
|
|
451
|
+
return;
|
|
452
|
+
resumeCancelled?.();
|
|
453
|
+
});
|
|
454
|
+
// Back / Forward swaps the page with no finish event.
|
|
455
|
+
const unbindNavigate = router.on('navigate', () =>
|
|
456
|
+
resumeCancelled?.()
|
|
457
|
+
);
|
|
458
|
+
resumeCancelled = () => {
|
|
459
|
+
resumeCancelled = null;
|
|
460
|
+
unbindFinish();
|
|
461
|
+
unbindNavigate();
|
|
462
|
+
void nextTick(() => {
|
|
463
|
+
inFlightPromise = null;
|
|
464
|
+
if (isUnmounted || loaded.value) settle();
|
|
465
|
+
else void loadProp(onSuccess).then(settle);
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// A network error or a non-Inertia response (Inertia's error
|
|
471
|
+
// dialog) reaches only onFinish: report it. The self-loading read
|
|
472
|
+
// does not retry a failed load; `execute()` does.
|
|
473
|
+
if (!succeeded && !error.value && !isUnmounted) {
|
|
474
|
+
error.value = new Error(`Failed to load lazy prop "${propName}"`);
|
|
475
|
+
}
|
|
476
|
+
settle();
|
|
477
|
+
},
|
|
340
478
|
});
|
|
479
|
+
// Refused before it started (a `before` listener cancelled it, or no
|
|
480
|
+
// `window` during SSR): no callback will ever come.
|
|
481
|
+
if (!started) settle();
|
|
341
482
|
|
|
342
|
-
return
|
|
483
|
+
return promise;
|
|
343
484
|
};
|
|
344
485
|
|
|
345
486
|
/** Self-loading ref: reading it while the prop is absent starts the fetch. */
|
|
@@ -348,8 +489,7 @@ export function useLazyProp<T>(
|
|
|
348
489
|
track();
|
|
349
490
|
const isMissing = page.props[propName] === undefined;
|
|
350
491
|
|
|
351
|
-
if (isMissing && !loaded.value && !loading.value) {
|
|
352
|
-
loading.value = true;
|
|
492
|
+
if (isMissing && !loaded.value && !loading.value && !error.value) {
|
|
353
493
|
void loadProp(trigger);
|
|
354
494
|
}
|
|
355
495
|
|
|
@@ -408,6 +548,10 @@ Gotchas:
|
|
|
408
548
|
load `stats.revenue`, only `stats`.
|
|
409
549
|
- Filter navigations reset lazy props server-side; with `cache: true`
|
|
410
550
|
(default) the last value keeps rendering while the fresh one loads.
|
|
551
|
+
- A failed load (offline, a non-Inertia 500) sets `error` and is **not**
|
|
552
|
+
retried by the next read — that would be a request storm. `execute()`
|
|
553
|
+
retries. A load a navigation cancels is no failure: it stays `loading`
|
|
554
|
+
until that navigation is over, then retries if the page is still mounted.
|
|
411
555
|
|
|
412
556
|
### useLazyPropGetter
|
|
413
557
|
|
|
@@ -458,7 +602,9 @@ export function useLazyPropGetter<T, U = T>(
|
|
|
458
602
|
if (!loaded.value) {
|
|
459
603
|
await execute();
|
|
460
604
|
}
|
|
461
|
-
|
|
605
|
+
// `loaded` first: reading `value` while unloaded would start a
|
|
606
|
+
// second, background reload.
|
|
607
|
+
if (!loaded.value || isNil(value.value)) {
|
|
462
608
|
throw new Error(`Could not load lazy prop "${propName}"`);
|
|
463
609
|
}
|
|
464
610
|
if (isNil(formatter)) return value.value as unknown as U;
|
|
@@ -496,29 +642,163 @@ navigation.
|
|
|
496
642
|
|
|
497
643
|
### useQuery
|
|
498
644
|
|
|
499
|
-
Parses the current page URL's query string into an object
|
|
500
|
-
|
|
645
|
+
Parses the current page URL's query string into an object, the way the
|
|
646
|
+
server reads it: decoded once, comma arrays split (an encoded `%2C` too, as
|
|
647
|
+
PHP decodes it before Spatie splits — so with the comma format a comma inside
|
|
648
|
+
a free-text value reads back as a separator, exactly as the server sees it).
|
|
649
|
+
Snapshot, not reactive — used by the writers below.
|
|
650
|
+
|
|
651
|
+
The file is also the one URL writer every helper shares (`withQuery`,
|
|
652
|
+
`visitUrl`): while a write's visit is in flight, every reader sees the URL it
|
|
653
|
+
is heading to and every writer builds on it. So a sort and a direction set in
|
|
654
|
+
one click both land, a filter change followed by `page = 1` keeps the filter,
|
|
655
|
+
undoing a write before the server answers is not swallowed, and a ref reads
|
|
656
|
+
back what was just written. Any visit that lands supersedes it. A write
|
|
657
|
+
changes only its own keys: the other parameters keep their order and
|
|
658
|
+
encoding, and a `#fragment` stays a fragment.
|
|
501
659
|
|
|
502
660
|
```ts
|
|
503
661
|
// inertia-helpers/useQuery.ts
|
|
504
662
|
// requires: npm i query-string
|
|
505
|
-
import {
|
|
663
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
664
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
506
665
|
import qs, { type StringifyOptions } from 'query-string';
|
|
666
|
+
import { shallowRef } from 'vue';
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* How arrays sit in the query string, for every helper that reads or writes
|
|
670
|
+
* it: `tags=a,b`, what Spatie QueryBuilder parses. Change it here, once —
|
|
671
|
+
* a reader and a writer that disagree turn an array into a string.
|
|
672
|
+
*/
|
|
673
|
+
export const QUERY_ARRAY_FORMAT = 'comma';
|
|
674
|
+
|
|
675
|
+
/** A URL's parts. The first `?` starts the query; a `#` ends it. */
|
|
676
|
+
export const splitUrl = (url: string) => {
|
|
677
|
+
const hashAt = url.indexOf('#');
|
|
678
|
+
const hash = hashAt < 0 ? '' : url.slice(hashAt);
|
|
679
|
+
const rest = hashAt < 0 ? url : url.slice(0, hashAt);
|
|
680
|
+
const queryAt = rest.indexOf('?');
|
|
681
|
+
return queryAt < 0
|
|
682
|
+
? { path: rest, query: '', hash }
|
|
683
|
+
: { path: rest.slice(0, queryAt), query: rest.slice(queryAt + 1), hash };
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The URL the last write is heading to, while its visit is in flight. Every
|
|
688
|
+
* reader and writer builds on it, so two writes before the server answers
|
|
689
|
+
* both land, and a write reads back at once. It counts only while `page.url`
|
|
690
|
+
* is still the one it was built on: any visit that lands supersedes it.
|
|
691
|
+
*/
|
|
692
|
+
const pending = shallowRef<{ from: string; url: string } | null>(null);
|
|
693
|
+
|
|
694
|
+
/** `page.url`, or where the write in flight is taking it. */
|
|
695
|
+
export const currentUrl = (): string => {
|
|
696
|
+
const { url } = usePage();
|
|
697
|
+
return pending.value?.from === url ? pending.value.url : url;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const decodeKey = (raw: string): string => {
|
|
701
|
+
try {
|
|
702
|
+
return decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
703
|
+
} catch {
|
|
704
|
+
return raw;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `url` with `changes` written in. A nullish, empty-string or empty-array
|
|
710
|
+
* value removes its key. Every other parameter is kept as it was written, in
|
|
711
|
+
* its place: a write never re-encodes or re-orders what it does not own.
|
|
712
|
+
*/
|
|
713
|
+
export const withQuery = (
|
|
714
|
+
url: string,
|
|
715
|
+
changes: Record<string, unknown>
|
|
716
|
+
): string => {
|
|
717
|
+
const { path, query, hash } = splitUrl(url);
|
|
718
|
+
const encoded = (key: string): string[] => {
|
|
719
|
+
const value = changes[key];
|
|
720
|
+
if (value == null || value === '') return [];
|
|
721
|
+
if (Array.isArray(value) && value.length === 0) return [];
|
|
722
|
+
return [
|
|
723
|
+
qs.stringify({ [key]: value }, { arrayFormat: QUERY_ARRAY_FORMAT }),
|
|
724
|
+
];
|
|
725
|
+
};
|
|
726
|
+
const written = new Set<string>();
|
|
727
|
+
const parts = query
|
|
728
|
+
.split('&')
|
|
729
|
+
.filter(Boolean)
|
|
730
|
+
.flatMap((part) => {
|
|
731
|
+
const key = decodeKey(part.split('=')[0] ?? '');
|
|
732
|
+
if (!(key in changes)) return [part];
|
|
733
|
+
if (written.has(key)) return [];
|
|
734
|
+
written.add(key);
|
|
735
|
+
return encoded(key);
|
|
736
|
+
});
|
|
737
|
+
Object.keys(changes)
|
|
738
|
+
.filter((key) => !written.has(key))
|
|
739
|
+
.forEach((key) => parts.push(...encoded(key)));
|
|
740
|
+
return `${path}${parts.length ? `?${parts.join('&')}` : ''}${hash}`;
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Takes the page to `url`: an Inertia visit, or with `server: false` a URL
|
|
745
|
+
* write with no request. Readers see `url` from now until the visit finishes.
|
|
746
|
+
*/
|
|
747
|
+
export const visitUrl = (
|
|
748
|
+
url: string,
|
|
749
|
+
options: Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>,
|
|
750
|
+
server = true
|
|
751
|
+
): void => {
|
|
752
|
+
const from = usePage().url;
|
|
753
|
+
const write = { from, url };
|
|
754
|
+
pending.value = write;
|
|
755
|
+
const settle = () => {
|
|
756
|
+
if (pending.value === write) pending.value = null;
|
|
757
|
+
};
|
|
758
|
+
if (server) {
|
|
759
|
+
let started = false;
|
|
760
|
+
router.visit(url, {
|
|
761
|
+
...options,
|
|
762
|
+
onStart: (visit) => {
|
|
763
|
+
started = true;
|
|
764
|
+
options.onStart?.(visit);
|
|
765
|
+
},
|
|
766
|
+
onFinish: (visit) => {
|
|
767
|
+
settle();
|
|
768
|
+
options.onFinish?.(visit);
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
// A visit a `before` listener refused never starts, and never finishes.
|
|
772
|
+
if (!started) settle();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const { preserveState, preserveScroll, replace } = options;
|
|
776
|
+
const client = { url, preserveState, preserveScroll, onFinish: settle };
|
|
777
|
+
if (replace) router.replace(client);
|
|
778
|
+
else router.push(client);
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
/** The query of `url` as an object, the way the server reads it. */
|
|
782
|
+
export const parseQuery = (
|
|
783
|
+
url: string,
|
|
784
|
+
arrayFormat: StringifyOptions['arrayFormat'] = QUERY_ARRAY_FORMAT
|
|
785
|
+
): Record<string, unknown> => {
|
|
786
|
+
const { query } = splitUrl(url);
|
|
787
|
+
if (!query) return {};
|
|
788
|
+
// `qs.parse` decodes once. An encoded comma separates too: Laravel's pager
|
|
789
|
+
// links write `a%2Cb`, which PHP decodes to `a,b` before Spatie splits it.
|
|
790
|
+
return qs.parse(
|
|
791
|
+
arrayFormat === 'comma' ? query.replace(/%2C/gi, ',') : query,
|
|
792
|
+
{ arrayFormat }
|
|
793
|
+
);
|
|
794
|
+
};
|
|
507
795
|
|
|
508
796
|
export const useQuery = ({
|
|
509
|
-
arrayFormat =
|
|
797
|
+
arrayFormat = QUERY_ARRAY_FORMAT,
|
|
510
798
|
}: { arrayFormat?: StringifyOptions['arrayFormat'] } = {}): Record<
|
|
511
799
|
string,
|
|
512
800
|
unknown
|
|
513
|
-
> =>
|
|
514
|
-
const { url } = usePage();
|
|
515
|
-
const [, queryString] = url.split('?');
|
|
516
|
-
if (!queryString) return {};
|
|
517
|
-
return qs.parse(decodeURIComponent(queryString), {
|
|
518
|
-
arrayFormat,
|
|
519
|
-
decode: true,
|
|
520
|
-
});
|
|
521
|
-
};
|
|
801
|
+
> => parseQuery(currentUrl(), arrayFormat);
|
|
522
802
|
```
|
|
523
803
|
|
|
524
804
|
**Don't swap `query-string` for a hand-rolled `URLSearchParams` reader.** It
|
|
@@ -535,14 +815,19 @@ assume the parsed-array shape. Install the dependency.
|
|
|
535
815
|
### useRouteQuery
|
|
536
816
|
|
|
537
817
|
A single query parameter as a **writable ref**. Reading tracks the current
|
|
538
|
-
URL
|
|
539
|
-
|
|
540
|
-
safe to sync from server
|
|
818
|
+
URL (a repeated key reads its last value, as PHP does); writing merges the
|
|
819
|
+
parameter and performs a (replace) visit, so the server re-renders with the
|
|
820
|
+
new value. Writing the current value is a no-op — safe to sync from server
|
|
821
|
+
responses without visit loops. Writing `null` / `undefined` or the default
|
|
822
|
+
removes the key. Options, as on `useQueryObject`: `visitOptions` (Inertia's
|
|
823
|
+
`VisitOptions`, merged over `preserveState` / `preserveScroll` /
|
|
824
|
+
`replace: true`) and `server: false` for a URL write with no request.
|
|
541
825
|
|
|
542
826
|
```ts
|
|
543
827
|
// inertia-helpers/useRouteQuery.ts
|
|
544
828
|
// requires: npm i query-string
|
|
545
|
-
import {
|
|
829
|
+
import { currentUrl, splitUrl, visitUrl, withQuery } from './useQuery';
|
|
830
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
546
831
|
import qs from 'query-string';
|
|
547
832
|
import {
|
|
548
833
|
computed,
|
|
@@ -554,8 +839,15 @@ import {
|
|
|
554
839
|
type Options<T> = {
|
|
555
840
|
/** Parse the raw string (e.g. `Number`). Defaults to identity. */
|
|
556
841
|
transform?: (value: string) => T;
|
|
557
|
-
|
|
558
|
-
|
|
842
|
+
/** `false` writes the URL with no request. Default `true`. */
|
|
843
|
+
server?: boolean;
|
|
844
|
+
/**
|
|
845
|
+
* Merged over `{ preserveState, preserveScroll, replace: true }`. With
|
|
846
|
+
* `server: false` only those three apply.
|
|
847
|
+
*/
|
|
848
|
+
visitOptions?: MaybeRefOrGetter<
|
|
849
|
+
Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
|
|
850
|
+
>;
|
|
559
851
|
};
|
|
560
852
|
|
|
561
853
|
export function useRouteQuery<T = string>(
|
|
@@ -566,37 +858,34 @@ export function useRouteQuery<T = string>(
|
|
|
566
858
|
): WritableComputedRef<T> {
|
|
567
859
|
const {
|
|
568
860
|
transform = (value) => value as unknown as T,
|
|
569
|
-
|
|
570
|
-
|
|
861
|
+
server = true,
|
|
862
|
+
visitOptions,
|
|
571
863
|
} = options;
|
|
572
|
-
const page = usePage();
|
|
573
864
|
|
|
574
865
|
const readRaw = (): string => {
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
const single = Array.isArray(raw) ? raw
|
|
866
|
+
const raw = qs.parse(splitUrl(currentUrl()).query)[key];
|
|
867
|
+
// A repeated key reads its last value, as PHP does.
|
|
868
|
+
const single = Array.isArray(raw) ? raw.at(-1) : raw;
|
|
578
869
|
return single != null ? String(single) : toValue(defaultValue);
|
|
579
870
|
};
|
|
580
871
|
|
|
581
872
|
return computed<T>({
|
|
582
873
|
get: () => transform(readRaw()),
|
|
583
874
|
set: (next) => {
|
|
584
|
-
|
|
875
|
+
// `null` / `undefined` mean "no value": the key leaves the URL.
|
|
876
|
+
const nextRaw = next == null ? toValue(defaultValue) : String(next);
|
|
585
877
|
if (nextRaw === readRaw()) return;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
preserveState,
|
|
598
|
-
replace: true,
|
|
599
|
-
});
|
|
878
|
+
const value = nextRaw === toValue(defaultValue) ? null : nextRaw; // keep URLs clean at the default
|
|
879
|
+
visitUrl(
|
|
880
|
+
withQuery(currentUrl(), { [key]: value }),
|
|
881
|
+
{
|
|
882
|
+
preserveScroll: true,
|
|
883
|
+
preserveState: true,
|
|
884
|
+
replace: true,
|
|
885
|
+
...toValue(visitOptions),
|
|
886
|
+
},
|
|
887
|
+
server
|
|
888
|
+
);
|
|
600
889
|
},
|
|
601
890
|
});
|
|
602
891
|
}
|
|
@@ -605,103 +894,120 @@ export function useRouteQuery<T = string>(
|
|
|
605
894
|
### useQueryObject
|
|
606
895
|
|
|
607
896
|
The **filter engine**: watch a filters object and mirror its non-empty
|
|
608
|
-
values into the query string with
|
|
609
|
-
seen are cleaned up when they empty out;
|
|
610
|
-
|
|
897
|
+
values into the query string with one Inertia visit per change. Keys it has
|
|
898
|
+
seen are cleaned up when they empty out; keys it does not own (a tab, a sort)
|
|
899
|
+
are kept. Every change also removes `resetKeys` (default `['page']`), so
|
|
900
|
+
results are never hidden on an out-of-range page.
|
|
901
|
+
|
|
902
|
+
| Option | Default | What it does |
|
|
903
|
+
| -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
904
|
+
| `visitOptions` | — | Inertia's own `VisitOptions` (`only`, `headers`, `reset`, callbacks…), merged over `preserveState` / `preserveScroll` / `replace: true` |
|
|
905
|
+
| `debounce` | `{}` | Per-key wait in ms for typed keys: `{ search: 300 }` |
|
|
906
|
+
| `resetKeys` | `['page']` | Keys removed on every change. Passing it **replaces** the default: `['page', 'lots_page']`, or `[]` to keep the page |
|
|
907
|
+
| `paused` | `false` | While `true`, track changes but send nothing; resuming sends the current state once (see below) |
|
|
908
|
+
| `server` | `true` | `false` writes the URL with no request (`router.replace` / `push`); `visitOptions` then takes only `preserveState` / `preserveScroll` / `replace` |
|
|
909
|
+
|
|
910
|
+
Arrays are written in the one `QUERY_ARRAY_FORMAT` of `useQuery.ts` (`comma`:
|
|
911
|
+
`tags=a,b`, what Spatie QueryBuilder parses), the same one `useQuery` reads.
|
|
912
|
+
To send server-shaped keys (`filter[status]`) or reshape values, pass a getter
|
|
913
|
+
that builds them: `useQueryObject(() => ({ 'filter[status]': f.status }))`.
|
|
611
914
|
|
|
612
915
|
```ts
|
|
613
916
|
// inertia-helpers/useQueryObject.ts
|
|
614
917
|
// requires: npm i query-string
|
|
615
|
-
import {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
918
|
+
import {
|
|
919
|
+
QUERY_ARRAY_FORMAT,
|
|
920
|
+
currentUrl,
|
|
921
|
+
parseQuery,
|
|
922
|
+
visitUrl,
|
|
923
|
+
withQuery,
|
|
924
|
+
} from './useQuery';
|
|
925
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
926
|
+
import qs from 'query-string';
|
|
619
927
|
import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from 'vue';
|
|
620
928
|
|
|
929
|
+
/** A server visit's options; the URL, method and data are the helper's. */
|
|
930
|
+
type ServerVisitOptions = Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>;
|
|
931
|
+
/** A URL-only write (`server: false`) has no request to configure. */
|
|
932
|
+
type ClientVisitOptions = Pick<
|
|
933
|
+
VisitOptions,
|
|
934
|
+
'preserveState' | 'preserveScroll' | 'replace'
|
|
935
|
+
>;
|
|
936
|
+
|
|
621
937
|
type Options<T> = {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
938
|
+
/**
|
|
939
|
+
* Query keys removed on every change. Default `['page']`, so a filter
|
|
940
|
+
* change never strands the list on a page past its new end. Passing it
|
|
941
|
+
* REPLACES the default: name `page` too when it should still reset.
|
|
942
|
+
*/
|
|
626
943
|
resetKeys?: string[];
|
|
627
|
-
/** Inertia partial reload prop names (supports reactive getter). */
|
|
628
|
-
only?: MaybeRefOrGetter<string[]>;
|
|
629
944
|
/**
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
945
|
+
* While it resolves to `true`, changes are tracked but no visit is sent.
|
|
946
|
+
* When it goes back to `false`, the object's current state is sent once,
|
|
947
|
+
* if the URL does not already hold it. See the guide for when to use it.
|
|
633
948
|
*/
|
|
634
|
-
|
|
949
|
+
paused?: MaybeRefOrGetter<boolean>;
|
|
635
950
|
/**
|
|
636
951
|
* Keys whose changes wait this many ms before the visit, e.g. a search
|
|
637
952
|
* box: `{ search: 300 }`. A change to any other key visits at once and
|
|
638
953
|
* carries the waiting value with it, so nothing typed is lost.
|
|
639
954
|
*/
|
|
640
955
|
debounce?: Partial<Record<keyof T & string, number>>;
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
956
|
+
} & (
|
|
957
|
+
| {
|
|
958
|
+
/** Default `true`: every change is an Inertia visit. */
|
|
959
|
+
server?: true;
|
|
960
|
+
/** Merged over `{ preserveState, preserveScroll, replace: true }`. */
|
|
961
|
+
visitOptions?: MaybeRefOrGetter<ServerVisitOptions>;
|
|
962
|
+
}
|
|
963
|
+
| {
|
|
964
|
+
/** `false` writes the URL with no request (`router.replace` / `push`). */
|
|
965
|
+
server: false;
|
|
966
|
+
visitOptions?: MaybeRefOrGetter<ClientVisitOptions>;
|
|
967
|
+
}
|
|
968
|
+
);
|
|
646
969
|
|
|
647
970
|
export function useQueryObject<T extends Record<string, unknown>>(
|
|
648
971
|
object: MaybeRefOrGetter<T>,
|
|
649
972
|
options: Options<T> = {}
|
|
650
973
|
): void {
|
|
651
974
|
const {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
resetKeys = [],
|
|
655
|
-
preserveState = true,
|
|
656
|
-
preserveScroll = true,
|
|
657
|
-
replace = true,
|
|
658
|
-
only: onlyOption = [],
|
|
659
|
-
shouldNavigate = true,
|
|
975
|
+
resetKeys = ['page'],
|
|
976
|
+
paused = false,
|
|
660
977
|
debounce = {},
|
|
661
|
-
|
|
978
|
+
server = true,
|
|
979
|
+
visitOptions,
|
|
662
980
|
} = options;
|
|
663
981
|
|
|
664
|
-
const filterValidValues = (obj: T): Partial<T> => {
|
|
665
|
-
const filtered: Partial<T> = {};
|
|
666
|
-
Object.entries(obj).forEach(([key, val]) => {
|
|
667
|
-
if (!isNil(val) && val !== '') {
|
|
668
|
-
(filtered as Record<string, unknown>)[key] = val;
|
|
669
|
-
}
|
|
670
|
-
});
|
|
671
|
-
return filtered;
|
|
672
|
-
};
|
|
673
|
-
|
|
674
982
|
/** Every key this composable has ever managed, for stable URL cleanup. */
|
|
675
983
|
const seenKeys = new Set<string>(Object.keys(toValue(object) ?? {}));
|
|
676
984
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
// Start from the existing query to preserve keys owned elsewhere.
|
|
682
|
-
const existingQuery = useQuery({ arrayFormat });
|
|
683
|
-
seenKeys.forEach((key) => delete existingQuery[key]);
|
|
684
|
-
if (resetPage) delete existingQuery.page;
|
|
685
|
-
resetKeys.forEach((key) => delete existingQuery[key]);
|
|
686
|
-
|
|
687
|
-
Object.entries(transformedValues).forEach(([key, val]) => {
|
|
688
|
-
if (!isEmpty(val)) existingQuery[key] = val;
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
const stringified = qs.stringify(existingQuery, { arrayFormat });
|
|
692
|
-
const newUrl = stringified
|
|
693
|
-
? [window.location.pathname, stringified].join('?')
|
|
694
|
-
: window.location.pathname;
|
|
985
|
+
/** A query in one canonical form, so encoding and key order never count. */
|
|
986
|
+
const canonical = (url: string) =>
|
|
987
|
+
qs.stringify(parseQuery(url), { arrayFormat: QUERY_ARRAY_FORMAT });
|
|
695
988
|
|
|
696
|
-
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
989
|
+
const navigate = (newObject: T) => {
|
|
990
|
+
if (toValue(paused)) return;
|
|
991
|
+
|
|
992
|
+
// Only the reset keys and the keys this object owns change; every
|
|
993
|
+
// other parameter (a tab, a sort) stays as it is.
|
|
994
|
+
const changes: Record<string, unknown> = {};
|
|
995
|
+
resetKeys.forEach((key) => (changes[key] = null));
|
|
996
|
+
seenKeys.forEach((key) => (changes[key] = newObject[key]));
|
|
997
|
+
const from = currentUrl();
|
|
998
|
+
const url = withQuery(from, changes);
|
|
999
|
+
if (canonical(url) === canonical(from)) return;
|
|
1000
|
+
|
|
1001
|
+
visitUrl(
|
|
1002
|
+
url,
|
|
1003
|
+
{
|
|
1004
|
+
preserveState: true,
|
|
1005
|
+
preserveScroll: true,
|
|
1006
|
+
replace: true,
|
|
1007
|
+
...toValue(visitOptions),
|
|
1008
|
+
},
|
|
1009
|
+
server
|
|
1010
|
+
);
|
|
705
1011
|
};
|
|
706
1012
|
|
|
707
1013
|
/** Each key's value as last seen, to tell which keys a change touched. */
|
|
@@ -714,8 +1020,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
714
1020
|
onScopeDispose(() => clearTimeout(pending));
|
|
715
1021
|
|
|
716
1022
|
watch(
|
|
717
|
-
() => toValue(object),
|
|
718
|
-
(newObject) => {
|
|
1023
|
+
[() => toValue(object), () => toValue(paused)],
|
|
1024
|
+
([newObject, isPaused], [, wasPaused]) => {
|
|
719
1025
|
if (!newObject) return;
|
|
720
1026
|
|
|
721
1027
|
Object.keys(newObject).forEach((key) => {
|
|
@@ -734,12 +1040,15 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
734
1040
|
);
|
|
735
1041
|
lastSeen = now;
|
|
736
1042
|
clearTimeout(pending);
|
|
1043
|
+
if (isPaused) return;
|
|
1044
|
+
// Resuming sends what changed while paused, without waiting.
|
|
1045
|
+
const resumed = wasPaused && !isPaused;
|
|
737
1046
|
const waits = changed.map(
|
|
738
1047
|
(key) => (debounce as Record<string, number | undefined>)[key] ?? 0
|
|
739
1048
|
);
|
|
740
1049
|
// Only debounced keys changed: wait for the typing to settle, then
|
|
741
1050
|
// visit with whatever the object holds by then.
|
|
742
|
-
if (changed.length && waits.every((ms) => ms > 0)) {
|
|
1051
|
+
if (!resumed && changed.length && waits.every((ms) => ms > 0)) {
|
|
743
1052
|
pending = setTimeout(
|
|
744
1053
|
() => navigate(toValue(object)),
|
|
745
1054
|
Math.max(...waits)
|
|
@@ -753,7 +1062,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
753
1062
|
* into one watcher run, so a `clearFilters()` that blanks three keys
|
|
754
1063
|
* fires ONE visit instead of three (measured: 3 writes → 3 runs under
|
|
755
1064
|
* `sync`, 1 under the default). The two it saves used to self-cancel,
|
|
756
|
-
* but they were still sent.
|
|
1065
|
+
* but they were still sent. Watching `paused` in the same source means
|
|
1066
|
+
* resuming and the change that comes with it are one run, one visit.
|
|
757
1067
|
*
|
|
758
1068
|
* This does NOT make typing cheap on its own — one visit per settled
|
|
759
1069
|
* keystroke is still one visit per keystroke. `debounce` is for that.
|
|
@@ -766,7 +1076,10 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
766
1076
|
```ts
|
|
767
1077
|
// A listing page's whole filter wiring:
|
|
768
1078
|
const filters = reactive({ search: '', status: null as string | null });
|
|
769
|
-
useQueryObject(filters, {
|
|
1079
|
+
useQueryObject(filters, {
|
|
1080
|
+
visitOptions: { only: ['invoices'] },
|
|
1081
|
+
debounce: { search: 300 },
|
|
1082
|
+
});
|
|
770
1083
|
// Bind BbTextInput / BbSelectPopover to `filters.*` — every change becomes a
|
|
771
1084
|
// partial reload and the server re-renders the `invoices` prop.
|
|
772
1085
|
```
|
|
@@ -775,9 +1088,9 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
|
|
|
775
1088
|
> their own URL writers (the `page` / `perPage` refs of
|
|
776
1089
|
> `usePaginatedResponseContext`, or `useRouteQuery`). Put the page ref in the
|
|
777
1090
|
> object too and a pager click visits twice: once through the ref, once
|
|
778
|
-
> through this watcher. And
|
|
779
|
-
> `page` from the query, then writes the object's value back, so a
|
|
780
|
-
> change keeps the old page, possibly past the last one.
|
|
1091
|
+
> through this watcher. And the page reset stops working: `navigate()`
|
|
1092
|
+
> deletes `page` from the query, then writes the object's value back, so a
|
|
1093
|
+
> filter change keeps the old page, possibly past the last one.
|
|
781
1094
|
|
|
782
1095
|
> **Typing: debounce the key, not the page.** Without `debounce`, "an
|
|
783
1096
|
> Inertia visit per change" is literal: a text field bound straight to
|
|
@@ -787,16 +1100,63 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
|
|
|
787
1100
|
>
|
|
788
1101
|
> ```ts
|
|
789
1102
|
> const filters = reactive({ search: '', status: null as string | null });
|
|
790
|
-
> useQueryObject(filters, {
|
|
1103
|
+
> useQueryObject(filters, {
|
|
1104
|
+
> visitOptions: { only: ['invoices'] },
|
|
1105
|
+
> debounce: { search: 300 },
|
|
1106
|
+
> });
|
|
791
1107
|
> ```
|
|
792
1108
|
>
|
|
793
1109
|
> Typing in the search waits 300 ms of quiet; picking a status visits at once
|
|
794
1110
|
> and takes the typed text with it. Keys not named in `debounce` never wait.
|
|
795
|
-
|
|
796
|
-
> **Programmatic multi-key writes
|
|
797
|
-
>
|
|
798
|
-
>
|
|
799
|
-
|
|
1111
|
+
|
|
1112
|
+
> **Programmatic multi-key writes need nothing.** A reset or an
|
|
1113
|
+
> apply-a-saved-view that writes several keys in one tick is already one
|
|
1114
|
+
> visit: the watcher runs once per tick. Do not reach for `paused` for this.
|
|
1115
|
+
|
|
1116
|
+
#### `paused`: when another visit owns the page for a while
|
|
1117
|
+
|
|
1118
|
+
Use it when something else sends a visit that also changes keys this object
|
|
1119
|
+
mirrors, and that visit takes time: a view switch whose own `router.get`
|
|
1120
|
+
rewrites the filters, a wizard step that navigates and seeds the next step's
|
|
1121
|
+
filters. Without it, the object's watcher fires on the next tick with the
|
|
1122
|
+
seeded values and sends a second visit; Inertia cancels the first (the one
|
|
1123
|
+
that mattered) in favour of it.
|
|
1124
|
+
|
|
1125
|
+
```ts
|
|
1126
|
+
const switching = ref(false);
|
|
1127
|
+
useQueryObject(filters, {
|
|
1128
|
+
visitOptions: { only: ['conversations'] },
|
|
1129
|
+
paused: switching,
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const switchView = (view: string) => {
|
|
1133
|
+
switching.value = true;
|
|
1134
|
+
Object.assign(filters, filtersFor(view)); // tracked, not sent
|
|
1135
|
+
router.get(
|
|
1136
|
+
`/inbox/${view}`,
|
|
1137
|
+
{},
|
|
1138
|
+
{
|
|
1139
|
+
onFinish: () => (switching.value = false), // also runs on cancel
|
|
1140
|
+
}
|
|
1141
|
+
);
|
|
1142
|
+
};
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
What happens, exactly:
|
|
1146
|
+
|
|
1147
|
+
- **While `paused` is `true`**, every change is tracked (key cleanup, the
|
|
1148
|
+
debounce bookkeeping) and nothing is sent. A debounced change already
|
|
1149
|
+
waiting when the pause starts is dropped, not sent late.
|
|
1150
|
+
- **When it goes back to `false`**, the object's _current_ state is sent once,
|
|
1151
|
+
without waiting for a debounce. If the URL already holds that state — the
|
|
1152
|
+
other visit landed exactly these filters, the usual case — nothing is sent.
|
|
1153
|
+
Resuming and a change in the same tick are one visit.
|
|
1154
|
+
- It is a **span over time**, not a tick guard: set and cleared in the same
|
|
1155
|
+
tick, it pauses nothing (the watcher runs after the tick, when it is already
|
|
1156
|
+
`false`). Clear it in the competing visit's `onFinish`, which Inertia also
|
|
1157
|
+
calls when that visit is cancelled or fails, so the object never stays
|
|
1158
|
+
paused.
|
|
1159
|
+
- It accepts a ref or a getter: `paused: () => form.processing`.
|
|
800
1160
|
|
|
801
1161
|
---
|
|
802
1162
|
|
|
@@ -807,31 +1167,50 @@ counterpart of the component-state context
|
|
|
807
1167
|
[`useBbTableContext`](../composables/useBbTableContext.md). Rule of thumb:
|
|
808
1168
|
`useBbTableContext` mirrors what the _table widget_ is doing (selection,
|
|
809
1169
|
sort, highlight); these contexts own what the _server sent_ (items, meta)
|
|
810
|
-
and survive being read from any component on the page.
|
|
811
|
-
the context
|
|
1170
|
+
and survive being read from any component on the page. The first caller
|
|
1171
|
+
creates the context and every later caller **below it** joins it — no
|
|
1172
|
+
provider component, but siblings (a table and a bulk bar) share one only
|
|
1173
|
+
when a common parent (the page) calls it first; each would otherwise create
|
|
1174
|
+
its own.
|
|
812
1175
|
|
|
813
1176
|
### usePaginatedResponseContext
|
|
814
1177
|
|
|
815
1178
|
Wraps a Laravel paginated prop (`PaginatedResponse<T>`) into a shared
|
|
816
|
-
context: `items` (
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1179
|
+
context: `items` (a deep copy of the rows: `v-model` a row's field for inline
|
|
1180
|
+
editing without touching the page prop; editable until the prop changes),
|
|
1181
|
+
`totalItems`, `totalPages`, and `page` / `perPage` refs that read what the
|
|
1182
|
+
server answered and visit when written (`page` at 1 and `per_page` at the
|
|
1183
|
+
server's default leave the URL; a new `perPage` starts again from page 1). A
|
|
1184
|
+
missing or deferred prop reads as an empty page.
|
|
1185
|
+
|
|
1186
|
+
**When `items` resets.** A server visit that asks for this prop (a pager
|
|
1187
|
+
click, a filter, `router.reload({ only: ['users'] })`) always brings fresh
|
|
1188
|
+
rows, even identical ones: reloading after a failed delete undoes the
|
|
1189
|
+
optimistic edit. Anything else that swaps the prop — `router.appendToProp`
|
|
1190
|
+
on another prop rebuilds every prop, a reload of another prop, Back /
|
|
1191
|
+
Forward — keeps your edits unless the rows really changed. Anything past
|
|
1192
|
+
that (rolling back a failed edit, merging a concurrent change) is the app's.
|
|
820
1193
|
|
|
821
1194
|
```ts
|
|
822
1195
|
// inertia-helpers/usePaginatedResponseContext.ts
|
|
823
|
-
|
|
1196
|
+
// requires: npm i query-string
|
|
1197
|
+
import { currentUrl, visitUrl, withQuery } from './useQuery';
|
|
824
1198
|
import type { PaginatedResponse } from './types';
|
|
825
|
-
import { isNotNil } from './utils';
|
|
826
|
-
import type { PageProps } from '@inertiajs/core';
|
|
827
|
-
import { usePage } from '@inertiajs/vue3';
|
|
1199
|
+
import { cloneDeep, isEqual, isNotNil } from './utils';
|
|
1200
|
+
import type { PageProps, VisitOptions } from '@inertiajs/core';
|
|
1201
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
828
1202
|
import { injectLocal, provideLocal } from '@vueuse/core';
|
|
829
1203
|
import {
|
|
830
1204
|
computed,
|
|
1205
|
+
onMounted,
|
|
1206
|
+
onScopeDispose,
|
|
831
1207
|
ref,
|
|
1208
|
+
shallowRef,
|
|
1209
|
+
toValue,
|
|
832
1210
|
watch,
|
|
833
1211
|
watchEffect,
|
|
834
1212
|
type ComputedRef,
|
|
1213
|
+
type MaybeRefOrGetter,
|
|
835
1214
|
type Ref,
|
|
836
1215
|
} from 'vue';
|
|
837
1216
|
|
|
@@ -840,10 +1219,21 @@ export type PaginatedResponseContext<T> = {
|
|
|
840
1219
|
perPage: Ref<number>;
|
|
841
1220
|
page: Ref<number>;
|
|
842
1221
|
totalPages: ComputedRef<number>;
|
|
843
|
-
/** Current page items
|
|
1222
|
+
/** Current page items, a deep copy: edit it freely; resets on a new answer. */
|
|
844
1223
|
items: Ref<T[]>;
|
|
845
1224
|
};
|
|
846
1225
|
|
|
1226
|
+
export type PaginatedResponseOptions = {
|
|
1227
|
+
/** The query key the server reads the page from (Laravel's `pageName`). Default `'page'`. */
|
|
1228
|
+
pageQueryKey?: string;
|
|
1229
|
+
/** The query key the server reads the page size from. Default `'per_page'`. */
|
|
1230
|
+
perPageQueryKey?: string;
|
|
1231
|
+
/** Merged over `{ preserveState, preserveScroll }` and the history default (a page pushes, a size replaces). */
|
|
1232
|
+
visitOptions?: MaybeRefOrGetter<
|
|
1233
|
+
Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
|
|
1234
|
+
>;
|
|
1235
|
+
};
|
|
1236
|
+
|
|
847
1237
|
const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
848
1238
|
|
|
849
1239
|
/**
|
|
@@ -851,30 +1241,40 @@ const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
|
851
1241
|
* fields under `meta` (a resource collection, a hand-built shape) or at the
|
|
852
1242
|
* top level (`->paginate()`, `->simplePaginate()`). `simplePaginate()` knows
|
|
853
1243
|
* no total: the count is the rows so far, and the last page is the next one
|
|
854
|
-
* while a next page exists.
|
|
1244
|
+
* while a next page exists, or the one before an empty page past the end.
|
|
855
1245
|
*/
|
|
856
1246
|
export const readPagination = (paginated: PaginatedResponse<unknown>) => {
|
|
857
1247
|
const fields = paginated.meta ?? paginated;
|
|
858
1248
|
const current = fields.current_page ?? 1;
|
|
859
1249
|
const perPage = fields.per_page ?? 10;
|
|
1250
|
+
const rows = paginated.data?.length ?? 0;
|
|
860
1251
|
const links = paginated.links as { next?: string | null } | undefined;
|
|
861
1252
|
const hasNext = !!(
|
|
862
1253
|
fields.next_page_url ?? (Array.isArray(links) ? null : links?.next)
|
|
863
1254
|
);
|
|
1255
|
+
const pastEnd = !rows && !hasNext && current > 1;
|
|
864
1256
|
return {
|
|
865
1257
|
current,
|
|
866
1258
|
perPage,
|
|
867
|
-
lastPage:
|
|
1259
|
+
lastPage:
|
|
1260
|
+
fields.last_page ??
|
|
1261
|
+
(hasNext ? current + 1 : pastEnd ? current - 1 : current),
|
|
868
1262
|
total:
|
|
869
1263
|
fields.total ??
|
|
870
|
-
(current - 1) * perPage +
|
|
871
|
-
(paginated.data?.length ?? 0) +
|
|
872
|
-
(hasNext ? 1 : 0),
|
|
1264
|
+
(pastEnd ? 0 : (current - 1) * perPage + rows + (hasNext ? 1 : 0)),
|
|
873
1265
|
};
|
|
874
1266
|
};
|
|
875
1267
|
|
|
1268
|
+
const isCount = (value: number): boolean =>
|
|
1269
|
+
Number.isInteger(value) && value > 0;
|
|
1270
|
+
|
|
876
1271
|
export const usePaginatedResponseContext = <T = object>(
|
|
877
|
-
dataKey: string
|
|
1272
|
+
dataKey: string,
|
|
1273
|
+
{
|
|
1274
|
+
pageQueryKey = 'page',
|
|
1275
|
+
perPageQueryKey = 'per_page',
|
|
1276
|
+
visitOptions,
|
|
1277
|
+
}: PaginatedResponseOptions = {}
|
|
878
1278
|
): PaginatedResponseContext<T> => {
|
|
879
1279
|
const existing = injectLocal<PaginatedResponseContext<T> | null>(
|
|
880
1280
|
toContextKey(dataKey),
|
|
@@ -893,18 +1293,46 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
893
1293
|
const inertiaPage = usePage<
|
|
894
1294
|
PageProps & Record<string, PaginatedResponse<T>>
|
|
895
1295
|
>();
|
|
1296
|
+
// A deferred, optional or not-yet-reloaded prop reads as an empty page.
|
|
896
1297
|
const paginated = computed(
|
|
897
|
-
() => inertiaPage.props[dataKey]
|
|
1298
|
+
(): PaginatedResponse<T> => inertiaPage.props[dataKey] ?? { data: [] }
|
|
898
1299
|
);
|
|
899
1300
|
|
|
900
1301
|
const pagination = computed(() => readPagination(paginated.value));
|
|
901
|
-
|
|
1302
|
+
// A deep copy: inline edits (`v-model` on a row's field) stay off the page
|
|
1303
|
+
// prop, so the "rows really changed" check below compares two things.
|
|
1304
|
+
const items = ref<T[]>(cloneDeep(paginated.value.data ?? [])) as Ref<T[]>;
|
|
902
1305
|
const totalItems = computed(() => pagination.value.total);
|
|
903
1306
|
const totalPages = computed(() => pagination.value.lastPage);
|
|
904
1307
|
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
1308
|
+
// Server visits in flight that ask for this prop (a pager click, a filter,
|
|
1309
|
+
// `router.reload({ only: [dataKey] })`).
|
|
1310
|
+
const asking = new Set<unknown>();
|
|
1311
|
+
const asks = ({ only, except }: { only: string[]; except: string[] }) =>
|
|
1312
|
+
only.length ? only.includes(dataKey) : !except.includes(dataKey);
|
|
1313
|
+
const unbindStart = router.on('start', ({ detail: { visit } }) => {
|
|
1314
|
+
if (asks(visit)) asking.add(visit);
|
|
1315
|
+
});
|
|
1316
|
+
const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
|
|
1317
|
+
asking.delete(visit);
|
|
1318
|
+
});
|
|
1319
|
+
onScopeDispose(() => {
|
|
1320
|
+
unbindStart();
|
|
1321
|
+
unbindFinish();
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
// A visit that asked for this prop is a new answer: fresh rows, the edits
|
|
1325
|
+
// made to `items` go. Anything else that swaps the prop (`appendToProp` on
|
|
1326
|
+
// another prop rebuilds them all, Back / Forward) keeps the edits unless the
|
|
1327
|
+
// rows really changed. `sync`: the answer lands before its visit finishes.
|
|
1328
|
+
watch(
|
|
1329
|
+
paginated,
|
|
1330
|
+
(fresh, old) => {
|
|
1331
|
+
if (!asking.size && isEqual(fresh.data, old.data)) return;
|
|
1332
|
+
items.value = cloneDeep(fresh.data ?? []);
|
|
1333
|
+
},
|
|
1334
|
+
{ flush: 'sync' }
|
|
1335
|
+
);
|
|
908
1336
|
|
|
909
1337
|
// The server's default page size is what it answers a URL without
|
|
910
1338
|
// `per_page`. Until such an answer arrives it is unknown (`''` matches no
|
|
@@ -913,24 +1341,74 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
913
1341
|
const defaultPerPage = ref('');
|
|
914
1342
|
watchEffect(() => {
|
|
915
1343
|
const [, queryString = ''] = inertiaPage.url.split('?');
|
|
916
|
-
if (!new URLSearchParams(queryString).has(
|
|
1344
|
+
if (!new URLSearchParams(queryString).has(perPageQueryKey))
|
|
917
1345
|
defaultPerPage.value = `${pagination.value.perPage}`;
|
|
918
1346
|
});
|
|
919
|
-
|
|
920
|
-
|
|
1347
|
+
|
|
1348
|
+
// Where the last pager write is going, while its visit is in flight: a
|
|
1349
|
+
// second click before the answer builds on it ("next" twice is page 3).
|
|
1350
|
+
const target = shallowRef<{ url: string; page: number; perPage: number }>();
|
|
1351
|
+
const inFlight = () => {
|
|
1352
|
+
const write = target.value;
|
|
1353
|
+
return write?.url === currentUrl() && write.url !== inertiaPage.url
|
|
1354
|
+
? write
|
|
1355
|
+
: undefined;
|
|
1356
|
+
};
|
|
1357
|
+
const go = (to: number, size?: number, extra?: { replace: true }) => {
|
|
1358
|
+
const url = withQuery(currentUrl(), {
|
|
1359
|
+
[pageQueryKey]: to === 1 ? null : to,
|
|
1360
|
+
...(isNotNil(size) && {
|
|
1361
|
+
[perPageQueryKey]: `${size}` === defaultPerPage.value ? null : size,
|
|
1362
|
+
}),
|
|
1363
|
+
});
|
|
1364
|
+
target.value = { url, page: to, perPage: size ?? perPage.value };
|
|
1365
|
+
visitUrl(url, {
|
|
1366
|
+
preserveScroll: true,
|
|
1367
|
+
preserveState: true,
|
|
1368
|
+
// A page is a history entry (Back goes to the previous page); a new
|
|
1369
|
+
// size is a setting and replaces.
|
|
1370
|
+
replace: isNotNil(size),
|
|
1371
|
+
...toValue(visitOptions),
|
|
1372
|
+
...extra,
|
|
1373
|
+
});
|
|
1374
|
+
};
|
|
921
1375
|
|
|
922
1376
|
// Both read the server's answer, not the URL: a clamped `?page=99`, a
|
|
923
1377
|
// capped `?per_page=500` or a redirect to the bare URL shows what the
|
|
924
|
-
// server sent. Writing one visits
|
|
1378
|
+
// server sent. Writing one visits when it changes what they show; a size
|
|
1379
|
+
// starts again from page 1. Junk (0, NaN, 2.5, past a known last page) is
|
|
1380
|
+
// ignored.
|
|
925
1381
|
const page = computed({
|
|
926
|
-
get: () => pagination.value.current,
|
|
927
|
-
set: (next: number) =>
|
|
1382
|
+
get: () => inFlight()?.page ?? pagination.value.current,
|
|
1383
|
+
set: (next: number) => {
|
|
1384
|
+
const last = (paginated.value.meta ?? paginated.value).last_page;
|
|
1385
|
+
if (!isCount(next) || (isNotNil(last) && next > last)) return;
|
|
1386
|
+
if (next !== page.value) go(next);
|
|
1387
|
+
},
|
|
928
1388
|
});
|
|
929
1389
|
const perPage = computed({
|
|
930
|
-
get: () => pagination.value.perPage,
|
|
931
|
-
set: (next: number) =>
|
|
1390
|
+
get: () => inFlight()?.perPage ?? pagination.value.perPage,
|
|
1391
|
+
set: (next: number) => {
|
|
1392
|
+
if (isCount(next) && next !== perPage.value) go(1, next);
|
|
1393
|
+
},
|
|
932
1394
|
});
|
|
933
1395
|
|
|
1396
|
+
// Laravel's `paginate()` does not clamp: `?page=3` after page 3's last row
|
|
1397
|
+
// went answers an empty page 3 of 2. Go to the last page, once per answer.
|
|
1398
|
+
let clampedFrom = '';
|
|
1399
|
+
onMounted(() =>
|
|
1400
|
+
watch(
|
|
1401
|
+
pagination,
|
|
1402
|
+
({ current, lastPage }) => {
|
|
1403
|
+
if (current <= lastPage || lastPage < 1) return;
|
|
1404
|
+
if (clampedFrom === inertiaPage.url) return;
|
|
1405
|
+
clampedFrom = inertiaPage.url;
|
|
1406
|
+
go(lastPage, undefined, { replace: true });
|
|
1407
|
+
},
|
|
1408
|
+
{ immediate: true }
|
|
1409
|
+
)
|
|
1410
|
+
);
|
|
1411
|
+
|
|
934
1412
|
const context: PaginatedResponseContext<T> = {
|
|
935
1413
|
totalItems,
|
|
936
1414
|
perPage,
|
|
@@ -963,6 +1441,38 @@ return Inertia::render('Admin/Users/Index', [
|
|
|
963
1441
|
one while a next page exists) and `totalPages` ends at the next page, so a
|
|
964
1442
|
pager shows "next" until the last page.
|
|
965
1443
|
|
|
1444
|
+
**`->cursorPaginate()` is not supported.** It has no page numbers (only
|
|
1445
|
+
`next_cursor` / `prev_cursor` links), so `page` would read 1 on every page
|
|
1446
|
+
and "next" would visit a `?page=2` Laravel ignores. Use `->paginate()` or
|
|
1447
|
+
`->simplePaginate()` with this context.
|
|
1448
|
+
|
|
1449
|
+
**A page is a history entry.** Writing `page` pushes, so Back goes to the
|
|
1450
|
+
previous page; a new `perPage` (a setting) and filter writes replace. Pass
|
|
1451
|
+
`visitOptions: { replace: true }` to keep the pager out of history.
|
|
1452
|
+
|
|
1453
|
+
**A page past the last goes to the last.** Laravel does not clamp: delete
|
|
1454
|
+
the last row of page 3 and `?page=3` answers an empty page 3 of 2. The
|
|
1455
|
+
context visits the last page once (`replace`); an empty `simplePaginate()`
|
|
1456
|
+
page past the first goes back one.
|
|
1457
|
+
|
|
1458
|
+
**Writes before the answer build on each other.** While a pager visit is in
|
|
1459
|
+
flight, `page` / `perPage` read where it is going, so "next" clicked twice
|
|
1460
|
+
lands two pages on, and a click right after a filter or size change keeps
|
|
1461
|
+
it. A write equal to what they show, a page that is not a positive integer,
|
|
1462
|
+
or a page past a known last page makes no visit.
|
|
1463
|
+
|
|
1464
|
+
Options (the second argument, also taken by `useTableContext`):
|
|
1465
|
+
|
|
1466
|
+
- `pageQueryKey` / `perPageQueryKey` — the query keys the server reads
|
|
1467
|
+
(defaults `'page'` / `'per_page'`). A paginator with its own page name,
|
|
1468
|
+
`->paginate(15, ['*'], 'lots_page')`, takes
|
|
1469
|
+
`usePaginatedResponseContext('lots', { pageQueryKey: 'lots_page' })` — and
|
|
1470
|
+
two tables on one page need one each.
|
|
1471
|
+
- `visitOptions` — merged over `{ preserveState, preserveScroll, replace:
|
|
1472
|
+
true }` on every pager visit, a ref or getter read each time. Without it a
|
|
1473
|
+
page change reloads every prop; `{ only: ['users'] }` reloads the table
|
|
1474
|
+
alone.
|
|
1475
|
+
|
|
966
1476
|
### useTableSelectionContext
|
|
967
1477
|
|
|
968
1478
|
Shared selection state by key, in server terms: `selected` and `unselected`
|
|
@@ -971,6 +1481,12 @@ item lists plus the `all` flag ("select-all across pages" semantics — when
|
|
|
971
1481
|
combines `all` + `unselected` instead). Bind these to `BbTable`'s
|
|
972
1482
|
`v-model`, `v-model:unselected-items` and `v-model:select-all`.
|
|
973
1483
|
|
|
1484
|
+
The selection is never reset for you: it survives page changes (that is
|
|
1485
|
+
the cross-page feature) and filter changes alike, since the URL also holds
|
|
1486
|
+
state that has nothing to do with the table. When a bulk action must not
|
|
1487
|
+
reach rows the new filter hides, call `resetSelection()` where the filters
|
|
1488
|
+
change, e.g. `watch(filters, resetSelection, { deep: true })`.
|
|
1489
|
+
|
|
974
1490
|
```ts
|
|
975
1491
|
// inertia-helpers/useTableSelectionContext.ts
|
|
976
1492
|
import { isNotNil } from './utils';
|
|
@@ -1034,6 +1550,7 @@ builds on.
|
|
|
1034
1550
|
import {
|
|
1035
1551
|
usePaginatedResponseContext,
|
|
1036
1552
|
type PaginatedResponseContext,
|
|
1553
|
+
type PaginatedResponseOptions,
|
|
1037
1554
|
} from './usePaginatedResponseContext';
|
|
1038
1555
|
import {
|
|
1039
1556
|
useTableSelectionContext,
|
|
@@ -1048,7 +1565,9 @@ export type TableContext<T, U> = TableSelectionContext<U> &
|
|
|
1048
1565
|
const toTableKey = (key: string): string => `${key}:table`;
|
|
1049
1566
|
|
|
1050
1567
|
export const useTableContext = <T = object, U = T>(
|
|
1051
|
-
dataKey: string
|
|
1568
|
+
dataKey: string,
|
|
1569
|
+
/** For the paginated half: query keys and pager visit options. */
|
|
1570
|
+
options?: PaginatedResponseOptions
|
|
1052
1571
|
): TableContext<T, U> => {
|
|
1053
1572
|
const existing = injectLocal<TableContext<T, U> | null>(
|
|
1054
1573
|
toTableKey(dataKey),
|
|
@@ -1058,7 +1577,7 @@ export const useTableContext = <T = object, U = T>(
|
|
|
1058
1577
|
|
|
1059
1578
|
const context: TableContext<T, U> = {
|
|
1060
1579
|
...useTableSelectionContext<U>(dataKey),
|
|
1061
|
-
...usePaginatedResponseContext<T>(dataKey),
|
|
1580
|
+
...usePaginatedResponseContext<T>(dataKey, options),
|
|
1062
1581
|
};
|
|
1063
1582
|
|
|
1064
1583
|
provideLocal(toTableKey(dataKey), context);
|
|
@@ -1070,7 +1589,10 @@ Gotchas:
|
|
|
1070
1589
|
|
|
1071
1590
|
- The key is a rendezvous string (use the page-prop name, e.g.
|
|
1072
1591
|
`'invoices'`). Like the library's table context, a typo doesn't error —
|
|
1073
|
-
it silently creates a second, orphan context.
|
|
1592
|
+
it silently creates a second, orphan context. So does a sibling: call it
|
|
1593
|
+
in the page (or a parent of every reader) first.
|
|
1594
|
+
- The selection outlives a filter change (it is cross-page by design). Call
|
|
1595
|
+
`resetSelection()` where the filter changes when the old picks must go.
|
|
1074
1596
|
- Each composite context provides under a namespaced key
|
|
1075
1597
|
(`invoices:table`, `invoices:selection`, `invoices:paginatedResponse`),
|
|
1076
1598
|
so the pieces can also be joined individually.
|
|
@@ -1104,9 +1626,14 @@ submit, chain a toast, or hand the promise to a `BbButton` `@click` /
|
|
|
1104
1626
|
Inertia form — `form.errors`, `form.isDirty`, `form.reset()` all work.
|
|
1105
1627
|
|
|
1106
1628
|
The promise resolves `true` when the server answered and `false` when the visit
|
|
1107
|
-
was cancelled (a `before` listener such as
|
|
1108
|
-
or interrupted by a newer one: nothing was
|
|
1109
|
-
|
|
1629
|
+
was cancelled (its own `onBefore` returning false, a `before` listener such as
|
|
1630
|
+
`useConfirmOnRouteLeave`, `cancel()`) or interrupted by a newer one: nothing was
|
|
1631
|
+
saved, so skip the success toast and the re-baseline
|
|
1632
|
+
(`if (!(await put(url))) return;`) and the form stays dirty. A save the server
|
|
1633
|
+
answers with a redirect to a `#fragment` (Inertia's 409 follow-up visit) or with
|
|
1634
|
+
`Inertia::location()` resolves `true`; an Inertia-rendered error page (a 403 /
|
|
1635
|
+
500 with your custom error component) rejects. Your own `onSuccess` / `onError`
|
|
1636
|
+
/ `onFinish` options still run.
|
|
1110
1637
|
|
|
1111
1638
|
**Declare the draft shape as a `type` alias, not an `interface`.** The generic
|
|
1112
1639
|
is constrained to `Record<string, FormValue>` (what Inertia can serialize), and
|
|
@@ -1119,6 +1646,7 @@ TypeScript quirk, not a statement about your data.
|
|
|
1119
1646
|
// inertia-helpers/usePromiseForm.ts
|
|
1120
1647
|
import { useLoading } from './useLoading';
|
|
1121
1648
|
import {
|
|
1649
|
+
router,
|
|
1122
1650
|
useForm,
|
|
1123
1651
|
type InertiaForm,
|
|
1124
1652
|
type InertiaFormProps,
|
|
@@ -1136,6 +1664,17 @@ type FormValue =
|
|
|
1136
1664
|
|
|
1137
1665
|
type Options = Parameters<InertiaFormProps<object>['get']>[1];
|
|
1138
1666
|
|
|
1667
|
+
// Inertia 3's per-visit failure callbacks. Inertia 2 has none (it ignores
|
|
1668
|
+
// them), only the global `invalid` / `exception` events, heard below.
|
|
1669
|
+
type FailureCallbacks = {
|
|
1670
|
+
onHttpException?: (response: { status: number }) => boolean | void;
|
|
1671
|
+
onNetworkError?: (error: unknown) => boolean | void;
|
|
1672
|
+
};
|
|
1673
|
+
const onRouterEvent = (
|
|
1674
|
+
name: string,
|
|
1675
|
+
listener: (event: CustomEvent) => void
|
|
1676
|
+
): (() => void) => router.on(name as 'start', listener as never);
|
|
1677
|
+
|
|
1139
1678
|
export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
1140
1679
|
initialData: T
|
|
1141
1680
|
) => {
|
|
@@ -1150,34 +1689,129 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
|
1150
1689
|
): Promise<boolean> =>
|
|
1151
1690
|
// true: the server answered and the save went through. false: the
|
|
1152
1691
|
// visit was cancelled or interrupted, nothing to report. Rejects with
|
|
1153
|
-
// the error bag, or for a response that is not Inertia's.
|
|
1154
|
-
new Promise<boolean>((
|
|
1155
|
-
const {
|
|
1692
|
+
// the error bag, or for an error page / a response that is not Inertia's.
|
|
1693
|
+
new Promise<boolean>((resolvePromise, rejectPromise) => {
|
|
1694
|
+
const {
|
|
1695
|
+
onBefore,
|
|
1696
|
+
onSuccess,
|
|
1697
|
+
onError,
|
|
1698
|
+
onHttpException,
|
|
1699
|
+
onNetworkError,
|
|
1700
|
+
onFinish,
|
|
1701
|
+
...visitOptions
|
|
1702
|
+
} = options as NonNullable<Options> & FailureCallbacks;
|
|
1703
|
+
// Visits sent with these callbacks, and how many finished: a save
|
|
1704
|
+
// answered with a 409 redirect (a redirect with a #fragment) is
|
|
1705
|
+
// re-sent by Inertia itself with the same callbacks, and that
|
|
1706
|
+
// follow-up interrupts the first visit. The last one settles.
|
|
1707
|
+
let sent = 0;
|
|
1708
|
+
let finished = 0;
|
|
1709
|
+
let awaitingBefore = false;
|
|
1710
|
+
let failedStatus: number | null = null;
|
|
1711
|
+
// Inertia 2: a non-Inertia response or a network error is a global
|
|
1712
|
+
// event only, fired before the visit finishes. Heard while this
|
|
1713
|
+
// promise is open (an async reload failing meanwhile counts too).
|
|
1714
|
+
const unbindFailures = [
|
|
1715
|
+
onRouterEvent('invalid', ({ detail }) => {
|
|
1716
|
+
failedStatus = (detail as { response: { status: number } }).response
|
|
1717
|
+
.status;
|
|
1718
|
+
}),
|
|
1719
|
+
onRouterEvent('exception', () => void (failedStatus = 0)),
|
|
1720
|
+
];
|
|
1721
|
+
const settle = () => {
|
|
1722
|
+
unbind();
|
|
1723
|
+
unbindFailures.forEach((unbindFailure) => unbindFailure());
|
|
1724
|
+
};
|
|
1725
|
+
const resolve = (value: boolean) => {
|
|
1726
|
+
settle();
|
|
1727
|
+
resolvePromise(value);
|
|
1728
|
+
};
|
|
1729
|
+
const reject = (error: unknown) => {
|
|
1730
|
+
settle();
|
|
1731
|
+
rejectPromise(error);
|
|
1732
|
+
};
|
|
1733
|
+
// A visit stopped before it starts (its own onBefore returning
|
|
1734
|
+
// false, below, or a `before` listener that refuses it) fires no
|
|
1735
|
+
// callback at all: no save, resolve false. Registered last, this
|
|
1736
|
+
// listener sees what the ones before it decided.
|
|
1737
|
+
const unbind = router.on('before', (event) => {
|
|
1738
|
+
if (!awaitingBefore) return;
|
|
1739
|
+
awaitingBefore = false;
|
|
1740
|
+
if (!event.defaultPrevented) return;
|
|
1741
|
+
// Held by useConfirmOnRouteLeave's dialog: "Discard changes"
|
|
1742
|
+
// re-sends it with these callbacks, "Keep editing" finishes it
|
|
1743
|
+
// as cancelled. Either one settles this promise.
|
|
1744
|
+
if (
|
|
1745
|
+
(event.detail.visit as { awaitingConfirm?: boolean })
|
|
1746
|
+
.awaitingConfirm
|
|
1747
|
+
)
|
|
1748
|
+
return;
|
|
1749
|
+
if (--sent === finished) resolve(false);
|
|
1750
|
+
});
|
|
1156
1751
|
|
|
1157
1752
|
form[method](url, {
|
|
1158
1753
|
...visitOptions,
|
|
1159
|
-
|
|
1160
|
-
|
|
1754
|
+
onBefore: (visit) => {
|
|
1755
|
+
const answer = onBefore?.(visit);
|
|
1756
|
+
if (answer === false) {
|
|
1757
|
+
if (sent === finished) resolve(false);
|
|
1758
|
+
} else {
|
|
1759
|
+
sent++;
|
|
1760
|
+
awaitingBefore = true;
|
|
1761
|
+
}
|
|
1762
|
+
return answer;
|
|
1763
|
+
},
|
|
1764
|
+
onSuccess: (page) => {
|
|
1765
|
+
onSuccess?.(page);
|
|
1766
|
+
// An error page Inertia renders (a 403 / 500 with `x-inertia`)
|
|
1767
|
+
// lands like a success: it is not a save.
|
|
1768
|
+
if (failedStatus === null) resolve(true);
|
|
1769
|
+
else reject(new Error(`Inertia visit answered ${failedStatus}.`));
|
|
1770
|
+
},
|
|
1771
|
+
onError: (errors) => {
|
|
1772
|
+
onError?.(errors);
|
|
1773
|
+
reject(errors);
|
|
1774
|
+
},
|
|
1775
|
+
...({
|
|
1776
|
+
onHttpException: (response) => {
|
|
1777
|
+
failedStatus = response.status;
|
|
1778
|
+
return onHttpException?.(response);
|
|
1779
|
+
},
|
|
1780
|
+
onNetworkError: (error) => {
|
|
1781
|
+
failedStatus = 0;
|
|
1782
|
+
return onNetworkError?.(error);
|
|
1783
|
+
},
|
|
1784
|
+
} satisfies FailureCallbacks),
|
|
1161
1785
|
onFinish: (
|
|
1162
1786
|
...args: Parameters<NonNullable<NonNullable<Options>['onFinish']>>
|
|
1163
1787
|
) => {
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
//
|
|
1788
|
+
onFinish?.(...args);
|
|
1789
|
+
finished++;
|
|
1790
|
+
// A cancelled visit (`cancel()`) or one a newer visit
|
|
1167
1791
|
// interrupted is no failure, and no save either: it reaches
|
|
1168
|
-
// only onFinish. Resolve false
|
|
1792
|
+
// only onFinish. Resolve false, unless it is Inertia's own
|
|
1793
|
+
// 409 follow-up that interrupted it: that one settles.
|
|
1169
1794
|
const [visit] = args as unknown as [
|
|
1170
1795
|
{ cancelled?: boolean; interrupted?: boolean } | undefined,
|
|
1171
1796
|
];
|
|
1172
1797
|
if (visit?.cancelled || visit?.interrupted) {
|
|
1173
|
-
resolve(false);
|
|
1798
|
+
if (sent <= finished) resolve(false);
|
|
1174
1799
|
return;
|
|
1175
1800
|
}
|
|
1176
1801
|
// A non-Inertia response (e.g. a 423 entity lock or a 419
|
|
1177
|
-
// CSRF mismatch) fires neither onSuccess
|
|
1178
|
-
// onFinish. Settle here so isSubmitting is
|
|
1179
|
-
// of hanging the form forever. No-op once
|
|
1180
|
-
|
|
1802
|
+
// CSRF mismatch) or a network error fires neither onSuccess
|
|
1803
|
+
// nor onError, only onFinish. Settle here so isSubmitting is
|
|
1804
|
+
// released instead of hanging the form forever. No-op once
|
|
1805
|
+
// already settled.
|
|
1806
|
+
if (failedStatus !== null) {
|
|
1807
|
+
reject(
|
|
1808
|
+
new Error('Inertia visit did not return a valid response.')
|
|
1809
|
+
);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
// `Inertia::location()` (an external redirect after the save)
|
|
1813
|
+
// fires only onFinish too, while the browser leaves.
|
|
1814
|
+
resolve(true);
|
|
1181
1815
|
},
|
|
1182
1816
|
});
|
|
1183
1817
|
})
|
|
@@ -1207,6 +1841,26 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
|
1207
1841
|
> have nothing else to do on rejection. Every call site in this guide and its
|
|
1208
1842
|
> recipes follows one of those two shapes — copy them, not a bare `post(...)`.
|
|
1209
1843
|
|
|
1844
|
+
**A summary line over the form is the page's, not the helper's.** The
|
|
1845
|
+
rejection tells the two failures apart: the error bag (an object, already in
|
|
1846
|
+
`form.errors`) or an `Error` (the server answered with something that is not
|
|
1847
|
+
Inertia's). Word them in the `catch`:
|
|
1848
|
+
|
|
1849
|
+
```ts
|
|
1850
|
+
const failure = ref('');
|
|
1851
|
+
const save = async () => {
|
|
1852
|
+
failure.value = '';
|
|
1853
|
+
try {
|
|
1854
|
+
await put(`/invoices/${invoice.id}`);
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
failure.value =
|
|
1857
|
+
error instanceof Error
|
|
1858
|
+
? 'The save did not go through. Try again.'
|
|
1859
|
+
: 'Check the highlighted fields.';
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
```
|
|
1863
|
+
|
|
1210
1864
|
**Seeding a draft: `NullableKeys<T>`, not a hand-rolled `| null` on every
|
|
1211
1865
|
field.** Don't declare a bespoke `Draft` type that bolts `| null` onto each
|
|
1212
1866
|
string prop by hand — that's what produces empty-string drafts (`''`
|
|
@@ -1251,57 +1905,86 @@ const { form } = usePromiseForm<NullableKeys<InvoiceRequest>>({
|
|
|
1251
1905
|
|
|
1252
1906
|
Deep dirty-checking against a snapshot baseline — for edit screens whose
|
|
1253
1907
|
state is not a single Inertia form (multiple forms, ad-hoc refs). For a
|
|
1254
|
-
single `usePromiseForm`, prefer the built-in `form.isDirty`.
|
|
1908
|
+
single `usePromiseForm`, prefer the built-in `form.isDirty`. The baseline is
|
|
1909
|
+
a structural hash of each value, so Dates, Sets, Maps, `undefined` and NaN
|
|
1910
|
+
compare as they are, and a `File` by its name, size, type and date, so picking
|
|
1911
|
+
another file over a picked one counts.
|
|
1255
1912
|
|
|
1256
1913
|
```ts
|
|
1257
1914
|
// inertia-helpers/useHasChanged.ts
|
|
1258
|
-
import {
|
|
1915
|
+
import { isPlainObject } from './utils';
|
|
1916
|
+
import { hash } from 'object-code';
|
|
1259
1917
|
import { computed, ref, toValue, type WatchSource } from 'vue';
|
|
1260
1918
|
|
|
1261
1919
|
export function useHasChanged<T extends WatchSource<unknown>[]>(
|
|
1262
1920
|
dependencies: T
|
|
1263
1921
|
) {
|
|
1264
1922
|
/*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1923
|
+
* The baseline is a structural HASH, not a copy. A copy has to survive the
|
|
1924
|
+
* values a form holds: a JSON round-trip turns a Date into a string, a File,
|
|
1925
|
+
* Set or Map into `{}`, drops `undefined` (and throws on a bare one), so
|
|
1926
|
+
* an untouched form read as changed; `structuredClone` throws on Vue proxies.
|
|
1927
|
+
* `object-code` hashes straight through a proxy and keeps Dates (by time),
|
|
1928
|
+
* Sets / Maps (by entries), `undefined` and NaN apart, and a number is all
|
|
1929
|
+
* the baseline needs to hold. It hashes every File alike (no own keys), so
|
|
1930
|
+
* a File is read by name, size, type and date first: picking another file
|
|
1931
|
+
* over a picked one counts as a change.
|
|
1274
1932
|
*/
|
|
1275
1933
|
const snapshot = (dep: WatchSource<unknown>) =>
|
|
1276
|
-
|
|
1934
|
+
hash(withFileFingerprints(toValue(dep)));
|
|
1277
1935
|
|
|
1278
|
-
const
|
|
1936
|
+
const baselines = ref<number[]>(dependencies.map(snapshot));
|
|
1279
1937
|
|
|
1280
1938
|
/** Re-baseline (call after a successful save). */
|
|
1281
1939
|
const takeSnapshot = (): void => {
|
|
1282
|
-
|
|
1940
|
+
baselines.value = dependencies.map(snapshot);
|
|
1283
1941
|
};
|
|
1284
1942
|
|
|
1285
1943
|
const hasChanged = computed(() =>
|
|
1286
|
-
dependencies.some(
|
|
1287
|
-
(dep, index) => !isEqual(toValue(dep), baselineValues.value[index])
|
|
1288
|
-
)
|
|
1944
|
+
dependencies.some((dep, index) => snapshot(dep) !== baselines.value[index])
|
|
1289
1945
|
);
|
|
1290
1946
|
|
|
1291
1947
|
return { hasChanged, takeSnapshot };
|
|
1292
1948
|
}
|
|
1949
|
+
|
|
1950
|
+
const withFileFingerprints = (value: unknown): unknown =>
|
|
1951
|
+
value instanceof Blob
|
|
1952
|
+
? [
|
|
1953
|
+
value.size,
|
|
1954
|
+
value.type,
|
|
1955
|
+
(value as Partial<File>).name,
|
|
1956
|
+
(value as Partial<File>).lastModified,
|
|
1957
|
+
]
|
|
1958
|
+
: Array.isArray(value)
|
|
1959
|
+
? value.map(withFileFingerprints)
|
|
1960
|
+
: isPlainObject(value)
|
|
1961
|
+
? Object.fromEntries(
|
|
1962
|
+
Object.entries(value).map(([key, item]) => [
|
|
1963
|
+
key,
|
|
1964
|
+
withFileFingerprints(item),
|
|
1965
|
+
])
|
|
1966
|
+
)
|
|
1967
|
+
: value;
|
|
1293
1968
|
```
|
|
1294
1969
|
|
|
1295
1970
|
### useConfirmOnRouteLeave
|
|
1296
1971
|
|
|
1297
1972
|
A dirty-state navigation guard: intercepts Inertia visits and asks through
|
|
1298
1973
|
[`useConfirm`](../composables/useConfirm.md) before leaving. Requires
|
|
1299
|
-
`<BbHost />` mounted in the app root.
|
|
1300
|
-
|
|
1301
|
-
|
|
1974
|
+
`<BbHost />` mounted in the app root. `whitelist` names the form's own
|
|
1975
|
+
submit targets: a submit (POST / PUT / PATCH / DELETE) to one of them goes
|
|
1976
|
+
out without asking, while a plain link to the same path (a Cancel button
|
|
1977
|
+
back to `/invoices`) still asks. Server redirects after a whitelisted
|
|
1978
|
+
action carry the `X-Whitelisted-Action` header so a saving visit's redirect
|
|
1979
|
+
is never blocked by its own guard. Leaving the app (closing the tab,
|
|
1980
|
+
reloading, a typed URL, a plain `<a>`) gets the browser's own "Leave site?"
|
|
1981
|
+
prompt while the guard is armed; a whitelisted save that the server answers
|
|
1982
|
+
with `Inertia::location()` leaves without one. `stack` picks the `BbDialog`
|
|
1983
|
+
stack the dialog joins; `variant` defaults to `'destructive'`.
|
|
1302
1984
|
|
|
1303
1985
|
```ts
|
|
1304
1986
|
// inertia-helpers/useConfirmOnRouteLeave.ts
|
|
1987
|
+
import { useHelperMessages } from './messages';
|
|
1305
1988
|
import { router } from '@inertiajs/vue3';
|
|
1306
1989
|
import { useConfirm, type BbConfirmMessage } from 'bitboss-ui';
|
|
1307
1990
|
import { onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue';
|
|
@@ -1315,7 +1998,11 @@ type Visit = {
|
|
|
1315
1998
|
};
|
|
1316
1999
|
|
|
1317
2000
|
type Options = {
|
|
1318
|
-
/**
|
|
2001
|
+
/**
|
|
2002
|
+
* Submit targets that bypass the dialog (the form's own save). Only a
|
|
2003
|
+
* submit (POST / PUT / PATCH / DELETE) passes: a link to the same path
|
|
2004
|
+
* (Cancel → the list) still asks.
|
|
2005
|
+
*/
|
|
1319
2006
|
whitelist?: MaybeRefOrGetter<string[]>;
|
|
1320
2007
|
/** Disable the guard (bind to `!hasChanged` / `!form.isDirty`). */
|
|
1321
2008
|
disabled?: MaybeRefOrGetter<boolean>;
|
|
@@ -1327,12 +2014,14 @@ type Options = {
|
|
|
1327
2014
|
text?: string;
|
|
1328
2015
|
/**
|
|
1329
2016
|
* The dialog's buttons and look, BbConfirm's own options: a label string
|
|
1330
|
-
* or a full button config.
|
|
1331
|
-
* `variant: 'destructive'`.
|
|
2017
|
+
* or a full button config. Default words: `leave` in `messages.ts`, in the
|
|
2018
|
+
* library's `locale`; `variant: 'destructive'`.
|
|
1332
2019
|
*/
|
|
1333
2020
|
yes?: BbConfirmMessage['yes'];
|
|
1334
2021
|
no?: BbConfirmMessage['no'];
|
|
1335
2022
|
variant?: BbConfirmMessage['variant'];
|
|
2023
|
+
/** The `BbDialog` stack the dialog joins (BbConfirm's `stack`). */
|
|
2024
|
+
stack?: BbConfirmMessage['stack'];
|
|
1336
2025
|
};
|
|
1337
2026
|
|
|
1338
2027
|
const normalizeUrl = (value: string | URL) => {
|
|
@@ -1359,24 +2048,26 @@ export const useConfirmOnRouteLeave = ({
|
|
|
1359
2048
|
disabled = false,
|
|
1360
2049
|
onBefore = () => true,
|
|
1361
2050
|
onLeave = () => true,
|
|
1362
|
-
title
|
|
1363
|
-
text
|
|
1364
|
-
yes
|
|
1365
|
-
no
|
|
2051
|
+
title,
|
|
2052
|
+
text,
|
|
2053
|
+
yes,
|
|
2054
|
+
no,
|
|
1366
2055
|
variant = 'destructive',
|
|
2056
|
+
stack,
|
|
1367
2057
|
}: Options = {}) => {
|
|
1368
2058
|
// Captured in setup so the built-in dialog is owned by the guarded page:
|
|
1369
2059
|
// if the page unmounts anyway, the dialog dismisses itself quietly.
|
|
1370
2060
|
const { confirm } = useConfirm();
|
|
2061
|
+
const messages = useHelperMessages();
|
|
1371
2062
|
let bypassNextVisit = false;
|
|
1372
2063
|
|
|
1373
2064
|
const unbind = router.on('before', (event) => {
|
|
1374
2065
|
const visit = event.detail.visit as Visit;
|
|
1375
2066
|
|
|
1376
2067
|
// A hover / mount prefetch (`<Link prefetch>`, `<BbButton href prefetch>`)
|
|
1377
|
-
// fires `before` too, but leaves nothing: no dialog
|
|
1378
|
-
//
|
|
1379
|
-
if (visit.prefetch) return;
|
|
2068
|
+
// fires `before` too, but leaves nothing: no dialog. A visit another
|
|
2069
|
+
// guard on the page already stopped is that guard's to ask about.
|
|
2070
|
+
if (visit.prefetch || event.defaultPrevented) return;
|
|
1380
2071
|
|
|
1381
2072
|
if (bypassNextVisit) {
|
|
1382
2073
|
bypassNextVisit = false;
|
|
@@ -1388,41 +2079,146 @@ export const useConfirmOnRouteLeave = ({
|
|
|
1388
2079
|
|
|
1389
2080
|
const nextPath = normalizeUrl(visit.url.href);
|
|
1390
2081
|
if (nextPath === normalizeUrl(window.location.href)) return;
|
|
1391
|
-
if (
|
|
2082
|
+
if (
|
|
2083
|
+
visit.method !== 'get' &&
|
|
2084
|
+
toValue(whitelist).map(normalizeUrl).includes(nextPath)
|
|
2085
|
+
) {
|
|
2086
|
+
// Marked: a save answered with a 409 redirect (a redirect with a
|
|
2087
|
+
// #fragment) is re-sent by Inertia with these same headers, and that
|
|
2088
|
+
// follow-up must not ask either.
|
|
2089
|
+
visit.headers = { ...visit.headers, [WHITELIST_HEADER]: '1' };
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
1392
2092
|
|
|
1393
2093
|
// `return`, not `return false`: an Inertia `before` listener that returns
|
|
1394
2094
|
// false CANCELS the visit, which is the opposite of what `onBefore`
|
|
1395
2095
|
// promises (false = let this visit through, no dialog).
|
|
1396
2096
|
if (onBefore(visit) === false) return;
|
|
1397
2097
|
event.preventDefault();
|
|
2098
|
+
// Held, not refused: the dialog decides. `usePromiseForm` reads this
|
|
2099
|
+
// flag and waits instead of answering `false` at once.
|
|
2100
|
+
visit.awaitingConfirm = true;
|
|
1398
2101
|
|
|
1399
2102
|
void (async () => {
|
|
1400
|
-
const
|
|
1401
|
-
|
|
2103
|
+
const words = messages.value.leave;
|
|
2104
|
+
const confirmed = await confirm({
|
|
2105
|
+
title: title ?? words.title,
|
|
2106
|
+
text: text ?? words.text,
|
|
2107
|
+
yes: yes ?? words.yes,
|
|
2108
|
+
no: no ?? words.no,
|
|
2109
|
+
variant,
|
|
2110
|
+
...(stack && { stack }),
|
|
2111
|
+
});
|
|
2112
|
+
if (!confirmed) {
|
|
2113
|
+
// "Keep editing" ends the held visit the way Inertia ends a
|
|
2114
|
+
// cancelled one, so its own callbacks (a form's processing state,
|
|
2115
|
+
// a pending promise) settle.
|
|
2116
|
+
const callbacks = visit as {
|
|
2117
|
+
onCancel?: () => void;
|
|
2118
|
+
onFinish?: (visit: Visit) => void;
|
|
2119
|
+
};
|
|
2120
|
+
callbacks.onCancel?.();
|
|
2121
|
+
callbacks.onFinish?.({ ...visit, cancelled: true });
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
1402
2124
|
|
|
1403
2125
|
await onLeave(visit);
|
|
2126
|
+
// Re-sent as a plain visit: never as a prefetch or a background one,
|
|
2127
|
+
// and without the visit's own onBefore (its `confirm('Sure?')` was
|
|
2128
|
+
// already answered).
|
|
2129
|
+
const {
|
|
2130
|
+
prefetch: _prefetch,
|
|
2131
|
+
async: _async,
|
|
2132
|
+
onBefore: _onBefore,
|
|
2133
|
+
awaitingConfirm: _awaitingConfirm,
|
|
2134
|
+
...rest
|
|
2135
|
+
} = visit;
|
|
2136
|
+
// `before` fires inside router.visit(): the bypass covers exactly
|
|
2137
|
+
// that visit, and is never left armed for the next one (a re-send
|
|
2138
|
+
// its own callbacks stop before `before`).
|
|
1404
2139
|
bypassNextVisit = true;
|
|
1405
|
-
// Re-sent as a plain visit: never as a prefetch or a background one.
|
|
1406
|
-
const { prefetch: _prefetch, async: _async, ...rest } = visit;
|
|
1407
2140
|
router.visit(visit.url, {
|
|
1408
2141
|
...(rest as Parameters<typeof router.visit>[1]),
|
|
1409
2142
|
headers: { ...visit.headers, [WHITELIST_HEADER]: '1' },
|
|
1410
2143
|
});
|
|
2144
|
+
bypassNextVisit = false;
|
|
1411
2145
|
})();
|
|
1412
2146
|
});
|
|
1413
2147
|
|
|
2148
|
+
// Leaving the app (closing the tab, reloading, a typed URL, a plain `<a>`)
|
|
2149
|
+
// never reaches `before`: the browser's own prompt covers it. Not for a
|
|
2150
|
+
// visit this guard let through: a save answered with `Inertia::location()`
|
|
2151
|
+
// is a real unload with the form still dirty. Inertia sets
|
|
2152
|
+
// `window.location` inside that visit, and WebKit asks only after the visit
|
|
2153
|
+
// finished, so a let-through visit that completed with no answer (no page,
|
|
2154
|
+
// no error bag, no error response: Inertia left) keeps it quiet until the
|
|
2155
|
+
// next visit starts.
|
|
2156
|
+
const released = new Set<unknown>();
|
|
2157
|
+
let answered = false;
|
|
2158
|
+
let leaving = false;
|
|
2159
|
+
const marked = (visit: { headers?: Record<string, string> }) =>
|
|
2160
|
+
getHeaderValue(visit.headers, WHITELIST_HEADER) === '1';
|
|
2161
|
+
const unbindStart = router.on('start', ({ detail: { visit } }) => {
|
|
2162
|
+
leaving = false;
|
|
2163
|
+
answered = false;
|
|
2164
|
+
if (marked(visit)) released.add(visit);
|
|
2165
|
+
});
|
|
2166
|
+
// Every way a visit gets an answer; the last two pairs are Inertia 3's
|
|
2167
|
+
// names and Inertia 2's (each fires only on its own version).
|
|
2168
|
+
const onRouterEvent = (name: string, listener: () => void) =>
|
|
2169
|
+
router.on(name as 'start', listener);
|
|
2170
|
+
const unbindAnswers = [
|
|
2171
|
+
'success',
|
|
2172
|
+
'error',
|
|
2173
|
+
'httpException',
|
|
2174
|
+
'invalid',
|
|
2175
|
+
'networkError',
|
|
2176
|
+
'exception',
|
|
2177
|
+
].map((name) => onRouterEvent(name, () => void (answered = true)));
|
|
2178
|
+
const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
|
|
2179
|
+
released.delete(visit);
|
|
2180
|
+
if (marked(visit) && visit.completed && !answered) leaving = true;
|
|
2181
|
+
});
|
|
2182
|
+
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
2183
|
+
if (toValue(disabled) || released.size || leaving) return;
|
|
2184
|
+
event.preventDefault();
|
|
2185
|
+
event.returnValue = '';
|
|
2186
|
+
};
|
|
2187
|
+
const inBrowser = typeof window !== 'undefined';
|
|
2188
|
+
if (inBrowser) window.addEventListener('beforeunload', onBeforeUnload);
|
|
2189
|
+
|
|
1414
2190
|
// `router.on` is a global bus that outlives this component: unbind it.
|
|
1415
2191
|
// `onScopeDispose` over `onUnmounted`: it fires on unmount too (`setup()`
|
|
1416
2192
|
// runs inside a component's own effect scope), but also works if this
|
|
1417
2193
|
// composable is ever called from outside one.
|
|
1418
|
-
onScopeDispose(
|
|
2194
|
+
onScopeDispose(() => {
|
|
2195
|
+
unbind();
|
|
2196
|
+
unbindStart();
|
|
2197
|
+
unbindAnswers.forEach((unbindAnswer) => unbindAnswer());
|
|
2198
|
+
unbindFinish();
|
|
2199
|
+
if (inBrowser) window.removeEventListener('beforeunload', onBeforeUnload);
|
|
2200
|
+
});
|
|
1419
2201
|
};
|
|
1420
2202
|
```
|
|
1421
2203
|
|
|
1422
2204
|
A prefetch never asks: `<Link prefetch>` and `<BbButton href prefetch>` fire
|
|
1423
2205
|
Inertia's `before` event on hover too, and the guard lets them through (they
|
|
1424
2206
|
leave nothing). The visit "Discard changes" re-sends is a plain one, never a
|
|
1425
|
-
prefetch or an `async` visit
|
|
2207
|
+
prefetch or an `async` visit, and it does not ask the visit's own `onBefore`
|
|
2208
|
+
(an `onBefore: () => confirm('Sure?')`) a second time. "Keep editing" ends
|
|
2209
|
+
the held visit as Inertia ends a cancelled one (`onCancel`, then `onFinish`
|
|
2210
|
+
with `cancelled: true`), so a `usePromiseForm` save held by the dialog
|
|
2211
|
+
answers the user's choice: the re-sent save's result, or `false`. Two guards
|
|
2212
|
+
on one page ask once. **Back / Forward are not guarded, by design:** Inertia restores
|
|
2213
|
+
those pages from history without a `before` event, and catching them would
|
|
2214
|
+
mean fighting Inertia's own history and scroll restore.
|
|
2215
|
+
|
|
2216
|
+
**The dialog speaks the library's `locale`.** Its default words come from
|
|
2217
|
+
`messages.ts` (see [Shared types and utilities](#shared-types-and-utilities)),
|
|
2218
|
+
which ships English only. Add your app's locale there once, keyed as you set
|
|
2219
|
+
the library's `locale`, and every guard follows `setConfig({ locale })`; a
|
|
2220
|
+
locale with no entry falls back to English. `title` / `text` / `yes` / `no`
|
|
2221
|
+
passed to one guard still win over it.
|
|
1426
2222
|
|
|
1427
2223
|
> **`router.on(...)` subscribes to a global, module-level event bus — it
|
|
1428
2224
|
> outlives the component that registered it unless you unbind it.** Inertia
|
|
@@ -1610,13 +2406,22 @@ export function useAsyncFn<T>(
|
|
|
1610
2406
|
"Given an id, keep its resource loaded": re-fetches whenever the id
|
|
1611
2407
|
changes, resets to `null` when the id clears, exposes `loading` and
|
|
1612
2408
|
`refresh()`. The natural engine for a detail sidebar or peek panel — feed
|
|
1613
|
-
it the highlighted row's id.
|
|
2409
|
+
it the highlighted row's id. `fn` gets an `AbortSignal` as its second argument
|
|
2410
|
+
(`(id, signal) => fetch(url, { signal })`): it aborts when the load goes stale
|
|
2411
|
+
(a newer id, a clear, the page unmounting).
|
|
1614
2412
|
|
|
1615
2413
|
```ts
|
|
1616
2414
|
// inertia-helpers/useResourceContext.ts
|
|
1617
2415
|
import { useLoading } from './useLoading';
|
|
1618
2416
|
import { noop } from './utils';
|
|
1619
|
-
import {
|
|
2417
|
+
import {
|
|
2418
|
+
onScopeDispose,
|
|
2419
|
+
ref,
|
|
2420
|
+
toValue,
|
|
2421
|
+
watch,
|
|
2422
|
+
type MaybeRefOrGetter,
|
|
2423
|
+
type Ref,
|
|
2424
|
+
} from 'vue';
|
|
1620
2425
|
|
|
1621
2426
|
type UseResourceContextOptions<T, ID> = {
|
|
1622
2427
|
immediate?: boolean;
|
|
@@ -1629,7 +2434,8 @@ type UseResourceContextOptions<T, ID> = {
|
|
|
1629
2434
|
|
|
1630
2435
|
export function useResourceContext<T, ID = string>(
|
|
1631
2436
|
id: MaybeRefOrGetter<ID | null | undefined>,
|
|
1632
|
-
|
|
2437
|
+
/** `signal` aborts when the load goes stale: pass it to `fetch` / axios. */
|
|
2438
|
+
fn: (id: ID, signal: AbortSignal) => Promise<T>,
|
|
1633
2439
|
options: UseResourceContextOptions<T, ID> = {}
|
|
1634
2440
|
) {
|
|
1635
2441
|
const data = ref<T | null>(null) as Ref<T | null>;
|
|
@@ -1643,31 +2449,69 @@ export function useResourceContext<T, ID = string>(
|
|
|
1643
2449
|
onFinish = noop,
|
|
1644
2450
|
} = options;
|
|
1645
2451
|
|
|
1646
|
-
const { loading, fn: fetch } = useLoading(
|
|
2452
|
+
const { loading, fn: fetch } = useLoading(
|
|
2453
|
+
(resourceId: ID, signal: AbortSignal) => fn(resourceId, signal)
|
|
2454
|
+
);
|
|
2455
|
+
|
|
2456
|
+
// Each load takes a ticket; a newer load, an id change or the scope's end
|
|
2457
|
+
// makes older tickets stale, and a stale response is dropped (no data, no
|
|
2458
|
+
// callbacks): a slow answer for the row the user left never lands. Going
|
|
2459
|
+
// stale also aborts its request.
|
|
2460
|
+
let ticket = 0;
|
|
2461
|
+
let controller: AbortController | null = null;
|
|
2462
|
+
const stale = () => {
|
|
2463
|
+
ticket++;
|
|
2464
|
+
controller?.abort();
|
|
2465
|
+
};
|
|
2466
|
+
let loadedId: ID | null = null;
|
|
2467
|
+
const clear = () => {
|
|
2468
|
+
stale();
|
|
2469
|
+
data.value = null;
|
|
2470
|
+
loadedId = null;
|
|
2471
|
+
};
|
|
2472
|
+
onScopeDispose(stale);
|
|
1647
2473
|
|
|
1648
2474
|
const load = async (resourceId: ID) => {
|
|
2475
|
+
stale();
|
|
2476
|
+
const own = ticket;
|
|
2477
|
+
controller = new AbortController();
|
|
2478
|
+
const outcome = await fetch(resourceId, controller.signal).then(
|
|
2479
|
+
(result) => ({ ok: true as const, result }),
|
|
2480
|
+
(error: unknown) => ({ ok: false as const, error })
|
|
2481
|
+
);
|
|
2482
|
+
if (own !== ticket) return;
|
|
1649
2483
|
try {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2484
|
+
if (outcome.ok) {
|
|
2485
|
+
data.value = outcome.result;
|
|
2486
|
+
loadedId = resourceId;
|
|
2487
|
+
// Outside the fetch's catch: a throwing onSuccess is the
|
|
2488
|
+
// caller's bug, not a failed fetch.
|
|
2489
|
+
await onSuccess(outcome.result, resourceId);
|
|
2490
|
+
} else {
|
|
2491
|
+
clear();
|
|
2492
|
+
await onError(outcome.error, resourceId);
|
|
2493
|
+
}
|
|
1656
2494
|
} finally {
|
|
1657
2495
|
await onFinish(resourceId);
|
|
1658
2496
|
}
|
|
1659
2497
|
};
|
|
1660
2498
|
|
|
2499
|
+
// Per-item sources: a re-run that yields the same id and flag (a partial
|
|
2500
|
+
// reload handing a new row object with the same id) does not refetch.
|
|
1661
2501
|
watch(
|
|
1662
|
-
() =>
|
|
1663
|
-
async (
|
|
2502
|
+
[() => toValue(id), () => toValue(disabled)],
|
|
2503
|
+
async ([resourceId, isDisabled]) => {
|
|
1664
2504
|
if (!resourceId) {
|
|
1665
|
-
|
|
2505
|
+
clear();
|
|
1666
2506
|
await options.onReset?.();
|
|
1667
2507
|
return;
|
|
1668
2508
|
}
|
|
1669
|
-
// Keep already-loaded data while disabled
|
|
1670
|
-
|
|
2509
|
+
// Keep already-loaded data while disabled, but only for the id it
|
|
2510
|
+
// was loaded for: never show row a's resource under row b.
|
|
2511
|
+
if (isDisabled) {
|
|
2512
|
+
if (resourceId !== loadedId) clear();
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
1671
2515
|
await load(resourceId as ID);
|
|
1672
2516
|
},
|
|
1673
2517
|
{ immediate }
|
|
@@ -1697,6 +2541,10 @@ Gotchas:
|
|
|
1697
2541
|
never rejects on a non-2xx response — a 403/404 body still parses as JSON
|
|
1698
2542
|
(or throws inside `.json()`, which is _also_ swallowed by `useResourceContext`
|
|
1699
2543
|
the same way). Throw when `!res.ok` so `onError` actually fires.
|
|
2544
|
+
- **Only the current id's answer lands.** A slower response for a row the
|
|
2545
|
+
user already left, or one landing after the id cleared or the scope ended,
|
|
2546
|
+
is dropped: no `data`, no callbacks. While `disabled`, `data` is kept only
|
|
2547
|
+
for the id it was loaded for; an id change clears it (re-enabling loads).
|
|
1700
2548
|
|
|
1701
2549
|
## UI utilities
|
|
1702
2550
|
|
|
@@ -1741,28 +2589,32 @@ export function useDeleteCmd(
|
|
|
1741
2589
|
|
|
1742
2590
|
### useScreenSizes
|
|
1743
2591
|
|
|
1744
|
-
Shared reactive breakpoints
|
|
1745
|
-
|
|
2592
|
+
Shared reactive breakpoints — one window listener for the whole app via
|
|
2593
|
+
`createSharedComposable`. `isMobile` is the library's own signal (below the
|
|
2594
|
+
plugin's `mobileMaxWidth`, 768 by default), so a page and its components
|
|
2595
|
+
agree; tablet ends at Tailwind `lg`.
|
|
1746
2596
|
|
|
1747
2597
|
```ts
|
|
1748
2598
|
// inertia-helpers/useScreenSizes.ts
|
|
1749
2599
|
import { createSharedComposable, useWindowSize } from '@vueuse/core';
|
|
2600
|
+
import { useMobile } from 'bitboss-ui';
|
|
1750
2601
|
import { computed } from 'vue';
|
|
1751
2602
|
|
|
1752
|
-
/** Tailwind
|
|
1753
|
-
export const SCREEN_BREAKPOINT_MD = 768;
|
|
1754
|
-
/** Tailwind lg breakpoint (px). Tablet when 768 <= width < 1024. */
|
|
2603
|
+
/** Tailwind lg breakpoint (px). Tablet from the library's mobile cut up to here. */
|
|
1755
2604
|
export const SCREEN_BREAKPOINT_LG = 1024;
|
|
1756
2605
|
|
|
1757
2606
|
const useScreenSizesImpl = () => {
|
|
1758
2607
|
const { width } = useWindowSize();
|
|
2608
|
+
// The library's own signal (below the plugin's `mobileMaxWidth`), so a page
|
|
2609
|
+
// and the components on it never disagree about "mobile".
|
|
2610
|
+
const { isMobile } = useMobile();
|
|
1759
2611
|
|
|
1760
|
-
const isMobile = computed(() => width.value < SCREEN_BREAKPOINT_MD);
|
|
1761
2612
|
const isTablet = computed(
|
|
1762
|
-
() =>
|
|
1763
|
-
|
|
2613
|
+
() => !isMobile.value && width.value < SCREEN_BREAKPOINT_LG
|
|
2614
|
+
);
|
|
2615
|
+
const isDesktop = computed(
|
|
2616
|
+
() => !isMobile.value && width.value >= SCREEN_BREAKPOINT_LG
|
|
1764
2617
|
);
|
|
1765
|
-
const isDesktop = computed(() => width.value >= SCREEN_BREAKPOINT_LG);
|
|
1766
2618
|
|
|
1767
2619
|
return { width, isMobile, isTablet, isDesktop };
|
|
1768
2620
|
};
|