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
package/dist/llms-full.txt
CHANGED
|
@@ -54562,13 +54562,14 @@ plugin (`bitbossUi({ ... })`) and cannot be changed at runtime.
|
|
|
54562
54562
|
|
|
54563
54563
|
Use it for exactly one job: switching the locale of locale-aware components
|
|
54564
54564
|
(built-in labels, close buttons, table/pagination strings) while the app runs.
|
|
54565
|
+
`locale` reads the current setting back, read-only.
|
|
54565
54566
|
|
|
54566
54567
|
## Import
|
|
54567
54568
|
|
|
54568
54569
|
```ts
|
|
54569
54570
|
import { useBbConfig } from 'bitboss-ui';
|
|
54570
54571
|
|
|
54571
|
-
const { setConfig } = useBbConfig();
|
|
54572
|
+
const { setConfig, locale } = useBbConfig();
|
|
54572
54573
|
```
|
|
54573
54574
|
|
|
54574
54575
|
## API
|
|
@@ -54583,6 +54584,12 @@ declare function setConfig(patch: Partial<DynamicConfig>): void;
|
|
|
54583
54584
|
Merges the patch into **this app's** config. The change is immediate and
|
|
54584
54585
|
reactive — every mounted component in that app which reads the key updates.
|
|
54585
54586
|
|
|
54587
|
+
### `locale`
|
|
54588
|
+
|
|
54589
|
+
A read-only `ComputedRef` of this app's locale exactly as it was set (`'it'`,
|
|
54590
|
+
`'it-IT'`), following every `setConfig`. Read it to pick your own words in the
|
|
54591
|
+
same language as the library's (the Inertia helpers' `messages.ts` does).
|
|
54592
|
+
|
|
54586
54593
|
### Per-request locale under SSR
|
|
54587
54594
|
|
|
54588
54595
|
The config is held **per Vue app**, and SSR gives every request its own app,
|
|
@@ -61094,6 +61101,12 @@ nothing imports them. Your own `@/utils` and `@/types` stay yours. If the app
|
|
|
61094
61101
|
auto-imports `composables/**`, exclude one of the two trees until the old
|
|
61095
61102
|
copies are gone, or both export `useQuery`.
|
|
61096
61103
|
|
|
61104
|
+
The helpers run and type-check on Inertia 2 and 3 (`@inertiajs/vue3` 2.x or
|
|
61105
|
+
3.x). On Inertia 2, `useLazyProp`'s reload cannot keep the page's validation
|
|
61106
|
+
errors (Inertia 2 has no `preserveErrors`), and `usePromiseForm` hears a failed
|
|
61107
|
+
save through Inertia's global `invalid` / `exception` events, so an async reload
|
|
61108
|
+
that fails while a save is out counts as that save failing.
|
|
61109
|
+
|
|
61097
61110
|
The helpers need these npm packages beyond the app's own
|
|
61098
61111
|
`@inertiajs/vue3`/`vue` (`add` prints the ones a helper needs). Install them
|
|
61099
61112
|
explicitly rather than relying on them being hoisted through `bitboss-ui`'s own
|
|
@@ -61109,10 +61122,12 @@ npm i -D @inertiajs/core
|
|
|
61109
61122
|
`usePageShellContext`) and `useDeleteCmd`/`useScreenSizes`; `object-code`
|
|
61110
61123
|
powers the `isEqual` hash-comparison utility below; `query-string` backs
|
|
61111
61124
|
`useQuery`/`useRouteQuery`/`useQueryObject`; `@inertiajs/core` is a type-only
|
|
61112
|
-
import (`PageProps` in
|
|
61113
|
-
does not re-export, so a strict pnpm app fails to type-check without it.
|
|
61114
|
-
|
|
61115
|
-
|
|
61125
|
+
import (`PageProps`, `VisitOptions`, in the URL and context helpers) that `@inertiajs/vue3`
|
|
61126
|
+
does not re-export, so a strict pnpm app fails to type-check without it. Install
|
|
61127
|
+
the version your `@inertiajs/vue3` runs on (`add` prints it pinned, e.g.
|
|
61128
|
+
`@inertiajs/core@3.1.1`): a newer core's visit types do not match. The
|
|
61129
|
+
helpers' shared types, tiny utilities and words are three files of the
|
|
61130
|
+
folder, `types.ts`, `utils.ts` and `messages.ts`, below.
|
|
61116
61131
|
|
|
61117
61132
|
## Shared types and utilities
|
|
61118
61133
|
|
|
@@ -61176,10 +61191,76 @@ export const isPlainObject = (
|
|
|
61176
61191
|
typeof value === 'object' &&
|
|
61177
61192
|
value !== null &&
|
|
61178
61193
|
Object.getPrototypeOf(value) === Object.prototype;
|
|
61194
|
+
|
|
61195
|
+
/**
|
|
61196
|
+
* Deep copy of server data (arrays and plain objects; anything else, e.g. a
|
|
61197
|
+
* Date an app put there, is shared). Reads through Vue proxies, which
|
|
61198
|
+
* `structuredClone` refuses.
|
|
61199
|
+
*/
|
|
61200
|
+
export const cloneDeep = <T>(value: T): T =>
|
|
61201
|
+
(Array.isArray(value)
|
|
61202
|
+
? value.map(cloneDeep)
|
|
61203
|
+
: isPlainObject(value)
|
|
61204
|
+
? Object.fromEntries(
|
|
61205
|
+
Object.entries(value).map(([key, item]) => [key, cloneDeep(item)])
|
|
61206
|
+
)
|
|
61207
|
+
: value) as T;
|
|
61208
|
+
```
|
|
61209
|
+
|
|
61210
|
+
`messages.ts` holds the words a helper shows (today the leave guard's dialog),
|
|
61211
|
+
by the library's `locale` (`useBbConfig().locale`): `it-IT`, else `it`, else
|
|
61212
|
+
`en`. It is the one helper file meant to be edited: add your locales next to
|
|
61213
|
+
`en`. `helpers update` keeps your copy; when a release adds words here, it
|
|
61214
|
+
holds back this file and the helpers that read it until you merge them
|
|
61215
|
+
(`npx bitboss-ui helpers diff messages`).
|
|
61216
|
+
|
|
61217
|
+
```ts
|
|
61218
|
+
// inertia-helpers/messages.ts
|
|
61219
|
+
// The words the helpers show, by the library's `locale`. Yours to edit: add
|
|
61220
|
+
// your app's locales next to `en` (keyed as you set `locale`: `it`, or `it-IT`
|
|
61221
|
+
// to tell regions apart). `helpers update` keeps your copy; if a release
|
|
61222
|
+
// adds words here, `npx bitboss-ui helpers diff messages` shows what to merge.
|
|
61223
|
+
import { useBbConfig } from 'bitboss-ui';
|
|
61224
|
+
import { computed } from 'vue';
|
|
61225
|
+
|
|
61226
|
+
export type HelperMessages = {
|
|
61227
|
+
leave: { title: string; text: string; yes: string; no: string };
|
|
61228
|
+
};
|
|
61229
|
+
|
|
61230
|
+
export const messages: Record<string, HelperMessages> = {
|
|
61231
|
+
en: {
|
|
61232
|
+
leave: {
|
|
61233
|
+
title: 'Unsaved changes',
|
|
61234
|
+
text: 'You have unsaved changes. Leave without saving?',
|
|
61235
|
+
yes: 'Discard changes',
|
|
61236
|
+
no: 'Keep editing',
|
|
61237
|
+
},
|
|
61238
|
+
},
|
|
61239
|
+
};
|
|
61240
|
+
|
|
61241
|
+
/** The messages for the current locale: `it-IT`, else `it`, else `en`. */
|
|
61242
|
+
export const useHelperMessages = () => {
|
|
61243
|
+
const { locale } = useBbConfig();
|
|
61244
|
+
return computed(
|
|
61245
|
+
() =>
|
|
61246
|
+
messages[locale.value] ??
|
|
61247
|
+
messages[locale.value.split('-')[0]!.toLowerCase()] ??
|
|
61248
|
+
messages.en!
|
|
61249
|
+
);
|
|
61250
|
+
};
|
|
61179
61251
|
```
|
|
61180
61252
|
|
|
61181
61253
|
---
|
|
61182
61254
|
|
|
61255
|
+
**Unit-testing code that uses the helpers.** A test that mocks
|
|
61256
|
+
`@inertiajs/vue3` has to follow Inertia's own contract, or the helpers read the
|
|
61257
|
+
mock as a refused or broken visit: `router.visit` / `reload` call `onStart`
|
|
61258
|
+
synchronously (a visit with no `onStart` counts as refused before it started);
|
|
61259
|
+
`onFinish` receives the visit with `cancelled` / `interrupted` / `completed`;
|
|
61260
|
+
`router.on` returns its unbind function. `usePaginatedResponseContext` clamps a
|
|
61261
|
+
page past the end in `onMounted`, so call it from a mounted component, not a
|
|
61262
|
+
bare `effectScope`.
|
|
61263
|
+
|
|
61183
61264
|
## Page props
|
|
61184
61265
|
|
|
61185
61266
|
### useProp / useOptionalProp
|
|
@@ -61272,9 +61353,17 @@ when the data lands. `loading`/`loaded`/`error` expose the fetch state;
|
|
|
61272
61353
|
```ts
|
|
61273
61354
|
// inertia-helpers/useLazyProp.ts
|
|
61274
61355
|
import { isEqual, isNil, isNotNil } from './utils';
|
|
61356
|
+
import type { PageProps } from '@inertiajs/core';
|
|
61275
61357
|
import { router, usePage } from '@inertiajs/vue3';
|
|
61276
61358
|
import type { Ref } from 'vue';
|
|
61277
|
-
import {
|
|
61359
|
+
import {
|
|
61360
|
+
customRef,
|
|
61361
|
+
nextTick,
|
|
61362
|
+
onBeforeUnmount,
|
|
61363
|
+
readonly,
|
|
61364
|
+
ref,
|
|
61365
|
+
watch,
|
|
61366
|
+
} from 'vue';
|
|
61278
61367
|
|
|
61279
61368
|
export type UseLazyPropOptions = {
|
|
61280
61369
|
/** Keep the last non-nil value across visits that reset the prop. Default true. */
|
|
@@ -61306,7 +61395,9 @@ export function useLazyProp<T>(
|
|
|
61306
61395
|
initialValue?: T,
|
|
61307
61396
|
options: UseLazyPropOptions = {}
|
|
61308
61397
|
): UseLazyProp<T | undefined> {
|
|
61309
|
-
|
|
61398
|
+
// `PageProps &` keeps usePage's constraint satisfied in an app that
|
|
61399
|
+
// augments Inertia's `PageProps` (TS2344 otherwise).
|
|
61400
|
+
const page = usePage<PageProps & { [key: string]: T | undefined }>();
|
|
61310
61401
|
const { cache = true } = options;
|
|
61311
61402
|
const loading = ref(false);
|
|
61312
61403
|
const loaded = ref(page.props[propName] !== undefined);
|
|
@@ -61314,50 +61405,107 @@ export function useLazyProp<T>(
|
|
|
61314
61405
|
let inFlightPromise: Promise<void> | null = null;
|
|
61315
61406
|
let isUnmounted = false;
|
|
61316
61407
|
let cached = page.props[propName];
|
|
61408
|
+
// Settles a reload a navigation cancelled, once that navigation is over.
|
|
61409
|
+
let resumeCancelled: (() => void) | null = null;
|
|
61317
61410
|
|
|
61318
61411
|
onBeforeUnmount(() => {
|
|
61319
61412
|
isUnmounted = true;
|
|
61413
|
+
resumeCancelled?.();
|
|
61320
61414
|
});
|
|
61321
61415
|
|
|
61322
61416
|
const loadProp = (onSuccess: () => void): Promise<void> => {
|
|
61323
61417
|
if (inFlightPromise) return inFlightPromise;
|
|
61324
61418
|
|
|
61325
61419
|
error.value = null;
|
|
61326
|
-
|
|
61327
|
-
|
|
61328
|
-
|
|
61329
|
-
|
|
61330
|
-
|
|
61331
|
-
|
|
61332
|
-
|
|
61333
|
-
|
|
61334
|
-
|
|
61335
|
-
onSuccess: () => {
|
|
61336
|
-
if (!isUnmounted) {
|
|
61337
|
-
loaded.value = true;
|
|
61338
|
-
cached = page.props[propName];
|
|
61339
|
-
onSuccess();
|
|
61340
|
-
}
|
|
61341
|
-
},
|
|
61342
|
-
onError: (errors) => {
|
|
61343
|
-
if (!isUnmounted) {
|
|
61344
|
-
error.value =
|
|
61345
|
-
errors instanceof Error
|
|
61346
|
-
? errors
|
|
61347
|
-
: new Error(`Failed to load lazy prop "${propName}"`);
|
|
61348
|
-
}
|
|
61349
|
-
},
|
|
61350
|
-
onFinish: () => {
|
|
61351
|
-
if (!isUnmounted) {
|
|
61352
|
-
loading.value = false;
|
|
61353
|
-
}
|
|
61354
|
-
inFlightPromise = null;
|
|
61355
|
-
resolve();
|
|
61356
|
-
},
|
|
61357
|
-
});
|
|
61420
|
+
let started = false;
|
|
61421
|
+
let succeeded = false;
|
|
61422
|
+
let settle!: () => void;
|
|
61423
|
+
const promise = new Promise<void>((resolve) => {
|
|
61424
|
+
settle = () => {
|
|
61425
|
+
if (!isUnmounted) loading.value = false;
|
|
61426
|
+
if (inFlightPromise === promise) inFlightPromise = null;
|
|
61427
|
+
resolve();
|
|
61428
|
+
};
|
|
61358
61429
|
});
|
|
61430
|
+
inFlightPromise = promise;
|
|
61431
|
+
|
|
61432
|
+
router.reload({
|
|
61433
|
+
only: [propName],
|
|
61434
|
+
replace: true,
|
|
61435
|
+
// Like Inertia's own deferred-props loader: keep the page's
|
|
61436
|
+
// validation errors (Inertia 3; Inertia 2 has no such option).
|
|
61437
|
+
...({ preserveErrors: true } as object),
|
|
61438
|
+
...(options.headers ? { headers: options.headers } : {}),
|
|
61439
|
+
onStart: () => {
|
|
61440
|
+
started = true;
|
|
61441
|
+
loading.value = true;
|
|
61442
|
+
},
|
|
61443
|
+
onSuccess: () => {
|
|
61444
|
+
succeeded = true;
|
|
61445
|
+
if (!isUnmounted) {
|
|
61446
|
+
loaded.value = true;
|
|
61447
|
+
cached = page.props[propName];
|
|
61448
|
+
onSuccess();
|
|
61449
|
+
}
|
|
61450
|
+
},
|
|
61451
|
+
onError: (errors) => {
|
|
61452
|
+
if (!isUnmounted) {
|
|
61453
|
+
error.value =
|
|
61454
|
+
errors instanceof Error
|
|
61455
|
+
? errors
|
|
61456
|
+
: new Error(`Failed to load lazy prop "${propName}"`);
|
|
61457
|
+
}
|
|
61458
|
+
},
|
|
61459
|
+
onFinish: (visit) => {
|
|
61460
|
+
// A visit to another page cancels this reload: no failure. Stay
|
|
61461
|
+
// loading (a reader must not re-fire it at the outgoing URL) until
|
|
61462
|
+
// that navigation is over, then retry if this page is still
|
|
61463
|
+
// mounted (a same-component visit) or settle if it unmounted.
|
|
61464
|
+
if (visit.cancelled) {
|
|
61465
|
+
const cancelledOn = page.url;
|
|
61466
|
+
const unbindFinish = router.on('finish', (event) => {
|
|
61467
|
+
const next = event.detail.visit;
|
|
61468
|
+
if (next.cancelled || next.interrupted) return;
|
|
61469
|
+
// An async visit is over too (a mutation answered on another
|
|
61470
|
+
// URL redirects back with no other visit), unless it is one
|
|
61471
|
+
// of this page's own reloads (a poll, another lazy prop).
|
|
61472
|
+
if (
|
|
61473
|
+
next.async &&
|
|
61474
|
+
next.url.href === new URL(cancelledOn, next.url).href
|
|
61475
|
+
)
|
|
61476
|
+
return;
|
|
61477
|
+
resumeCancelled?.();
|
|
61478
|
+
});
|
|
61479
|
+
// Back / Forward swaps the page with no finish event.
|
|
61480
|
+
const unbindNavigate = router.on('navigate', () =>
|
|
61481
|
+
resumeCancelled?.()
|
|
61482
|
+
);
|
|
61483
|
+
resumeCancelled = () => {
|
|
61484
|
+
resumeCancelled = null;
|
|
61485
|
+
unbindFinish();
|
|
61486
|
+
unbindNavigate();
|
|
61487
|
+
void nextTick(() => {
|
|
61488
|
+
inFlightPromise = null;
|
|
61489
|
+
if (isUnmounted || loaded.value) settle();
|
|
61490
|
+
else void loadProp(onSuccess).then(settle);
|
|
61491
|
+
});
|
|
61492
|
+
};
|
|
61493
|
+
return;
|
|
61494
|
+
}
|
|
61495
|
+
// A network error or a non-Inertia response (Inertia's error
|
|
61496
|
+
// dialog) reaches only onFinish: report it. The self-loading read
|
|
61497
|
+
// does not retry a failed load; `execute()` does.
|
|
61498
|
+
if (!succeeded && !error.value && !isUnmounted) {
|
|
61499
|
+
error.value = new Error(`Failed to load lazy prop "${propName}"`);
|
|
61500
|
+
}
|
|
61501
|
+
settle();
|
|
61502
|
+
},
|
|
61503
|
+
});
|
|
61504
|
+
// Refused before it started (a `before` listener cancelled it, or no
|
|
61505
|
+
// `window` during SSR): no callback will ever come.
|
|
61506
|
+
if (!started) settle();
|
|
61359
61507
|
|
|
61360
|
-
return
|
|
61508
|
+
return promise;
|
|
61361
61509
|
};
|
|
61362
61510
|
|
|
61363
61511
|
/** Self-loading ref: reading it while the prop is absent starts the fetch. */
|
|
@@ -61366,8 +61514,7 @@ export function useLazyProp<T>(
|
|
|
61366
61514
|
track();
|
|
61367
61515
|
const isMissing = page.props[propName] === undefined;
|
|
61368
61516
|
|
|
61369
|
-
if (isMissing && !loaded.value && !loading.value) {
|
|
61370
|
-
loading.value = true;
|
|
61517
|
+
if (isMissing && !loaded.value && !loading.value && !error.value) {
|
|
61371
61518
|
void loadProp(trigger);
|
|
61372
61519
|
}
|
|
61373
61520
|
|
|
@@ -61426,6 +61573,10 @@ Gotchas:
|
|
|
61426
61573
|
load `stats.revenue`, only `stats`.
|
|
61427
61574
|
- Filter navigations reset lazy props server-side; with `cache: true`
|
|
61428
61575
|
(default) the last value keeps rendering while the fresh one loads.
|
|
61576
|
+
- A failed load (offline, a non-Inertia 500) sets `error` and is **not**
|
|
61577
|
+
retried by the next read — that would be a request storm. `execute()`
|
|
61578
|
+
retries. A load a navigation cancels is no failure: it stays `loading`
|
|
61579
|
+
until that navigation is over, then retries if the page is still mounted.
|
|
61429
61580
|
|
|
61430
61581
|
### useLazyPropGetter
|
|
61431
61582
|
|
|
@@ -61476,7 +61627,9 @@ export function useLazyPropGetter<T, U = T>(
|
|
|
61476
61627
|
if (!loaded.value) {
|
|
61477
61628
|
await execute();
|
|
61478
61629
|
}
|
|
61479
|
-
|
|
61630
|
+
// `loaded` first: reading `value` while unloaded would start a
|
|
61631
|
+
// second, background reload.
|
|
61632
|
+
if (!loaded.value || isNil(value.value)) {
|
|
61480
61633
|
throw new Error(`Could not load lazy prop "${propName}"`);
|
|
61481
61634
|
}
|
|
61482
61635
|
if (isNil(formatter)) return value.value as unknown as U;
|
|
@@ -61514,29 +61667,163 @@ navigation.
|
|
|
61514
61667
|
|
|
61515
61668
|
### useQuery
|
|
61516
61669
|
|
|
61517
|
-
Parses the current page URL's query string into an object
|
|
61518
|
-
|
|
61670
|
+
Parses the current page URL's query string into an object, the way the
|
|
61671
|
+
server reads it: decoded once, comma arrays split (an encoded `%2C` too, as
|
|
61672
|
+
PHP decodes it before Spatie splits — so with the comma format a comma inside
|
|
61673
|
+
a free-text value reads back as a separator, exactly as the server sees it).
|
|
61674
|
+
Snapshot, not reactive — used by the writers below.
|
|
61675
|
+
|
|
61676
|
+
The file is also the one URL writer every helper shares (`withQuery`,
|
|
61677
|
+
`visitUrl`): while a write's visit is in flight, every reader sees the URL it
|
|
61678
|
+
is heading to and every writer builds on it. So a sort and a direction set in
|
|
61679
|
+
one click both land, a filter change followed by `page = 1` keeps the filter,
|
|
61680
|
+
undoing a write before the server answers is not swallowed, and a ref reads
|
|
61681
|
+
back what was just written. Any visit that lands supersedes it. A write
|
|
61682
|
+
changes only its own keys: the other parameters keep their order and
|
|
61683
|
+
encoding, and a `#fragment` stays a fragment.
|
|
61519
61684
|
|
|
61520
61685
|
```ts
|
|
61521
61686
|
// inertia-helpers/useQuery.ts
|
|
61522
61687
|
// requires: npm i query-string
|
|
61523
|
-
import {
|
|
61688
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
61689
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
61524
61690
|
import qs, { type StringifyOptions } from 'query-string';
|
|
61691
|
+
import { shallowRef } from 'vue';
|
|
61692
|
+
|
|
61693
|
+
/**
|
|
61694
|
+
* How arrays sit in the query string, for every helper that reads or writes
|
|
61695
|
+
* it: `tags=a,b`, what Spatie QueryBuilder parses. Change it here, once —
|
|
61696
|
+
* a reader and a writer that disagree turn an array into a string.
|
|
61697
|
+
*/
|
|
61698
|
+
export const QUERY_ARRAY_FORMAT = 'comma';
|
|
61699
|
+
|
|
61700
|
+
/** A URL's parts. The first `?` starts the query; a `#` ends it. */
|
|
61701
|
+
export const splitUrl = (url: string) => {
|
|
61702
|
+
const hashAt = url.indexOf('#');
|
|
61703
|
+
const hash = hashAt < 0 ? '' : url.slice(hashAt);
|
|
61704
|
+
const rest = hashAt < 0 ? url : url.slice(0, hashAt);
|
|
61705
|
+
const queryAt = rest.indexOf('?');
|
|
61706
|
+
return queryAt < 0
|
|
61707
|
+
? { path: rest, query: '', hash }
|
|
61708
|
+
: { path: rest.slice(0, queryAt), query: rest.slice(queryAt + 1), hash };
|
|
61709
|
+
};
|
|
61710
|
+
|
|
61711
|
+
/**
|
|
61712
|
+
* The URL the last write is heading to, while its visit is in flight. Every
|
|
61713
|
+
* reader and writer builds on it, so two writes before the server answers
|
|
61714
|
+
* both land, and a write reads back at once. It counts only while `page.url`
|
|
61715
|
+
* is still the one it was built on: any visit that lands supersedes it.
|
|
61716
|
+
*/
|
|
61717
|
+
const pending = shallowRef<{ from: string; url: string } | null>(null);
|
|
61718
|
+
|
|
61719
|
+
/** `page.url`, or where the write in flight is taking it. */
|
|
61720
|
+
export const currentUrl = (): string => {
|
|
61721
|
+
const { url } = usePage();
|
|
61722
|
+
return pending.value?.from === url ? pending.value.url : url;
|
|
61723
|
+
};
|
|
61724
|
+
|
|
61725
|
+
const decodeKey = (raw: string): string => {
|
|
61726
|
+
try {
|
|
61727
|
+
return decodeURIComponent(raw.replace(/\+/g, ' '));
|
|
61728
|
+
} catch {
|
|
61729
|
+
return raw;
|
|
61730
|
+
}
|
|
61731
|
+
};
|
|
61732
|
+
|
|
61733
|
+
/**
|
|
61734
|
+
* `url` with `changes` written in. A nullish, empty-string or empty-array
|
|
61735
|
+
* value removes its key. Every other parameter is kept as it was written, in
|
|
61736
|
+
* its place: a write never re-encodes or re-orders what it does not own.
|
|
61737
|
+
*/
|
|
61738
|
+
export const withQuery = (
|
|
61739
|
+
url: string,
|
|
61740
|
+
changes: Record<string, unknown>
|
|
61741
|
+
): string => {
|
|
61742
|
+
const { path, query, hash } = splitUrl(url);
|
|
61743
|
+
const encoded = (key: string): string[] => {
|
|
61744
|
+
const value = changes[key];
|
|
61745
|
+
if (value == null || value === '') return [];
|
|
61746
|
+
if (Array.isArray(value) && value.length === 0) return [];
|
|
61747
|
+
return [
|
|
61748
|
+
qs.stringify({ [key]: value }, { arrayFormat: QUERY_ARRAY_FORMAT }),
|
|
61749
|
+
];
|
|
61750
|
+
};
|
|
61751
|
+
const written = new Set<string>();
|
|
61752
|
+
const parts = query
|
|
61753
|
+
.split('&')
|
|
61754
|
+
.filter(Boolean)
|
|
61755
|
+
.flatMap((part) => {
|
|
61756
|
+
const key = decodeKey(part.split('=')[0] ?? '');
|
|
61757
|
+
if (!(key in changes)) return [part];
|
|
61758
|
+
if (written.has(key)) return [];
|
|
61759
|
+
written.add(key);
|
|
61760
|
+
return encoded(key);
|
|
61761
|
+
});
|
|
61762
|
+
Object.keys(changes)
|
|
61763
|
+
.filter((key) => !written.has(key))
|
|
61764
|
+
.forEach((key) => parts.push(...encoded(key)));
|
|
61765
|
+
return `${path}${parts.length ? `?${parts.join('&')}` : ''}${hash}`;
|
|
61766
|
+
};
|
|
61767
|
+
|
|
61768
|
+
/**
|
|
61769
|
+
* Takes the page to `url`: an Inertia visit, or with `server: false` a URL
|
|
61770
|
+
* write with no request. Readers see `url` from now until the visit finishes.
|
|
61771
|
+
*/
|
|
61772
|
+
export const visitUrl = (
|
|
61773
|
+
url: string,
|
|
61774
|
+
options: Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>,
|
|
61775
|
+
server = true
|
|
61776
|
+
): void => {
|
|
61777
|
+
const from = usePage().url;
|
|
61778
|
+
const write = { from, url };
|
|
61779
|
+
pending.value = write;
|
|
61780
|
+
const settle = () => {
|
|
61781
|
+
if (pending.value === write) pending.value = null;
|
|
61782
|
+
};
|
|
61783
|
+
if (server) {
|
|
61784
|
+
let started = false;
|
|
61785
|
+
router.visit(url, {
|
|
61786
|
+
...options,
|
|
61787
|
+
onStart: (visit) => {
|
|
61788
|
+
started = true;
|
|
61789
|
+
options.onStart?.(visit);
|
|
61790
|
+
},
|
|
61791
|
+
onFinish: (visit) => {
|
|
61792
|
+
settle();
|
|
61793
|
+
options.onFinish?.(visit);
|
|
61794
|
+
},
|
|
61795
|
+
});
|
|
61796
|
+
// A visit a `before` listener refused never starts, and never finishes.
|
|
61797
|
+
if (!started) settle();
|
|
61798
|
+
return;
|
|
61799
|
+
}
|
|
61800
|
+
const { preserveState, preserveScroll, replace } = options;
|
|
61801
|
+
const client = { url, preserveState, preserveScroll, onFinish: settle };
|
|
61802
|
+
if (replace) router.replace(client);
|
|
61803
|
+
else router.push(client);
|
|
61804
|
+
};
|
|
61805
|
+
|
|
61806
|
+
/** The query of `url` as an object, the way the server reads it. */
|
|
61807
|
+
export const parseQuery = (
|
|
61808
|
+
url: string,
|
|
61809
|
+
arrayFormat: StringifyOptions['arrayFormat'] = QUERY_ARRAY_FORMAT
|
|
61810
|
+
): Record<string, unknown> => {
|
|
61811
|
+
const { query } = splitUrl(url);
|
|
61812
|
+
if (!query) return {};
|
|
61813
|
+
// `qs.parse` decodes once. An encoded comma separates too: Laravel's pager
|
|
61814
|
+
// links write `a%2Cb`, which PHP decodes to `a,b` before Spatie splits it.
|
|
61815
|
+
return qs.parse(
|
|
61816
|
+
arrayFormat === 'comma' ? query.replace(/%2C/gi, ',') : query,
|
|
61817
|
+
{ arrayFormat }
|
|
61818
|
+
);
|
|
61819
|
+
};
|
|
61525
61820
|
|
|
61526
61821
|
export const useQuery = ({
|
|
61527
|
-
arrayFormat =
|
|
61822
|
+
arrayFormat = QUERY_ARRAY_FORMAT,
|
|
61528
61823
|
}: { arrayFormat?: StringifyOptions['arrayFormat'] } = {}): Record<
|
|
61529
61824
|
string,
|
|
61530
61825
|
unknown
|
|
61531
|
-
> =>
|
|
61532
|
-
const { url } = usePage();
|
|
61533
|
-
const [, queryString] = url.split('?');
|
|
61534
|
-
if (!queryString) return {};
|
|
61535
|
-
return qs.parse(decodeURIComponent(queryString), {
|
|
61536
|
-
arrayFormat,
|
|
61537
|
-
decode: true,
|
|
61538
|
-
});
|
|
61539
|
-
};
|
|
61826
|
+
> => parseQuery(currentUrl(), arrayFormat);
|
|
61540
61827
|
```
|
|
61541
61828
|
|
|
61542
61829
|
**Don't swap `query-string` for a hand-rolled `URLSearchParams` reader.** It
|
|
@@ -61553,14 +61840,19 @@ assume the parsed-array shape. Install the dependency.
|
|
|
61553
61840
|
### useRouteQuery
|
|
61554
61841
|
|
|
61555
61842
|
A single query parameter as a **writable ref**. Reading tracks the current
|
|
61556
|
-
URL
|
|
61557
|
-
|
|
61558
|
-
safe to sync from server
|
|
61843
|
+
URL (a repeated key reads its last value, as PHP does); writing merges the
|
|
61844
|
+
parameter and performs a (replace) visit, so the server re-renders with the
|
|
61845
|
+
new value. Writing the current value is a no-op — safe to sync from server
|
|
61846
|
+
responses without visit loops. Writing `null` / `undefined` or the default
|
|
61847
|
+
removes the key. Options, as on `useQueryObject`: `visitOptions` (Inertia's
|
|
61848
|
+
`VisitOptions`, merged over `preserveState` / `preserveScroll` /
|
|
61849
|
+
`replace: true`) and `server: false` for a URL write with no request.
|
|
61559
61850
|
|
|
61560
61851
|
```ts
|
|
61561
61852
|
// inertia-helpers/useRouteQuery.ts
|
|
61562
61853
|
// requires: npm i query-string
|
|
61563
|
-
import {
|
|
61854
|
+
import { currentUrl, splitUrl, visitUrl, withQuery } from './useQuery';
|
|
61855
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
61564
61856
|
import qs from 'query-string';
|
|
61565
61857
|
import {
|
|
61566
61858
|
computed,
|
|
@@ -61572,8 +61864,15 @@ import {
|
|
|
61572
61864
|
type Options<T> = {
|
|
61573
61865
|
/** Parse the raw string (e.g. `Number`). Defaults to identity. */
|
|
61574
61866
|
transform?: (value: string) => T;
|
|
61575
|
-
|
|
61576
|
-
|
|
61867
|
+
/** `false` writes the URL with no request. Default `true`. */
|
|
61868
|
+
server?: boolean;
|
|
61869
|
+
/**
|
|
61870
|
+
* Merged over `{ preserveState, preserveScroll, replace: true }`. With
|
|
61871
|
+
* `server: false` only those three apply.
|
|
61872
|
+
*/
|
|
61873
|
+
visitOptions?: MaybeRefOrGetter<
|
|
61874
|
+
Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
|
|
61875
|
+
>;
|
|
61577
61876
|
};
|
|
61578
61877
|
|
|
61579
61878
|
export function useRouteQuery<T = string>(
|
|
@@ -61584,37 +61883,34 @@ export function useRouteQuery<T = string>(
|
|
|
61584
61883
|
): WritableComputedRef<T> {
|
|
61585
61884
|
const {
|
|
61586
61885
|
transform = (value) => value as unknown as T,
|
|
61587
|
-
|
|
61588
|
-
|
|
61886
|
+
server = true,
|
|
61887
|
+
visitOptions,
|
|
61589
61888
|
} = options;
|
|
61590
|
-
const page = usePage();
|
|
61591
61889
|
|
|
61592
61890
|
const readRaw = (): string => {
|
|
61593
|
-
const
|
|
61594
|
-
|
|
61595
|
-
const single = Array.isArray(raw) ? raw
|
|
61891
|
+
const raw = qs.parse(splitUrl(currentUrl()).query)[key];
|
|
61892
|
+
// A repeated key reads its last value, as PHP does.
|
|
61893
|
+
const single = Array.isArray(raw) ? raw.at(-1) : raw;
|
|
61596
61894
|
return single != null ? String(single) : toValue(defaultValue);
|
|
61597
61895
|
};
|
|
61598
61896
|
|
|
61599
61897
|
return computed<T>({
|
|
61600
61898
|
get: () => transform(readRaw()),
|
|
61601
61899
|
set: (next) => {
|
|
61602
|
-
|
|
61900
|
+
// `null` / `undefined` mean "no value": the key leaves the URL.
|
|
61901
|
+
const nextRaw = next == null ? toValue(defaultValue) : String(next);
|
|
61603
61902
|
if (nextRaw === readRaw()) return;
|
|
61604
|
-
|
|
61605
|
-
|
|
61606
|
-
|
|
61607
|
-
|
|
61608
|
-
|
|
61609
|
-
|
|
61610
|
-
|
|
61611
|
-
|
|
61612
|
-
|
|
61613
|
-
|
|
61614
|
-
|
|
61615
|
-
preserveState,
|
|
61616
|
-
replace: true,
|
|
61617
|
-
});
|
|
61903
|
+
const value = nextRaw === toValue(defaultValue) ? null : nextRaw; // keep URLs clean at the default
|
|
61904
|
+
visitUrl(
|
|
61905
|
+
withQuery(currentUrl(), { [key]: value }),
|
|
61906
|
+
{
|
|
61907
|
+
preserveScroll: true,
|
|
61908
|
+
preserveState: true,
|
|
61909
|
+
replace: true,
|
|
61910
|
+
...toValue(visitOptions),
|
|
61911
|
+
},
|
|
61912
|
+
server
|
|
61913
|
+
);
|
|
61618
61914
|
},
|
|
61619
61915
|
});
|
|
61620
61916
|
}
|
|
@@ -61623,103 +61919,120 @@ export function useRouteQuery<T = string>(
|
|
|
61623
61919
|
### useQueryObject
|
|
61624
61920
|
|
|
61625
61921
|
The **filter engine**: watch a filters object and mirror its non-empty
|
|
61626
|
-
values into the query string with
|
|
61627
|
-
seen are cleaned up when they empty out;
|
|
61628
|
-
|
|
61922
|
+
values into the query string with one Inertia visit per change. Keys it has
|
|
61923
|
+
seen are cleaned up when they empty out; keys it does not own (a tab, a sort)
|
|
61924
|
+
are kept. Every change also removes `resetKeys` (default `['page']`), so
|
|
61925
|
+
results are never hidden on an out-of-range page.
|
|
61926
|
+
|
|
61927
|
+
| Option | Default | What it does |
|
|
61928
|
+
| -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
61929
|
+
| `visitOptions` | — | Inertia's own `VisitOptions` (`only`, `headers`, `reset`, callbacks…), merged over `preserveState` / `preserveScroll` / `replace: true` |
|
|
61930
|
+
| `debounce` | `{}` | Per-key wait in ms for typed keys: `{ search: 300 }` |
|
|
61931
|
+
| `resetKeys` | `['page']` | Keys removed on every change. Passing it **replaces** the default: `['page', 'lots_page']`, or `[]` to keep the page |
|
|
61932
|
+
| `paused` | `false` | While `true`, track changes but send nothing; resuming sends the current state once (see below) |
|
|
61933
|
+
| `server` | `true` | `false` writes the URL with no request (`router.replace` / `push`); `visitOptions` then takes only `preserveState` / `preserveScroll` / `replace` |
|
|
61934
|
+
|
|
61935
|
+
Arrays are written in the one `QUERY_ARRAY_FORMAT` of `useQuery.ts` (`comma`:
|
|
61936
|
+
`tags=a,b`, what Spatie QueryBuilder parses), the same one `useQuery` reads.
|
|
61937
|
+
To send server-shaped keys (`filter[status]`) or reshape values, pass a getter
|
|
61938
|
+
that builds them: `useQueryObject(() => ({ 'filter[status]': f.status }))`.
|
|
61629
61939
|
|
|
61630
61940
|
```ts
|
|
61631
61941
|
// inertia-helpers/useQueryObject.ts
|
|
61632
61942
|
// requires: npm i query-string
|
|
61633
|
-
import {
|
|
61634
|
-
|
|
61635
|
-
|
|
61636
|
-
|
|
61943
|
+
import {
|
|
61944
|
+
QUERY_ARRAY_FORMAT,
|
|
61945
|
+
currentUrl,
|
|
61946
|
+
parseQuery,
|
|
61947
|
+
visitUrl,
|
|
61948
|
+
withQuery,
|
|
61949
|
+
} from './useQuery';
|
|
61950
|
+
import type { VisitOptions } from '@inertiajs/core';
|
|
61951
|
+
import qs from 'query-string';
|
|
61637
61952
|
import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from 'vue';
|
|
61638
61953
|
|
|
61954
|
+
/** A server visit's options; the URL, method and data are the helper's. */
|
|
61955
|
+
type ServerVisitOptions = Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>;
|
|
61956
|
+
/** A URL-only write (`server: false`) has no request to configure. */
|
|
61957
|
+
type ClientVisitOptions = Pick<
|
|
61958
|
+
VisitOptions,
|
|
61959
|
+
'preserveState' | 'preserveScroll' | 'replace'
|
|
61960
|
+
>;
|
|
61961
|
+
|
|
61639
61962
|
type Options<T> = {
|
|
61640
|
-
|
|
61641
|
-
|
|
61642
|
-
|
|
61643
|
-
|
|
61963
|
+
/**
|
|
61964
|
+
* Query keys removed on every change. Default `['page']`, so a filter
|
|
61965
|
+
* change never strands the list on a page past its new end. Passing it
|
|
61966
|
+
* REPLACES the default: name `page` too when it should still reset.
|
|
61967
|
+
*/
|
|
61644
61968
|
resetKeys?: string[];
|
|
61645
|
-
/** Inertia partial reload prop names (supports reactive getter). */
|
|
61646
|
-
only?: MaybeRefOrGetter<string[]>;
|
|
61647
61969
|
/**
|
|
61648
|
-
*
|
|
61649
|
-
*
|
|
61650
|
-
*
|
|
61970
|
+
* While it resolves to `true`, changes are tracked but no visit is sent.
|
|
61971
|
+
* When it goes back to `false`, the object's current state is sent once,
|
|
61972
|
+
* if the URL does not already hold it. See the guide for when to use it.
|
|
61651
61973
|
*/
|
|
61652
|
-
|
|
61974
|
+
paused?: MaybeRefOrGetter<boolean>;
|
|
61653
61975
|
/**
|
|
61654
61976
|
* Keys whose changes wait this many ms before the visit, e.g. a search
|
|
61655
61977
|
* box: `{ search: 300 }`. A change to any other key visits at once and
|
|
61656
61978
|
* carries the waiting value with it, so nothing typed is lost.
|
|
61657
61979
|
*/
|
|
61658
61980
|
debounce?: Partial<Record<keyof T & string, number>>;
|
|
61659
|
-
|
|
61660
|
-
|
|
61661
|
-
|
|
61662
|
-
|
|
61663
|
-
}
|
|
61981
|
+
} & (
|
|
61982
|
+
| {
|
|
61983
|
+
/** Default `true`: every change is an Inertia visit. */
|
|
61984
|
+
server?: true;
|
|
61985
|
+
/** Merged over `{ preserveState, preserveScroll, replace: true }`. */
|
|
61986
|
+
visitOptions?: MaybeRefOrGetter<ServerVisitOptions>;
|
|
61987
|
+
}
|
|
61988
|
+
| {
|
|
61989
|
+
/** `false` writes the URL with no request (`router.replace` / `push`). */
|
|
61990
|
+
server: false;
|
|
61991
|
+
visitOptions?: MaybeRefOrGetter<ClientVisitOptions>;
|
|
61992
|
+
}
|
|
61993
|
+
);
|
|
61664
61994
|
|
|
61665
61995
|
export function useQueryObject<T extends Record<string, unknown>>(
|
|
61666
61996
|
object: MaybeRefOrGetter<T>,
|
|
61667
61997
|
options: Options<T> = {}
|
|
61668
61998
|
): void {
|
|
61669
61999
|
const {
|
|
61670
|
-
|
|
61671
|
-
|
|
61672
|
-
resetKeys = [],
|
|
61673
|
-
preserveState = true,
|
|
61674
|
-
preserveScroll = true,
|
|
61675
|
-
replace = true,
|
|
61676
|
-
only: onlyOption = [],
|
|
61677
|
-
shouldNavigate = true,
|
|
62000
|
+
resetKeys = ['page'],
|
|
62001
|
+
paused = false,
|
|
61678
62002
|
debounce = {},
|
|
61679
|
-
|
|
62003
|
+
server = true,
|
|
62004
|
+
visitOptions,
|
|
61680
62005
|
} = options;
|
|
61681
62006
|
|
|
61682
|
-
const filterValidValues = (obj: T): Partial<T> => {
|
|
61683
|
-
const filtered: Partial<T> = {};
|
|
61684
|
-
Object.entries(obj).forEach(([key, val]) => {
|
|
61685
|
-
if (!isNil(val) && val !== '') {
|
|
61686
|
-
(filtered as Record<string, unknown>)[key] = val;
|
|
61687
|
-
}
|
|
61688
|
-
});
|
|
61689
|
-
return filtered;
|
|
61690
|
-
};
|
|
61691
|
-
|
|
61692
62007
|
/** Every key this composable has ever managed, for stable URL cleanup. */
|
|
61693
62008
|
const seenKeys = new Set<string>(Object.keys(toValue(object) ?? {}));
|
|
61694
62009
|
|
|
61695
|
-
|
|
61696
|
-
|
|
61697
|
-
|
|
61698
|
-
|
|
61699
|
-
// Start from the existing query to preserve keys owned elsewhere.
|
|
61700
|
-
const existingQuery = useQuery({ arrayFormat });
|
|
61701
|
-
seenKeys.forEach((key) => delete existingQuery[key]);
|
|
61702
|
-
if (resetPage) delete existingQuery.page;
|
|
61703
|
-
resetKeys.forEach((key) => delete existingQuery[key]);
|
|
61704
|
-
|
|
61705
|
-
Object.entries(transformedValues).forEach(([key, val]) => {
|
|
61706
|
-
if (!isEmpty(val)) existingQuery[key] = val;
|
|
61707
|
-
});
|
|
62010
|
+
/** A query in one canonical form, so encoding and key order never count. */
|
|
62011
|
+
const canonical = (url: string) =>
|
|
62012
|
+
qs.stringify(parseQuery(url), { arrayFormat: QUERY_ARRAY_FORMAT });
|
|
61708
62013
|
|
|
61709
|
-
|
|
61710
|
-
|
|
61711
|
-
|
|
61712
|
-
|
|
61713
|
-
|
|
61714
|
-
|
|
61715
|
-
|
|
61716
|
-
|
|
61717
|
-
|
|
61718
|
-
|
|
61719
|
-
|
|
61720
|
-
|
|
61721
|
-
|
|
61722
|
-
|
|
62014
|
+
const navigate = (newObject: T) => {
|
|
62015
|
+
if (toValue(paused)) return;
|
|
62016
|
+
|
|
62017
|
+
// Only the reset keys and the keys this object owns change; every
|
|
62018
|
+
// other parameter (a tab, a sort) stays as it is.
|
|
62019
|
+
const changes: Record<string, unknown> = {};
|
|
62020
|
+
resetKeys.forEach((key) => (changes[key] = null));
|
|
62021
|
+
seenKeys.forEach((key) => (changes[key] = newObject[key]));
|
|
62022
|
+
const from = currentUrl();
|
|
62023
|
+
const url = withQuery(from, changes);
|
|
62024
|
+
if (canonical(url) === canonical(from)) return;
|
|
62025
|
+
|
|
62026
|
+
visitUrl(
|
|
62027
|
+
url,
|
|
62028
|
+
{
|
|
62029
|
+
preserveState: true,
|
|
62030
|
+
preserveScroll: true,
|
|
62031
|
+
replace: true,
|
|
62032
|
+
...toValue(visitOptions),
|
|
62033
|
+
},
|
|
62034
|
+
server
|
|
62035
|
+
);
|
|
61723
62036
|
};
|
|
61724
62037
|
|
|
61725
62038
|
/** Each key's value as last seen, to tell which keys a change touched. */
|
|
@@ -61732,8 +62045,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
61732
62045
|
onScopeDispose(() => clearTimeout(pending));
|
|
61733
62046
|
|
|
61734
62047
|
watch(
|
|
61735
|
-
() => toValue(object),
|
|
61736
|
-
(newObject) => {
|
|
62048
|
+
[() => toValue(object), () => toValue(paused)],
|
|
62049
|
+
([newObject, isPaused], [, wasPaused]) => {
|
|
61737
62050
|
if (!newObject) return;
|
|
61738
62051
|
|
|
61739
62052
|
Object.keys(newObject).forEach((key) => {
|
|
@@ -61752,12 +62065,15 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
61752
62065
|
);
|
|
61753
62066
|
lastSeen = now;
|
|
61754
62067
|
clearTimeout(pending);
|
|
62068
|
+
if (isPaused) return;
|
|
62069
|
+
// Resuming sends what changed while paused, without waiting.
|
|
62070
|
+
const resumed = wasPaused && !isPaused;
|
|
61755
62071
|
const waits = changed.map(
|
|
61756
62072
|
(key) => (debounce as Record<string, number | undefined>)[key] ?? 0
|
|
61757
62073
|
);
|
|
61758
62074
|
// Only debounced keys changed: wait for the typing to settle, then
|
|
61759
62075
|
// visit with whatever the object holds by then.
|
|
61760
|
-
if (changed.length && waits.every((ms) => ms > 0)) {
|
|
62076
|
+
if (!resumed && changed.length && waits.every((ms) => ms > 0)) {
|
|
61761
62077
|
pending = setTimeout(
|
|
61762
62078
|
() => navigate(toValue(object)),
|
|
61763
62079
|
Math.max(...waits)
|
|
@@ -61771,7 +62087,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
61771
62087
|
* into one watcher run, so a `clearFilters()` that blanks three keys
|
|
61772
62088
|
* fires ONE visit instead of three (measured: 3 writes → 3 runs under
|
|
61773
62089
|
* `sync`, 1 under the default). The two it saves used to self-cancel,
|
|
61774
|
-
* but they were still sent.
|
|
62090
|
+
* but they were still sent. Watching `paused` in the same source means
|
|
62091
|
+
* resuming and the change that comes with it are one run, one visit.
|
|
61775
62092
|
*
|
|
61776
62093
|
* This does NOT make typing cheap on its own — one visit per settled
|
|
61777
62094
|
* keystroke is still one visit per keystroke. `debounce` is for that.
|
|
@@ -61784,7 +62101,10 @@ export function useQueryObject<T extends Record<string, unknown>>(
|
|
|
61784
62101
|
```ts
|
|
61785
62102
|
// A listing page's whole filter wiring:
|
|
61786
62103
|
const filters = reactive({ search: '', status: null as string | null });
|
|
61787
|
-
useQueryObject(filters, {
|
|
62104
|
+
useQueryObject(filters, {
|
|
62105
|
+
visitOptions: { only: ['invoices'] },
|
|
62106
|
+
debounce: { search: 300 },
|
|
62107
|
+
});
|
|
61788
62108
|
// Bind BbTextInput / BbSelectPopover to `filters.*` — every change becomes a
|
|
61789
62109
|
// partial reload and the server re-renders the `invoices` prop.
|
|
61790
62110
|
```
|
|
@@ -61793,9 +62113,9 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
|
|
|
61793
62113
|
> their own URL writers (the `page` / `perPage` refs of
|
|
61794
62114
|
> `usePaginatedResponseContext`, or `useRouteQuery`). Put the page ref in the
|
|
61795
62115
|
> object too and a pager click visits twice: once through the ref, once
|
|
61796
|
-
> through this watcher. And
|
|
61797
|
-
> `page` from the query, then writes the object's value back, so a
|
|
61798
|
-
> change keeps the old page, possibly past the last one.
|
|
62116
|
+
> through this watcher. And the page reset stops working: `navigate()`
|
|
62117
|
+
> deletes `page` from the query, then writes the object's value back, so a
|
|
62118
|
+
> filter change keeps the old page, possibly past the last one.
|
|
61799
62119
|
|
|
61800
62120
|
> **Typing: debounce the key, not the page.** Without `debounce`, "an
|
|
61801
62121
|
> Inertia visit per change" is literal: a text field bound straight to
|
|
@@ -61805,16 +62125,63 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
|
|
|
61805
62125
|
>
|
|
61806
62126
|
> ```ts
|
|
61807
62127
|
> const filters = reactive({ search: '', status: null as string | null });
|
|
61808
|
-
> useQueryObject(filters, {
|
|
62128
|
+
> useQueryObject(filters, {
|
|
62129
|
+
> visitOptions: { only: ['invoices'] },
|
|
62130
|
+
> debounce: { search: 300 },
|
|
62131
|
+
> });
|
|
61809
62132
|
> ```
|
|
61810
62133
|
>
|
|
61811
62134
|
> Typing in the search waits 300 ms of quiet; picking a status visits at once
|
|
61812
62135
|
> and takes the typed text with it. Keys not named in `debounce` never wait.
|
|
61813
|
-
|
|
61814
|
-
> **Programmatic multi-key writes
|
|
61815
|
-
>
|
|
61816
|
-
>
|
|
61817
|
-
|
|
62136
|
+
|
|
62137
|
+
> **Programmatic multi-key writes need nothing.** A reset or an
|
|
62138
|
+
> apply-a-saved-view that writes several keys in one tick is already one
|
|
62139
|
+
> visit: the watcher runs once per tick. Do not reach for `paused` for this.
|
|
62140
|
+
|
|
62141
|
+
#### `paused`: when another visit owns the page for a while
|
|
62142
|
+
|
|
62143
|
+
Use it when something else sends a visit that also changes keys this object
|
|
62144
|
+
mirrors, and that visit takes time: a view switch whose own `router.get`
|
|
62145
|
+
rewrites the filters, a wizard step that navigates and seeds the next step's
|
|
62146
|
+
filters. Without it, the object's watcher fires on the next tick with the
|
|
62147
|
+
seeded values and sends a second visit; Inertia cancels the first (the one
|
|
62148
|
+
that mattered) in favour of it.
|
|
62149
|
+
|
|
62150
|
+
```ts
|
|
62151
|
+
const switching = ref(false);
|
|
62152
|
+
useQueryObject(filters, {
|
|
62153
|
+
visitOptions: { only: ['conversations'] },
|
|
62154
|
+
paused: switching,
|
|
62155
|
+
});
|
|
62156
|
+
|
|
62157
|
+
const switchView = (view: string) => {
|
|
62158
|
+
switching.value = true;
|
|
62159
|
+
Object.assign(filters, filtersFor(view)); // tracked, not sent
|
|
62160
|
+
router.get(
|
|
62161
|
+
`/inbox/${view}`,
|
|
62162
|
+
{},
|
|
62163
|
+
{
|
|
62164
|
+
onFinish: () => (switching.value = false), // also runs on cancel
|
|
62165
|
+
}
|
|
62166
|
+
);
|
|
62167
|
+
};
|
|
62168
|
+
```
|
|
62169
|
+
|
|
62170
|
+
What happens, exactly:
|
|
62171
|
+
|
|
62172
|
+
- **While `paused` is `true`**, every change is tracked (key cleanup, the
|
|
62173
|
+
debounce bookkeeping) and nothing is sent. A debounced change already
|
|
62174
|
+
waiting when the pause starts is dropped, not sent late.
|
|
62175
|
+
- **When it goes back to `false`**, the object's _current_ state is sent once,
|
|
62176
|
+
without waiting for a debounce. If the URL already holds that state — the
|
|
62177
|
+
other visit landed exactly these filters, the usual case — nothing is sent.
|
|
62178
|
+
Resuming and a change in the same tick are one visit.
|
|
62179
|
+
- It is a **span over time**, not a tick guard: set and cleared in the same
|
|
62180
|
+
tick, it pauses nothing (the watcher runs after the tick, when it is already
|
|
62181
|
+
`false`). Clear it in the competing visit's `onFinish`, which Inertia also
|
|
62182
|
+
calls when that visit is cancelled or fails, so the object never stays
|
|
62183
|
+
paused.
|
|
62184
|
+
- It accepts a ref or a getter: `paused: () => form.processing`.
|
|
61818
62185
|
|
|
61819
62186
|
---
|
|
61820
62187
|
|
|
@@ -61825,31 +62192,50 @@ counterpart of the component-state context
|
|
|
61825
62192
|
[`useBbTableContext`](https://llms-bitboss-ui.bitboss.it/dist/ai/composables/useBbTableContext.md). Rule of thumb:
|
|
61826
62193
|
`useBbTableContext` mirrors what the _table widget_ is doing (selection,
|
|
61827
62194
|
sort, highlight); these contexts own what the _server sent_ (items, meta)
|
|
61828
|
-
and survive being read from any component on the page.
|
|
61829
|
-
the context
|
|
62195
|
+
and survive being read from any component on the page. The first caller
|
|
62196
|
+
creates the context and every later caller **below it** joins it — no
|
|
62197
|
+
provider component, but siblings (a table and a bulk bar) share one only
|
|
62198
|
+
when a common parent (the page) calls it first; each would otherwise create
|
|
62199
|
+
its own.
|
|
61830
62200
|
|
|
61831
62201
|
### usePaginatedResponseContext
|
|
61832
62202
|
|
|
61833
62203
|
Wraps a Laravel paginated prop (`PaginatedResponse<T>`) into a shared
|
|
61834
|
-
context: `items` (
|
|
61835
|
-
|
|
61836
|
-
|
|
61837
|
-
|
|
62204
|
+
context: `items` (a deep copy of the rows: `v-model` a row's field for inline
|
|
62205
|
+
editing without touching the page prop; editable until the prop changes),
|
|
62206
|
+
`totalItems`, `totalPages`, and `page` / `perPage` refs that read what the
|
|
62207
|
+
server answered and visit when written (`page` at 1 and `per_page` at the
|
|
62208
|
+
server's default leave the URL; a new `perPage` starts again from page 1). A
|
|
62209
|
+
missing or deferred prop reads as an empty page.
|
|
62210
|
+
|
|
62211
|
+
**When `items` resets.** A server visit that asks for this prop (a pager
|
|
62212
|
+
click, a filter, `router.reload({ only: ['users'] })`) always brings fresh
|
|
62213
|
+
rows, even identical ones: reloading after a failed delete undoes the
|
|
62214
|
+
optimistic edit. Anything else that swaps the prop — `router.appendToProp`
|
|
62215
|
+
on another prop rebuilds every prop, a reload of another prop, Back /
|
|
62216
|
+
Forward — keeps your edits unless the rows really changed. Anything past
|
|
62217
|
+
that (rolling back a failed edit, merging a concurrent change) is the app's.
|
|
61838
62218
|
|
|
61839
62219
|
```ts
|
|
61840
62220
|
// inertia-helpers/usePaginatedResponseContext.ts
|
|
61841
|
-
|
|
62221
|
+
// requires: npm i query-string
|
|
62222
|
+
import { currentUrl, visitUrl, withQuery } from './useQuery';
|
|
61842
62223
|
import type { PaginatedResponse } from './types';
|
|
61843
|
-
import { isNotNil } from './utils';
|
|
61844
|
-
import type { PageProps } from '@inertiajs/core';
|
|
61845
|
-
import { usePage } from '@inertiajs/vue3';
|
|
62224
|
+
import { cloneDeep, isEqual, isNotNil } from './utils';
|
|
62225
|
+
import type { PageProps, VisitOptions } from '@inertiajs/core';
|
|
62226
|
+
import { router, usePage } from '@inertiajs/vue3';
|
|
61846
62227
|
import { injectLocal, provideLocal } from '@vueuse/core';
|
|
61847
62228
|
import {
|
|
61848
62229
|
computed,
|
|
62230
|
+
onMounted,
|
|
62231
|
+
onScopeDispose,
|
|
61849
62232
|
ref,
|
|
62233
|
+
shallowRef,
|
|
62234
|
+
toValue,
|
|
61850
62235
|
watch,
|
|
61851
62236
|
watchEffect,
|
|
61852
62237
|
type ComputedRef,
|
|
62238
|
+
type MaybeRefOrGetter,
|
|
61853
62239
|
type Ref,
|
|
61854
62240
|
} from 'vue';
|
|
61855
62241
|
|
|
@@ -61858,10 +62244,21 @@ export type PaginatedResponseContext<T> = {
|
|
|
61858
62244
|
perPage: Ref<number>;
|
|
61859
62245
|
page: Ref<number>;
|
|
61860
62246
|
totalPages: ComputedRef<number>;
|
|
61861
|
-
/** Current page items
|
|
62247
|
+
/** Current page items, a deep copy: edit it freely; resets on a new answer. */
|
|
61862
62248
|
items: Ref<T[]>;
|
|
61863
62249
|
};
|
|
61864
62250
|
|
|
62251
|
+
export type PaginatedResponseOptions = {
|
|
62252
|
+
/** The query key the server reads the page from (Laravel's `pageName`). Default `'page'`. */
|
|
62253
|
+
pageQueryKey?: string;
|
|
62254
|
+
/** The query key the server reads the page size from. Default `'per_page'`. */
|
|
62255
|
+
perPageQueryKey?: string;
|
|
62256
|
+
/** Merged over `{ preserveState, preserveScroll }` and the history default (a page pushes, a size replaces). */
|
|
62257
|
+
visitOptions?: MaybeRefOrGetter<
|
|
62258
|
+
Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
|
|
62259
|
+
>;
|
|
62260
|
+
};
|
|
62261
|
+
|
|
61865
62262
|
const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
61866
62263
|
|
|
61867
62264
|
/**
|
|
@@ -61869,30 +62266,40 @@ const toContextKey = (key: string): string => `${key}:paginatedResponse`;
|
|
|
61869
62266
|
* fields under `meta` (a resource collection, a hand-built shape) or at the
|
|
61870
62267
|
* top level (`->paginate()`, `->simplePaginate()`). `simplePaginate()` knows
|
|
61871
62268
|
* no total: the count is the rows so far, and the last page is the next one
|
|
61872
|
-
* while a next page exists.
|
|
62269
|
+
* while a next page exists, or the one before an empty page past the end.
|
|
61873
62270
|
*/
|
|
61874
62271
|
export const readPagination = (paginated: PaginatedResponse<unknown>) => {
|
|
61875
62272
|
const fields = paginated.meta ?? paginated;
|
|
61876
62273
|
const current = fields.current_page ?? 1;
|
|
61877
62274
|
const perPage = fields.per_page ?? 10;
|
|
62275
|
+
const rows = paginated.data?.length ?? 0;
|
|
61878
62276
|
const links = paginated.links as { next?: string | null } | undefined;
|
|
61879
62277
|
const hasNext = !!(
|
|
61880
62278
|
fields.next_page_url ?? (Array.isArray(links) ? null : links?.next)
|
|
61881
62279
|
);
|
|
62280
|
+
const pastEnd = !rows && !hasNext && current > 1;
|
|
61882
62281
|
return {
|
|
61883
62282
|
current,
|
|
61884
62283
|
perPage,
|
|
61885
|
-
lastPage:
|
|
62284
|
+
lastPage:
|
|
62285
|
+
fields.last_page ??
|
|
62286
|
+
(hasNext ? current + 1 : pastEnd ? current - 1 : current),
|
|
61886
62287
|
total:
|
|
61887
62288
|
fields.total ??
|
|
61888
|
-
(current - 1) * perPage +
|
|
61889
|
-
(paginated.data?.length ?? 0) +
|
|
61890
|
-
(hasNext ? 1 : 0),
|
|
62289
|
+
(pastEnd ? 0 : (current - 1) * perPage + rows + (hasNext ? 1 : 0)),
|
|
61891
62290
|
};
|
|
61892
62291
|
};
|
|
61893
62292
|
|
|
62293
|
+
const isCount = (value: number): boolean =>
|
|
62294
|
+
Number.isInteger(value) && value > 0;
|
|
62295
|
+
|
|
61894
62296
|
export const usePaginatedResponseContext = <T = object>(
|
|
61895
|
-
dataKey: string
|
|
62297
|
+
dataKey: string,
|
|
62298
|
+
{
|
|
62299
|
+
pageQueryKey = 'page',
|
|
62300
|
+
perPageQueryKey = 'per_page',
|
|
62301
|
+
visitOptions,
|
|
62302
|
+
}: PaginatedResponseOptions = {}
|
|
61896
62303
|
): PaginatedResponseContext<T> => {
|
|
61897
62304
|
const existing = injectLocal<PaginatedResponseContext<T> | null>(
|
|
61898
62305
|
toContextKey(dataKey),
|
|
@@ -61911,18 +62318,46 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
61911
62318
|
const inertiaPage = usePage<
|
|
61912
62319
|
PageProps & Record<string, PaginatedResponse<T>>
|
|
61913
62320
|
>();
|
|
62321
|
+
// A deferred, optional or not-yet-reloaded prop reads as an empty page.
|
|
61914
62322
|
const paginated = computed(
|
|
61915
|
-
() => inertiaPage.props[dataKey]
|
|
62323
|
+
(): PaginatedResponse<T> => inertiaPage.props[dataKey] ?? { data: [] }
|
|
61916
62324
|
);
|
|
61917
62325
|
|
|
61918
62326
|
const pagination = computed(() => readPagination(paginated.value));
|
|
61919
|
-
|
|
62327
|
+
// A deep copy: inline edits (`v-model` on a row's field) stay off the page
|
|
62328
|
+
// prop, so the "rows really changed" check below compares two things.
|
|
62329
|
+
const items = ref<T[]>(cloneDeep(paginated.value.data ?? [])) as Ref<T[]>;
|
|
61920
62330
|
const totalItems = computed(() => pagination.value.total);
|
|
61921
62331
|
const totalPages = computed(() => pagination.value.lastPage);
|
|
61922
62332
|
|
|
61923
|
-
//
|
|
61924
|
-
//
|
|
61925
|
-
|
|
62333
|
+
// Server visits in flight that ask for this prop (a pager click, a filter,
|
|
62334
|
+
// `router.reload({ only: [dataKey] })`).
|
|
62335
|
+
const asking = new Set<unknown>();
|
|
62336
|
+
const asks = ({ only, except }: { only: string[]; except: string[] }) =>
|
|
62337
|
+
only.length ? only.includes(dataKey) : !except.includes(dataKey);
|
|
62338
|
+
const unbindStart = router.on('start', ({ detail: { visit } }) => {
|
|
62339
|
+
if (asks(visit)) asking.add(visit);
|
|
62340
|
+
});
|
|
62341
|
+
const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
|
|
62342
|
+
asking.delete(visit);
|
|
62343
|
+
});
|
|
62344
|
+
onScopeDispose(() => {
|
|
62345
|
+
unbindStart();
|
|
62346
|
+
unbindFinish();
|
|
62347
|
+
});
|
|
62348
|
+
|
|
62349
|
+
// A visit that asked for this prop is a new answer: fresh rows, the edits
|
|
62350
|
+
// made to `items` go. Anything else that swaps the prop (`appendToProp` on
|
|
62351
|
+
// another prop rebuilds them all, Back / Forward) keeps the edits unless the
|
|
62352
|
+
// rows really changed. `sync`: the answer lands before its visit finishes.
|
|
62353
|
+
watch(
|
|
62354
|
+
paginated,
|
|
62355
|
+
(fresh, old) => {
|
|
62356
|
+
if (!asking.size && isEqual(fresh.data, old.data)) return;
|
|
62357
|
+
items.value = cloneDeep(fresh.data ?? []);
|
|
62358
|
+
},
|
|
62359
|
+
{ flush: 'sync' }
|
|
62360
|
+
);
|
|
61926
62361
|
|
|
61927
62362
|
// The server's default page size is what it answers a URL without
|
|
61928
62363
|
// `per_page`. Until such an answer arrives it is unknown (`''` matches no
|
|
@@ -61931,24 +62366,74 @@ export const usePaginatedResponseContext = <T = object>(
|
|
|
61931
62366
|
const defaultPerPage = ref('');
|
|
61932
62367
|
watchEffect(() => {
|
|
61933
62368
|
const [, queryString = ''] = inertiaPage.url.split('?');
|
|
61934
|
-
if (!new URLSearchParams(queryString).has(
|
|
62369
|
+
if (!new URLSearchParams(queryString).has(perPageQueryKey))
|
|
61935
62370
|
defaultPerPage.value = `${pagination.value.perPage}`;
|
|
61936
62371
|
});
|
|
61937
|
-
|
|
61938
|
-
|
|
62372
|
+
|
|
62373
|
+
// Where the last pager write is going, while its visit is in flight: a
|
|
62374
|
+
// second click before the answer builds on it ("next" twice is page 3).
|
|
62375
|
+
const target = shallowRef<{ url: string; page: number; perPage: number }>();
|
|
62376
|
+
const inFlight = () => {
|
|
62377
|
+
const write = target.value;
|
|
62378
|
+
return write?.url === currentUrl() && write.url !== inertiaPage.url
|
|
62379
|
+
? write
|
|
62380
|
+
: undefined;
|
|
62381
|
+
};
|
|
62382
|
+
const go = (to: number, size?: number, extra?: { replace: true }) => {
|
|
62383
|
+
const url = withQuery(currentUrl(), {
|
|
62384
|
+
[pageQueryKey]: to === 1 ? null : to,
|
|
62385
|
+
...(isNotNil(size) && {
|
|
62386
|
+
[perPageQueryKey]: `${size}` === defaultPerPage.value ? null : size,
|
|
62387
|
+
}),
|
|
62388
|
+
});
|
|
62389
|
+
target.value = { url, page: to, perPage: size ?? perPage.value };
|
|
62390
|
+
visitUrl(url, {
|
|
62391
|
+
preserveScroll: true,
|
|
62392
|
+
preserveState: true,
|
|
62393
|
+
// A page is a history entry (Back goes to the previous page); a new
|
|
62394
|
+
// size is a setting and replaces.
|
|
62395
|
+
replace: isNotNil(size),
|
|
62396
|
+
...toValue(visitOptions),
|
|
62397
|
+
...extra,
|
|
62398
|
+
});
|
|
62399
|
+
};
|
|
61939
62400
|
|
|
61940
62401
|
// Both read the server's answer, not the URL: a clamped `?page=99`, a
|
|
61941
62402
|
// capped `?per_page=500` or a redirect to the bare URL shows what the
|
|
61942
|
-
// server sent. Writing one visits
|
|
62403
|
+
// server sent. Writing one visits when it changes what they show; a size
|
|
62404
|
+
// starts again from page 1. Junk (0, NaN, 2.5, past a known last page) is
|
|
62405
|
+
// ignored.
|
|
61943
62406
|
const page = computed({
|
|
61944
|
-
get: () => pagination.value.current,
|
|
61945
|
-
set: (next: number) =>
|
|
62407
|
+
get: () => inFlight()?.page ?? pagination.value.current,
|
|
62408
|
+
set: (next: number) => {
|
|
62409
|
+
const last = (paginated.value.meta ?? paginated.value).last_page;
|
|
62410
|
+
if (!isCount(next) || (isNotNil(last) && next > last)) return;
|
|
62411
|
+
if (next !== page.value) go(next);
|
|
62412
|
+
},
|
|
61946
62413
|
});
|
|
61947
62414
|
const perPage = computed({
|
|
61948
|
-
get: () => pagination.value.perPage,
|
|
61949
|
-
set: (next: number) =>
|
|
62415
|
+
get: () => inFlight()?.perPage ?? pagination.value.perPage,
|
|
62416
|
+
set: (next: number) => {
|
|
62417
|
+
if (isCount(next) && next !== perPage.value) go(1, next);
|
|
62418
|
+
},
|
|
61950
62419
|
});
|
|
61951
62420
|
|
|
62421
|
+
// Laravel's `paginate()` does not clamp: `?page=3` after page 3's last row
|
|
62422
|
+
// went answers an empty page 3 of 2. Go to the last page, once per answer.
|
|
62423
|
+
let clampedFrom = '';
|
|
62424
|
+
onMounted(() =>
|
|
62425
|
+
watch(
|
|
62426
|
+
pagination,
|
|
62427
|
+
({ current, lastPage }) => {
|
|
62428
|
+
if (current <= lastPage || lastPage < 1) return;
|
|
62429
|
+
if (clampedFrom === inertiaPage.url) return;
|
|
62430
|
+
clampedFrom = inertiaPage.url;
|
|
62431
|
+
go(lastPage, undefined, { replace: true });
|
|
62432
|
+
},
|
|
62433
|
+
{ immediate: true }
|
|
62434
|
+
)
|
|
62435
|
+
);
|
|
62436
|
+
|
|
61952
62437
|
const context: PaginatedResponseContext<T> = {
|
|
61953
62438
|
totalItems,
|
|
61954
62439
|
perPage,
|
|
@@ -61981,6 +62466,38 @@ return Inertia::render('Admin/Users/Index', [
|
|
|
61981
62466
|
one while a next page exists) and `totalPages` ends at the next page, so a
|
|
61982
62467
|
pager shows "next" until the last page.
|
|
61983
62468
|
|
|
62469
|
+
**`->cursorPaginate()` is not supported.** It has no page numbers (only
|
|
62470
|
+
`next_cursor` / `prev_cursor` links), so `page` would read 1 on every page
|
|
62471
|
+
and "next" would visit a `?page=2` Laravel ignores. Use `->paginate()` or
|
|
62472
|
+
`->simplePaginate()` with this context.
|
|
62473
|
+
|
|
62474
|
+
**A page is a history entry.** Writing `page` pushes, so Back goes to the
|
|
62475
|
+
previous page; a new `perPage` (a setting) and filter writes replace. Pass
|
|
62476
|
+
`visitOptions: { replace: true }` to keep the pager out of history.
|
|
62477
|
+
|
|
62478
|
+
**A page past the last goes to the last.** Laravel does not clamp: delete
|
|
62479
|
+
the last row of page 3 and `?page=3` answers an empty page 3 of 2. The
|
|
62480
|
+
context visits the last page once (`replace`); an empty `simplePaginate()`
|
|
62481
|
+
page past the first goes back one.
|
|
62482
|
+
|
|
62483
|
+
**Writes before the answer build on each other.** While a pager visit is in
|
|
62484
|
+
flight, `page` / `perPage` read where it is going, so "next" clicked twice
|
|
62485
|
+
lands two pages on, and a click right after a filter or size change keeps
|
|
62486
|
+
it. A write equal to what they show, a page that is not a positive integer,
|
|
62487
|
+
or a page past a known last page makes no visit.
|
|
62488
|
+
|
|
62489
|
+
Options (the second argument, also taken by `useTableContext`):
|
|
62490
|
+
|
|
62491
|
+
- `pageQueryKey` / `perPageQueryKey` — the query keys the server reads
|
|
62492
|
+
(defaults `'page'` / `'per_page'`). A paginator with its own page name,
|
|
62493
|
+
`->paginate(15, ['*'], 'lots_page')`, takes
|
|
62494
|
+
`usePaginatedResponseContext('lots', { pageQueryKey: 'lots_page' })` — and
|
|
62495
|
+
two tables on one page need one each.
|
|
62496
|
+
- `visitOptions` — merged over `{ preserveState, preserveScroll, replace:
|
|
62497
|
+
true }` on every pager visit, a ref or getter read each time. Without it a
|
|
62498
|
+
page change reloads every prop; `{ only: ['users'] }` reloads the table
|
|
62499
|
+
alone.
|
|
62500
|
+
|
|
61984
62501
|
### useTableSelectionContext
|
|
61985
62502
|
|
|
61986
62503
|
Shared selection state by key, in server terms: `selected` and `unselected`
|
|
@@ -61989,6 +62506,12 @@ item lists plus the `all` flag ("select-all across pages" semantics — when
|
|
|
61989
62506
|
combines `all` + `unselected` instead). Bind these to `BbTable`'s
|
|
61990
62507
|
`v-model`, `v-model:unselected-items` and `v-model:select-all`.
|
|
61991
62508
|
|
|
62509
|
+
The selection is never reset for you: it survives page changes (that is
|
|
62510
|
+
the cross-page feature) and filter changes alike, since the URL also holds
|
|
62511
|
+
state that has nothing to do with the table. When a bulk action must not
|
|
62512
|
+
reach rows the new filter hides, call `resetSelection()` where the filters
|
|
62513
|
+
change, e.g. `watch(filters, resetSelection, { deep: true })`.
|
|
62514
|
+
|
|
61992
62515
|
```ts
|
|
61993
62516
|
// inertia-helpers/useTableSelectionContext.ts
|
|
61994
62517
|
import { isNotNil } from './utils';
|
|
@@ -62052,6 +62575,7 @@ builds on.
|
|
|
62052
62575
|
import {
|
|
62053
62576
|
usePaginatedResponseContext,
|
|
62054
62577
|
type PaginatedResponseContext,
|
|
62578
|
+
type PaginatedResponseOptions,
|
|
62055
62579
|
} from './usePaginatedResponseContext';
|
|
62056
62580
|
import {
|
|
62057
62581
|
useTableSelectionContext,
|
|
@@ -62066,7 +62590,9 @@ export type TableContext<T, U> = TableSelectionContext<U> &
|
|
|
62066
62590
|
const toTableKey = (key: string): string => `${key}:table`;
|
|
62067
62591
|
|
|
62068
62592
|
export const useTableContext = <T = object, U = T>(
|
|
62069
|
-
dataKey: string
|
|
62593
|
+
dataKey: string,
|
|
62594
|
+
/** For the paginated half: query keys and pager visit options. */
|
|
62595
|
+
options?: PaginatedResponseOptions
|
|
62070
62596
|
): TableContext<T, U> => {
|
|
62071
62597
|
const existing = injectLocal<TableContext<T, U> | null>(
|
|
62072
62598
|
toTableKey(dataKey),
|
|
@@ -62076,7 +62602,7 @@ export const useTableContext = <T = object, U = T>(
|
|
|
62076
62602
|
|
|
62077
62603
|
const context: TableContext<T, U> = {
|
|
62078
62604
|
...useTableSelectionContext<U>(dataKey),
|
|
62079
|
-
...usePaginatedResponseContext<T>(dataKey),
|
|
62605
|
+
...usePaginatedResponseContext<T>(dataKey, options),
|
|
62080
62606
|
};
|
|
62081
62607
|
|
|
62082
62608
|
provideLocal(toTableKey(dataKey), context);
|
|
@@ -62088,7 +62614,10 @@ Gotchas:
|
|
|
62088
62614
|
|
|
62089
62615
|
- The key is a rendezvous string (use the page-prop name, e.g.
|
|
62090
62616
|
`'invoices'`). Like the library's table context, a typo doesn't error —
|
|
62091
|
-
it silently creates a second, orphan context.
|
|
62617
|
+
it silently creates a second, orphan context. So does a sibling: call it
|
|
62618
|
+
in the page (or a parent of every reader) first.
|
|
62619
|
+
- The selection outlives a filter change (it is cross-page by design). Call
|
|
62620
|
+
`resetSelection()` where the filter changes when the old picks must go.
|
|
62092
62621
|
- Each composite context provides under a namespaced key
|
|
62093
62622
|
(`invoices:table`, `invoices:selection`, `invoices:paginatedResponse`),
|
|
62094
62623
|
so the pieces can also be joined individually.
|
|
@@ -62122,9 +62651,14 @@ submit, chain a toast, or hand the promise to a `BbButton` `@click` /
|
|
|
62122
62651
|
Inertia form — `form.errors`, `form.isDirty`, `form.reset()` all work.
|
|
62123
62652
|
|
|
62124
62653
|
The promise resolves `true` when the server answered and `false` when the visit
|
|
62125
|
-
was cancelled (a `before` listener such as
|
|
62126
|
-
or interrupted by a newer one: nothing was
|
|
62127
|
-
|
|
62654
|
+
was cancelled (its own `onBefore` returning false, a `before` listener such as
|
|
62655
|
+
`useConfirmOnRouteLeave`, `cancel()`) or interrupted by a newer one: nothing was
|
|
62656
|
+
saved, so skip the success toast and the re-baseline
|
|
62657
|
+
(`if (!(await put(url))) return;`) and the form stays dirty. A save the server
|
|
62658
|
+
answers with a redirect to a `#fragment` (Inertia's 409 follow-up visit) or with
|
|
62659
|
+
`Inertia::location()` resolves `true`; an Inertia-rendered error page (a 403 /
|
|
62660
|
+
500 with your custom error component) rejects. Your own `onSuccess` / `onError`
|
|
62661
|
+
/ `onFinish` options still run.
|
|
62128
62662
|
|
|
62129
62663
|
**Declare the draft shape as a `type` alias, not an `interface`.** The generic
|
|
62130
62664
|
is constrained to `Record<string, FormValue>` (what Inertia can serialize), and
|
|
@@ -62137,6 +62671,7 @@ TypeScript quirk, not a statement about your data.
|
|
|
62137
62671
|
// inertia-helpers/usePromiseForm.ts
|
|
62138
62672
|
import { useLoading } from './useLoading';
|
|
62139
62673
|
import {
|
|
62674
|
+
router,
|
|
62140
62675
|
useForm,
|
|
62141
62676
|
type InertiaForm,
|
|
62142
62677
|
type InertiaFormProps,
|
|
@@ -62154,6 +62689,17 @@ type FormValue =
|
|
|
62154
62689
|
|
|
62155
62690
|
type Options = Parameters<InertiaFormProps<object>['get']>[1];
|
|
62156
62691
|
|
|
62692
|
+
// Inertia 3's per-visit failure callbacks. Inertia 2 has none (it ignores
|
|
62693
|
+
// them), only the global `invalid` / `exception` events, heard below.
|
|
62694
|
+
type FailureCallbacks = {
|
|
62695
|
+
onHttpException?: (response: { status: number }) => boolean | void;
|
|
62696
|
+
onNetworkError?: (error: unknown) => boolean | void;
|
|
62697
|
+
};
|
|
62698
|
+
const onRouterEvent = (
|
|
62699
|
+
name: string,
|
|
62700
|
+
listener: (event: CustomEvent) => void
|
|
62701
|
+
): (() => void) => router.on(name as 'start', listener as never);
|
|
62702
|
+
|
|
62157
62703
|
export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
62158
62704
|
initialData: T
|
|
62159
62705
|
) => {
|
|
@@ -62168,34 +62714,129 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
|
62168
62714
|
): Promise<boolean> =>
|
|
62169
62715
|
// true: the server answered and the save went through. false: the
|
|
62170
62716
|
// visit was cancelled or interrupted, nothing to report. Rejects with
|
|
62171
|
-
// the error bag, or for a response that is not Inertia's.
|
|
62172
|
-
new Promise<boolean>((
|
|
62173
|
-
const {
|
|
62717
|
+
// the error bag, or for an error page / a response that is not Inertia's.
|
|
62718
|
+
new Promise<boolean>((resolvePromise, rejectPromise) => {
|
|
62719
|
+
const {
|
|
62720
|
+
onBefore,
|
|
62721
|
+
onSuccess,
|
|
62722
|
+
onError,
|
|
62723
|
+
onHttpException,
|
|
62724
|
+
onNetworkError,
|
|
62725
|
+
onFinish,
|
|
62726
|
+
...visitOptions
|
|
62727
|
+
} = options as NonNullable<Options> & FailureCallbacks;
|
|
62728
|
+
// Visits sent with these callbacks, and how many finished: a save
|
|
62729
|
+
// answered with a 409 redirect (a redirect with a #fragment) is
|
|
62730
|
+
// re-sent by Inertia itself with the same callbacks, and that
|
|
62731
|
+
// follow-up interrupts the first visit. The last one settles.
|
|
62732
|
+
let sent = 0;
|
|
62733
|
+
let finished = 0;
|
|
62734
|
+
let awaitingBefore = false;
|
|
62735
|
+
let failedStatus: number | null = null;
|
|
62736
|
+
// Inertia 2: a non-Inertia response or a network error is a global
|
|
62737
|
+
// event only, fired before the visit finishes. Heard while this
|
|
62738
|
+
// promise is open (an async reload failing meanwhile counts too).
|
|
62739
|
+
const unbindFailures = [
|
|
62740
|
+
onRouterEvent('invalid', ({ detail }) => {
|
|
62741
|
+
failedStatus = (detail as { response: { status: number } }).response
|
|
62742
|
+
.status;
|
|
62743
|
+
}),
|
|
62744
|
+
onRouterEvent('exception', () => void (failedStatus = 0)),
|
|
62745
|
+
];
|
|
62746
|
+
const settle = () => {
|
|
62747
|
+
unbind();
|
|
62748
|
+
unbindFailures.forEach((unbindFailure) => unbindFailure());
|
|
62749
|
+
};
|
|
62750
|
+
const resolve = (value: boolean) => {
|
|
62751
|
+
settle();
|
|
62752
|
+
resolvePromise(value);
|
|
62753
|
+
};
|
|
62754
|
+
const reject = (error: unknown) => {
|
|
62755
|
+
settle();
|
|
62756
|
+
rejectPromise(error);
|
|
62757
|
+
};
|
|
62758
|
+
// A visit stopped before it starts (its own onBefore returning
|
|
62759
|
+
// false, below, or a `before` listener that refuses it) fires no
|
|
62760
|
+
// callback at all: no save, resolve false. Registered last, this
|
|
62761
|
+
// listener sees what the ones before it decided.
|
|
62762
|
+
const unbind = router.on('before', (event) => {
|
|
62763
|
+
if (!awaitingBefore) return;
|
|
62764
|
+
awaitingBefore = false;
|
|
62765
|
+
if (!event.defaultPrevented) return;
|
|
62766
|
+
// Held by useConfirmOnRouteLeave's dialog: "Discard changes"
|
|
62767
|
+
// re-sends it with these callbacks, "Keep editing" finishes it
|
|
62768
|
+
// as cancelled. Either one settles this promise.
|
|
62769
|
+
if (
|
|
62770
|
+
(event.detail.visit as { awaitingConfirm?: boolean })
|
|
62771
|
+
.awaitingConfirm
|
|
62772
|
+
)
|
|
62773
|
+
return;
|
|
62774
|
+
if (--sent === finished) resolve(false);
|
|
62775
|
+
});
|
|
62174
62776
|
|
|
62175
62777
|
form[method](url, {
|
|
62176
62778
|
...visitOptions,
|
|
62177
|
-
|
|
62178
|
-
|
|
62779
|
+
onBefore: (visit) => {
|
|
62780
|
+
const answer = onBefore?.(visit);
|
|
62781
|
+
if (answer === false) {
|
|
62782
|
+
if (sent === finished) resolve(false);
|
|
62783
|
+
} else {
|
|
62784
|
+
sent++;
|
|
62785
|
+
awaitingBefore = true;
|
|
62786
|
+
}
|
|
62787
|
+
return answer;
|
|
62788
|
+
},
|
|
62789
|
+
onSuccess: (page) => {
|
|
62790
|
+
onSuccess?.(page);
|
|
62791
|
+
// An error page Inertia renders (a 403 / 500 with `x-inertia`)
|
|
62792
|
+
// lands like a success: it is not a save.
|
|
62793
|
+
if (failedStatus === null) resolve(true);
|
|
62794
|
+
else reject(new Error(`Inertia visit answered ${failedStatus}.`));
|
|
62795
|
+
},
|
|
62796
|
+
onError: (errors) => {
|
|
62797
|
+
onError?.(errors);
|
|
62798
|
+
reject(errors);
|
|
62799
|
+
},
|
|
62800
|
+
...({
|
|
62801
|
+
onHttpException: (response) => {
|
|
62802
|
+
failedStatus = response.status;
|
|
62803
|
+
return onHttpException?.(response);
|
|
62804
|
+
},
|
|
62805
|
+
onNetworkError: (error) => {
|
|
62806
|
+
failedStatus = 0;
|
|
62807
|
+
return onNetworkError?.(error);
|
|
62808
|
+
},
|
|
62809
|
+
} satisfies FailureCallbacks),
|
|
62179
62810
|
onFinish: (
|
|
62180
62811
|
...args: Parameters<NonNullable<NonNullable<Options>['onFinish']>>
|
|
62181
62812
|
) => {
|
|
62182
|
-
|
|
62183
|
-
|
|
62184
|
-
//
|
|
62813
|
+
onFinish?.(...args);
|
|
62814
|
+
finished++;
|
|
62815
|
+
// A cancelled visit (`cancel()`) or one a newer visit
|
|
62185
62816
|
// interrupted is no failure, and no save either: it reaches
|
|
62186
|
-
// only onFinish. Resolve false
|
|
62817
|
+
// only onFinish. Resolve false, unless it is Inertia's own
|
|
62818
|
+
// 409 follow-up that interrupted it: that one settles.
|
|
62187
62819
|
const [visit] = args as unknown as [
|
|
62188
62820
|
{ cancelled?: boolean; interrupted?: boolean } | undefined,
|
|
62189
62821
|
];
|
|
62190
62822
|
if (visit?.cancelled || visit?.interrupted) {
|
|
62191
|
-
resolve(false);
|
|
62823
|
+
if (sent <= finished) resolve(false);
|
|
62192
62824
|
return;
|
|
62193
62825
|
}
|
|
62194
62826
|
// A non-Inertia response (e.g. a 423 entity lock or a 419
|
|
62195
|
-
// CSRF mismatch) fires neither onSuccess
|
|
62196
|
-
// onFinish. Settle here so isSubmitting is
|
|
62197
|
-
// of hanging the form forever. No-op once
|
|
62198
|
-
|
|
62827
|
+
// CSRF mismatch) or a network error fires neither onSuccess
|
|
62828
|
+
// nor onError, only onFinish. Settle here so isSubmitting is
|
|
62829
|
+
// released instead of hanging the form forever. No-op once
|
|
62830
|
+
// already settled.
|
|
62831
|
+
if (failedStatus !== null) {
|
|
62832
|
+
reject(
|
|
62833
|
+
new Error('Inertia visit did not return a valid response.')
|
|
62834
|
+
);
|
|
62835
|
+
return;
|
|
62836
|
+
}
|
|
62837
|
+
// `Inertia::location()` (an external redirect after the save)
|
|
62838
|
+
// fires only onFinish too, while the browser leaves.
|
|
62839
|
+
resolve(true);
|
|
62199
62840
|
},
|
|
62200
62841
|
});
|
|
62201
62842
|
})
|
|
@@ -62225,6 +62866,26 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
|
|
|
62225
62866
|
> have nothing else to do on rejection. Every call site in this guide and its
|
|
62226
62867
|
> recipes follows one of those two shapes — copy them, not a bare `post(...)`.
|
|
62227
62868
|
|
|
62869
|
+
**A summary line over the form is the page's, not the helper's.** The
|
|
62870
|
+
rejection tells the two failures apart: the error bag (an object, already in
|
|
62871
|
+
`form.errors`) or an `Error` (the server answered with something that is not
|
|
62872
|
+
Inertia's). Word them in the `catch`:
|
|
62873
|
+
|
|
62874
|
+
```ts
|
|
62875
|
+
const failure = ref('');
|
|
62876
|
+
const save = async () => {
|
|
62877
|
+
failure.value = '';
|
|
62878
|
+
try {
|
|
62879
|
+
await put(`/invoices/${invoice.id}`);
|
|
62880
|
+
} catch (error) {
|
|
62881
|
+
failure.value =
|
|
62882
|
+
error instanceof Error
|
|
62883
|
+
? 'The save did not go through. Try again.'
|
|
62884
|
+
: 'Check the highlighted fields.';
|
|
62885
|
+
}
|
|
62886
|
+
};
|
|
62887
|
+
```
|
|
62888
|
+
|
|
62228
62889
|
**Seeding a draft: `NullableKeys<T>`, not a hand-rolled `| null` on every
|
|
62229
62890
|
field.** Don't declare a bespoke `Draft` type that bolts `| null` onto each
|
|
62230
62891
|
string prop by hand — that's what produces empty-string drafts (`''`
|
|
@@ -62269,57 +62930,86 @@ const { form } = usePromiseForm<NullableKeys<InvoiceRequest>>({
|
|
|
62269
62930
|
|
|
62270
62931
|
Deep dirty-checking against a snapshot baseline — for edit screens whose
|
|
62271
62932
|
state is not a single Inertia form (multiple forms, ad-hoc refs). For a
|
|
62272
|
-
single `usePromiseForm`, prefer the built-in `form.isDirty`.
|
|
62933
|
+
single `usePromiseForm`, prefer the built-in `form.isDirty`. The baseline is
|
|
62934
|
+
a structural hash of each value, so Dates, Sets, Maps, `undefined` and NaN
|
|
62935
|
+
compare as they are, and a `File` by its name, size, type and date, so picking
|
|
62936
|
+
another file over a picked one counts.
|
|
62273
62937
|
|
|
62274
62938
|
```ts
|
|
62275
62939
|
// inertia-helpers/useHasChanged.ts
|
|
62276
|
-
import {
|
|
62940
|
+
import { isPlainObject } from './utils';
|
|
62941
|
+
import { hash } from 'object-code';
|
|
62277
62942
|
import { computed, ref, toValue, type WatchSource } from 'vue';
|
|
62278
62943
|
|
|
62279
62944
|
export function useHasChanged<T extends WatchSource<unknown>[]>(
|
|
62280
62945
|
dependencies: T
|
|
62281
62946
|
) {
|
|
62282
62947
|
/*
|
|
62283
|
-
*
|
|
62284
|
-
*
|
|
62285
|
-
*
|
|
62286
|
-
*
|
|
62287
|
-
*
|
|
62288
|
-
*
|
|
62289
|
-
*
|
|
62290
|
-
*
|
|
62291
|
-
*
|
|
62948
|
+
* The baseline is a structural HASH, not a copy. A copy has to survive the
|
|
62949
|
+
* values a form holds: a JSON round-trip turns a Date into a string, a File,
|
|
62950
|
+
* Set or Map into `{}`, drops `undefined` (and throws on a bare one), so
|
|
62951
|
+
* an untouched form read as changed; `structuredClone` throws on Vue proxies.
|
|
62952
|
+
* `object-code` hashes straight through a proxy and keeps Dates (by time),
|
|
62953
|
+
* Sets / Maps (by entries), `undefined` and NaN apart, and a number is all
|
|
62954
|
+
* the baseline needs to hold. It hashes every File alike (no own keys), so
|
|
62955
|
+
* a File is read by name, size, type and date first: picking another file
|
|
62956
|
+
* over a picked one counts as a change.
|
|
62292
62957
|
*/
|
|
62293
62958
|
const snapshot = (dep: WatchSource<unknown>) =>
|
|
62294
|
-
|
|
62959
|
+
hash(withFileFingerprints(toValue(dep)));
|
|
62295
62960
|
|
|
62296
|
-
const
|
|
62961
|
+
const baselines = ref<number[]>(dependencies.map(snapshot));
|
|
62297
62962
|
|
|
62298
62963
|
/** Re-baseline (call after a successful save). */
|
|
62299
62964
|
const takeSnapshot = (): void => {
|
|
62300
|
-
|
|
62965
|
+
baselines.value = dependencies.map(snapshot);
|
|
62301
62966
|
};
|
|
62302
62967
|
|
|
62303
62968
|
const hasChanged = computed(() =>
|
|
62304
|
-
dependencies.some(
|
|
62305
|
-
(dep, index) => !isEqual(toValue(dep), baselineValues.value[index])
|
|
62306
|
-
)
|
|
62969
|
+
dependencies.some((dep, index) => snapshot(dep) !== baselines.value[index])
|
|
62307
62970
|
);
|
|
62308
62971
|
|
|
62309
62972
|
return { hasChanged, takeSnapshot };
|
|
62310
62973
|
}
|
|
62974
|
+
|
|
62975
|
+
const withFileFingerprints = (value: unknown): unknown =>
|
|
62976
|
+
value instanceof Blob
|
|
62977
|
+
? [
|
|
62978
|
+
value.size,
|
|
62979
|
+
value.type,
|
|
62980
|
+
(value as Partial<File>).name,
|
|
62981
|
+
(value as Partial<File>).lastModified,
|
|
62982
|
+
]
|
|
62983
|
+
: Array.isArray(value)
|
|
62984
|
+
? value.map(withFileFingerprints)
|
|
62985
|
+
: isPlainObject(value)
|
|
62986
|
+
? Object.fromEntries(
|
|
62987
|
+
Object.entries(value).map(([key, item]) => [
|
|
62988
|
+
key,
|
|
62989
|
+
withFileFingerprints(item),
|
|
62990
|
+
])
|
|
62991
|
+
)
|
|
62992
|
+
: value;
|
|
62311
62993
|
```
|
|
62312
62994
|
|
|
62313
62995
|
### useConfirmOnRouteLeave
|
|
62314
62996
|
|
|
62315
62997
|
A dirty-state navigation guard: intercepts Inertia visits and asks through
|
|
62316
62998
|
[`useConfirm`](https://llms-bitboss-ui.bitboss.it/dist/ai/composables/useConfirm.md) before leaving. Requires
|
|
62317
|
-
`<BbHost />` mounted in the app root.
|
|
62318
|
-
|
|
62319
|
-
|
|
62999
|
+
`<BbHost />` mounted in the app root. `whitelist` names the form's own
|
|
63000
|
+
submit targets: a submit (POST / PUT / PATCH / DELETE) to one of them goes
|
|
63001
|
+
out without asking, while a plain link to the same path (a Cancel button
|
|
63002
|
+
back to `/invoices`) still asks. Server redirects after a whitelisted
|
|
63003
|
+
action carry the `X-Whitelisted-Action` header so a saving visit's redirect
|
|
63004
|
+
is never blocked by its own guard. Leaving the app (closing the tab,
|
|
63005
|
+
reloading, a typed URL, a plain `<a>`) gets the browser's own "Leave site?"
|
|
63006
|
+
prompt while the guard is armed; a whitelisted save that the server answers
|
|
63007
|
+
with `Inertia::location()` leaves without one. `stack` picks the `BbDialog`
|
|
63008
|
+
stack the dialog joins; `variant` defaults to `'destructive'`.
|
|
62320
63009
|
|
|
62321
63010
|
```ts
|
|
62322
63011
|
// inertia-helpers/useConfirmOnRouteLeave.ts
|
|
63012
|
+
import { useHelperMessages } from './messages';
|
|
62323
63013
|
import { router } from '@inertiajs/vue3';
|
|
62324
63014
|
import { useConfirm, type BbConfirmMessage } from 'bitboss-ui';
|
|
62325
63015
|
import { onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue';
|
|
@@ -62333,7 +63023,11 @@ type Visit = {
|
|
|
62333
63023
|
};
|
|
62334
63024
|
|
|
62335
63025
|
type Options = {
|
|
62336
|
-
/**
|
|
63026
|
+
/**
|
|
63027
|
+
* Submit targets that bypass the dialog (the form's own save). Only a
|
|
63028
|
+
* submit (POST / PUT / PATCH / DELETE) passes: a link to the same path
|
|
63029
|
+
* (Cancel → the list) still asks.
|
|
63030
|
+
*/
|
|
62337
63031
|
whitelist?: MaybeRefOrGetter<string[]>;
|
|
62338
63032
|
/** Disable the guard (bind to `!hasChanged` / `!form.isDirty`). */
|
|
62339
63033
|
disabled?: MaybeRefOrGetter<boolean>;
|
|
@@ -62345,12 +63039,14 @@ type Options = {
|
|
|
62345
63039
|
text?: string;
|
|
62346
63040
|
/**
|
|
62347
63041
|
* The dialog's buttons and look, BbConfirm's own options: a label string
|
|
62348
|
-
* or a full button config.
|
|
62349
|
-
* `variant: 'destructive'`.
|
|
63042
|
+
* or a full button config. Default words: `leave` in `messages.ts`, in the
|
|
63043
|
+
* library's `locale`; `variant: 'destructive'`.
|
|
62350
63044
|
*/
|
|
62351
63045
|
yes?: BbConfirmMessage['yes'];
|
|
62352
63046
|
no?: BbConfirmMessage['no'];
|
|
62353
63047
|
variant?: BbConfirmMessage['variant'];
|
|
63048
|
+
/** The `BbDialog` stack the dialog joins (BbConfirm's `stack`). */
|
|
63049
|
+
stack?: BbConfirmMessage['stack'];
|
|
62354
63050
|
};
|
|
62355
63051
|
|
|
62356
63052
|
const normalizeUrl = (value: string | URL) => {
|
|
@@ -62377,24 +63073,26 @@ export const useConfirmOnRouteLeave = ({
|
|
|
62377
63073
|
disabled = false,
|
|
62378
63074
|
onBefore = () => true,
|
|
62379
63075
|
onLeave = () => true,
|
|
62380
|
-
title
|
|
62381
|
-
text
|
|
62382
|
-
yes
|
|
62383
|
-
no
|
|
63076
|
+
title,
|
|
63077
|
+
text,
|
|
63078
|
+
yes,
|
|
63079
|
+
no,
|
|
62384
63080
|
variant = 'destructive',
|
|
63081
|
+
stack,
|
|
62385
63082
|
}: Options = {}) => {
|
|
62386
63083
|
// Captured in setup so the built-in dialog is owned by the guarded page:
|
|
62387
63084
|
// if the page unmounts anyway, the dialog dismisses itself quietly.
|
|
62388
63085
|
const { confirm } = useConfirm();
|
|
63086
|
+
const messages = useHelperMessages();
|
|
62389
63087
|
let bypassNextVisit = false;
|
|
62390
63088
|
|
|
62391
63089
|
const unbind = router.on('before', (event) => {
|
|
62392
63090
|
const visit = event.detail.visit as Visit;
|
|
62393
63091
|
|
|
62394
63092
|
// A hover / mount prefetch (`<Link prefetch>`, `<BbButton href prefetch>`)
|
|
62395
|
-
// fires `before` too, but leaves nothing: no dialog
|
|
62396
|
-
//
|
|
62397
|
-
if (visit.prefetch) return;
|
|
63093
|
+
// fires `before` too, but leaves nothing: no dialog. A visit another
|
|
63094
|
+
// guard on the page already stopped is that guard's to ask about.
|
|
63095
|
+
if (visit.prefetch || event.defaultPrevented) return;
|
|
62398
63096
|
|
|
62399
63097
|
if (bypassNextVisit) {
|
|
62400
63098
|
bypassNextVisit = false;
|
|
@@ -62406,41 +63104,146 @@ export const useConfirmOnRouteLeave = ({
|
|
|
62406
63104
|
|
|
62407
63105
|
const nextPath = normalizeUrl(visit.url.href);
|
|
62408
63106
|
if (nextPath === normalizeUrl(window.location.href)) return;
|
|
62409
|
-
if (
|
|
63107
|
+
if (
|
|
63108
|
+
visit.method !== 'get' &&
|
|
63109
|
+
toValue(whitelist).map(normalizeUrl).includes(nextPath)
|
|
63110
|
+
) {
|
|
63111
|
+
// Marked: a save answered with a 409 redirect (a redirect with a
|
|
63112
|
+
// #fragment) is re-sent by Inertia with these same headers, and that
|
|
63113
|
+
// follow-up must not ask either.
|
|
63114
|
+
visit.headers = { ...visit.headers, [WHITELIST_HEADER]: '1' };
|
|
63115
|
+
return;
|
|
63116
|
+
}
|
|
62410
63117
|
|
|
62411
63118
|
// `return`, not `return false`: an Inertia `before` listener that returns
|
|
62412
63119
|
// false CANCELS the visit, which is the opposite of what `onBefore`
|
|
62413
63120
|
// promises (false = let this visit through, no dialog).
|
|
62414
63121
|
if (onBefore(visit) === false) return;
|
|
62415
63122
|
event.preventDefault();
|
|
63123
|
+
// Held, not refused: the dialog decides. `usePromiseForm` reads this
|
|
63124
|
+
// flag and waits instead of answering `false` at once.
|
|
63125
|
+
visit.awaitingConfirm = true;
|
|
62416
63126
|
|
|
62417
63127
|
void (async () => {
|
|
62418
|
-
const
|
|
62419
|
-
|
|
63128
|
+
const words = messages.value.leave;
|
|
63129
|
+
const confirmed = await confirm({
|
|
63130
|
+
title: title ?? words.title,
|
|
63131
|
+
text: text ?? words.text,
|
|
63132
|
+
yes: yes ?? words.yes,
|
|
63133
|
+
no: no ?? words.no,
|
|
63134
|
+
variant,
|
|
63135
|
+
...(stack && { stack }),
|
|
63136
|
+
});
|
|
63137
|
+
if (!confirmed) {
|
|
63138
|
+
// "Keep editing" ends the held visit the way Inertia ends a
|
|
63139
|
+
// cancelled one, so its own callbacks (a form's processing state,
|
|
63140
|
+
// a pending promise) settle.
|
|
63141
|
+
const callbacks = visit as {
|
|
63142
|
+
onCancel?: () => void;
|
|
63143
|
+
onFinish?: (visit: Visit) => void;
|
|
63144
|
+
};
|
|
63145
|
+
callbacks.onCancel?.();
|
|
63146
|
+
callbacks.onFinish?.({ ...visit, cancelled: true });
|
|
63147
|
+
return;
|
|
63148
|
+
}
|
|
62420
63149
|
|
|
62421
63150
|
await onLeave(visit);
|
|
63151
|
+
// Re-sent as a plain visit: never as a prefetch or a background one,
|
|
63152
|
+
// and without the visit's own onBefore (its `confirm('Sure?')` was
|
|
63153
|
+
// already answered).
|
|
63154
|
+
const {
|
|
63155
|
+
prefetch: _prefetch,
|
|
63156
|
+
async: _async,
|
|
63157
|
+
onBefore: _onBefore,
|
|
63158
|
+
awaitingConfirm: _awaitingConfirm,
|
|
63159
|
+
...rest
|
|
63160
|
+
} = visit;
|
|
63161
|
+
// `before` fires inside router.visit(): the bypass covers exactly
|
|
63162
|
+
// that visit, and is never left armed for the next one (a re-send
|
|
63163
|
+
// its own callbacks stop before `before`).
|
|
62422
63164
|
bypassNextVisit = true;
|
|
62423
|
-
// Re-sent as a plain visit: never as a prefetch or a background one.
|
|
62424
|
-
const { prefetch: _prefetch, async: _async, ...rest } = visit;
|
|
62425
63165
|
router.visit(visit.url, {
|
|
62426
63166
|
...(rest as Parameters<typeof router.visit>[1]),
|
|
62427
63167
|
headers: { ...visit.headers, [WHITELIST_HEADER]: '1' },
|
|
62428
63168
|
});
|
|
63169
|
+
bypassNextVisit = false;
|
|
62429
63170
|
})();
|
|
62430
63171
|
});
|
|
62431
63172
|
|
|
63173
|
+
// Leaving the app (closing the tab, reloading, a typed URL, a plain `<a>`)
|
|
63174
|
+
// never reaches `before`: the browser's own prompt covers it. Not for a
|
|
63175
|
+
// visit this guard let through: a save answered with `Inertia::location()`
|
|
63176
|
+
// is a real unload with the form still dirty. Inertia sets
|
|
63177
|
+
// `window.location` inside that visit, and WebKit asks only after the visit
|
|
63178
|
+
// finished, so a let-through visit that completed with no answer (no page,
|
|
63179
|
+
// no error bag, no error response: Inertia left) keeps it quiet until the
|
|
63180
|
+
// next visit starts.
|
|
63181
|
+
const released = new Set<unknown>();
|
|
63182
|
+
let answered = false;
|
|
63183
|
+
let leaving = false;
|
|
63184
|
+
const marked = (visit: { headers?: Record<string, string> }) =>
|
|
63185
|
+
getHeaderValue(visit.headers, WHITELIST_HEADER) === '1';
|
|
63186
|
+
const unbindStart = router.on('start', ({ detail: { visit } }) => {
|
|
63187
|
+
leaving = false;
|
|
63188
|
+
answered = false;
|
|
63189
|
+
if (marked(visit)) released.add(visit);
|
|
63190
|
+
});
|
|
63191
|
+
// Every way a visit gets an answer; the last two pairs are Inertia 3's
|
|
63192
|
+
// names and Inertia 2's (each fires only on its own version).
|
|
63193
|
+
const onRouterEvent = (name: string, listener: () => void) =>
|
|
63194
|
+
router.on(name as 'start', listener);
|
|
63195
|
+
const unbindAnswers = [
|
|
63196
|
+
'success',
|
|
63197
|
+
'error',
|
|
63198
|
+
'httpException',
|
|
63199
|
+
'invalid',
|
|
63200
|
+
'networkError',
|
|
63201
|
+
'exception',
|
|
63202
|
+
].map((name) => onRouterEvent(name, () => void (answered = true)));
|
|
63203
|
+
const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
|
|
63204
|
+
released.delete(visit);
|
|
63205
|
+
if (marked(visit) && visit.completed && !answered) leaving = true;
|
|
63206
|
+
});
|
|
63207
|
+
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
63208
|
+
if (toValue(disabled) || released.size || leaving) return;
|
|
63209
|
+
event.preventDefault();
|
|
63210
|
+
event.returnValue = '';
|
|
63211
|
+
};
|
|
63212
|
+
const inBrowser = typeof window !== 'undefined';
|
|
63213
|
+
if (inBrowser) window.addEventListener('beforeunload', onBeforeUnload);
|
|
63214
|
+
|
|
62432
63215
|
// `router.on` is a global bus that outlives this component: unbind it.
|
|
62433
63216
|
// `onScopeDispose` over `onUnmounted`: it fires on unmount too (`setup()`
|
|
62434
63217
|
// runs inside a component's own effect scope), but also works if this
|
|
62435
63218
|
// composable is ever called from outside one.
|
|
62436
|
-
onScopeDispose(
|
|
63219
|
+
onScopeDispose(() => {
|
|
63220
|
+
unbind();
|
|
63221
|
+
unbindStart();
|
|
63222
|
+
unbindAnswers.forEach((unbindAnswer) => unbindAnswer());
|
|
63223
|
+
unbindFinish();
|
|
63224
|
+
if (inBrowser) window.removeEventListener('beforeunload', onBeforeUnload);
|
|
63225
|
+
});
|
|
62437
63226
|
};
|
|
62438
63227
|
```
|
|
62439
63228
|
|
|
62440
63229
|
A prefetch never asks: `<Link prefetch>` and `<BbButton href prefetch>` fire
|
|
62441
63230
|
Inertia's `before` event on hover too, and the guard lets them through (they
|
|
62442
63231
|
leave nothing). The visit "Discard changes" re-sends is a plain one, never a
|
|
62443
|
-
prefetch or an `async` visit
|
|
63232
|
+
prefetch or an `async` visit, and it does not ask the visit's own `onBefore`
|
|
63233
|
+
(an `onBefore: () => confirm('Sure?')`) a second time. "Keep editing" ends
|
|
63234
|
+
the held visit as Inertia ends a cancelled one (`onCancel`, then `onFinish`
|
|
63235
|
+
with `cancelled: true`), so a `usePromiseForm` save held by the dialog
|
|
63236
|
+
answers the user's choice: the re-sent save's result, or `false`. Two guards
|
|
63237
|
+
on one page ask once. **Back / Forward are not guarded, by design:** Inertia restores
|
|
63238
|
+
those pages from history without a `before` event, and catching them would
|
|
63239
|
+
mean fighting Inertia's own history and scroll restore.
|
|
63240
|
+
|
|
63241
|
+
**The dialog speaks the library's `locale`.** Its default words come from
|
|
63242
|
+
`messages.ts` (see [Shared types and utilities](#shared-types-and-utilities)),
|
|
63243
|
+
which ships English only. Add your app's locale there once, keyed as you set
|
|
63244
|
+
the library's `locale`, and every guard follows `setConfig({ locale })`; a
|
|
63245
|
+
locale with no entry falls back to English. `title` / `text` / `yes` / `no`
|
|
63246
|
+
passed to one guard still win over it.
|
|
62444
63247
|
|
|
62445
63248
|
> **`router.on(...)` subscribes to a global, module-level event bus — it
|
|
62446
63249
|
> outlives the component that registered it unless you unbind it.** Inertia
|
|
@@ -62628,13 +63431,22 @@ export function useAsyncFn<T>(
|
|
|
62628
63431
|
"Given an id, keep its resource loaded": re-fetches whenever the id
|
|
62629
63432
|
changes, resets to `null` when the id clears, exposes `loading` and
|
|
62630
63433
|
`refresh()`. The natural engine for a detail sidebar or peek panel — feed
|
|
62631
|
-
it the highlighted row's id.
|
|
63434
|
+
it the highlighted row's id. `fn` gets an `AbortSignal` as its second argument
|
|
63435
|
+
(`(id, signal) => fetch(url, { signal })`): it aborts when the load goes stale
|
|
63436
|
+
(a newer id, a clear, the page unmounting).
|
|
62632
63437
|
|
|
62633
63438
|
```ts
|
|
62634
63439
|
// inertia-helpers/useResourceContext.ts
|
|
62635
63440
|
import { useLoading } from './useLoading';
|
|
62636
63441
|
import { noop } from './utils';
|
|
62637
|
-
import {
|
|
63442
|
+
import {
|
|
63443
|
+
onScopeDispose,
|
|
63444
|
+
ref,
|
|
63445
|
+
toValue,
|
|
63446
|
+
watch,
|
|
63447
|
+
type MaybeRefOrGetter,
|
|
63448
|
+
type Ref,
|
|
63449
|
+
} from 'vue';
|
|
62638
63450
|
|
|
62639
63451
|
type UseResourceContextOptions<T, ID> = {
|
|
62640
63452
|
immediate?: boolean;
|
|
@@ -62647,7 +63459,8 @@ type UseResourceContextOptions<T, ID> = {
|
|
|
62647
63459
|
|
|
62648
63460
|
export function useResourceContext<T, ID = string>(
|
|
62649
63461
|
id: MaybeRefOrGetter<ID | null | undefined>,
|
|
62650
|
-
|
|
63462
|
+
/** `signal` aborts when the load goes stale: pass it to `fetch` / axios. */
|
|
63463
|
+
fn: (id: ID, signal: AbortSignal) => Promise<T>,
|
|
62651
63464
|
options: UseResourceContextOptions<T, ID> = {}
|
|
62652
63465
|
) {
|
|
62653
63466
|
const data = ref<T | null>(null) as Ref<T | null>;
|
|
@@ -62661,31 +63474,69 @@ export function useResourceContext<T, ID = string>(
|
|
|
62661
63474
|
onFinish = noop,
|
|
62662
63475
|
} = options;
|
|
62663
63476
|
|
|
62664
|
-
const { loading, fn: fetch } = useLoading(
|
|
63477
|
+
const { loading, fn: fetch } = useLoading(
|
|
63478
|
+
(resourceId: ID, signal: AbortSignal) => fn(resourceId, signal)
|
|
63479
|
+
);
|
|
63480
|
+
|
|
63481
|
+
// Each load takes a ticket; a newer load, an id change or the scope's end
|
|
63482
|
+
// makes older tickets stale, and a stale response is dropped (no data, no
|
|
63483
|
+
// callbacks): a slow answer for the row the user left never lands. Going
|
|
63484
|
+
// stale also aborts its request.
|
|
63485
|
+
let ticket = 0;
|
|
63486
|
+
let controller: AbortController | null = null;
|
|
63487
|
+
const stale = () => {
|
|
63488
|
+
ticket++;
|
|
63489
|
+
controller?.abort();
|
|
63490
|
+
};
|
|
63491
|
+
let loadedId: ID | null = null;
|
|
63492
|
+
const clear = () => {
|
|
63493
|
+
stale();
|
|
63494
|
+
data.value = null;
|
|
63495
|
+
loadedId = null;
|
|
63496
|
+
};
|
|
63497
|
+
onScopeDispose(stale);
|
|
62665
63498
|
|
|
62666
63499
|
const load = async (resourceId: ID) => {
|
|
63500
|
+
stale();
|
|
63501
|
+
const own = ticket;
|
|
63502
|
+
controller = new AbortController();
|
|
63503
|
+
const outcome = await fetch(resourceId, controller.signal).then(
|
|
63504
|
+
(result) => ({ ok: true as const, result }),
|
|
63505
|
+
(error: unknown) => ({ ok: false as const, error })
|
|
63506
|
+
);
|
|
63507
|
+
if (own !== ticket) return;
|
|
62667
63508
|
try {
|
|
62668
|
-
|
|
62669
|
-
|
|
62670
|
-
|
|
62671
|
-
|
|
62672
|
-
|
|
62673
|
-
|
|
63509
|
+
if (outcome.ok) {
|
|
63510
|
+
data.value = outcome.result;
|
|
63511
|
+
loadedId = resourceId;
|
|
63512
|
+
// Outside the fetch's catch: a throwing onSuccess is the
|
|
63513
|
+
// caller's bug, not a failed fetch.
|
|
63514
|
+
await onSuccess(outcome.result, resourceId);
|
|
63515
|
+
} else {
|
|
63516
|
+
clear();
|
|
63517
|
+
await onError(outcome.error, resourceId);
|
|
63518
|
+
}
|
|
62674
63519
|
} finally {
|
|
62675
63520
|
await onFinish(resourceId);
|
|
62676
63521
|
}
|
|
62677
63522
|
};
|
|
62678
63523
|
|
|
63524
|
+
// Per-item sources: a re-run that yields the same id and flag (a partial
|
|
63525
|
+
// reload handing a new row object with the same id) does not refetch.
|
|
62679
63526
|
watch(
|
|
62680
|
-
() =>
|
|
62681
|
-
async (
|
|
63527
|
+
[() => toValue(id), () => toValue(disabled)],
|
|
63528
|
+
async ([resourceId, isDisabled]) => {
|
|
62682
63529
|
if (!resourceId) {
|
|
62683
|
-
|
|
63530
|
+
clear();
|
|
62684
63531
|
await options.onReset?.();
|
|
62685
63532
|
return;
|
|
62686
63533
|
}
|
|
62687
|
-
// Keep already-loaded data while disabled
|
|
62688
|
-
|
|
63534
|
+
// Keep already-loaded data while disabled, but only for the id it
|
|
63535
|
+
// was loaded for: never show row a's resource under row b.
|
|
63536
|
+
if (isDisabled) {
|
|
63537
|
+
if (resourceId !== loadedId) clear();
|
|
63538
|
+
return;
|
|
63539
|
+
}
|
|
62689
63540
|
await load(resourceId as ID);
|
|
62690
63541
|
},
|
|
62691
63542
|
{ immediate }
|
|
@@ -62715,6 +63566,10 @@ Gotchas:
|
|
|
62715
63566
|
never rejects on a non-2xx response — a 403/404 body still parses as JSON
|
|
62716
63567
|
(or throws inside `.json()`, which is _also_ swallowed by `useResourceContext`
|
|
62717
63568
|
the same way). Throw when `!res.ok` so `onError` actually fires.
|
|
63569
|
+
- **Only the current id's answer lands.** A slower response for a row the
|
|
63570
|
+
user already left, or one landing after the id cleared or the scope ended,
|
|
63571
|
+
is dropped: no `data`, no callbacks. While `disabled`, `data` is kept only
|
|
63572
|
+
for the id it was loaded for; an id change clears it (re-enabling loads).
|
|
62718
63573
|
|
|
62719
63574
|
## UI utilities
|
|
62720
63575
|
|
|
@@ -62759,28 +63614,32 @@ export function useDeleteCmd(
|
|
|
62759
63614
|
|
|
62760
63615
|
### useScreenSizes
|
|
62761
63616
|
|
|
62762
|
-
Shared reactive breakpoints
|
|
62763
|
-
|
|
63617
|
+
Shared reactive breakpoints — one window listener for the whole app via
|
|
63618
|
+
`createSharedComposable`. `isMobile` is the library's own signal (below the
|
|
63619
|
+
plugin's `mobileMaxWidth`, 768 by default), so a page and its components
|
|
63620
|
+
agree; tablet ends at Tailwind `lg`.
|
|
62764
63621
|
|
|
62765
63622
|
```ts
|
|
62766
63623
|
// inertia-helpers/useScreenSizes.ts
|
|
62767
63624
|
import { createSharedComposable, useWindowSize } from '@vueuse/core';
|
|
63625
|
+
import { useMobile } from 'bitboss-ui';
|
|
62768
63626
|
import { computed } from 'vue';
|
|
62769
63627
|
|
|
62770
|
-
/** Tailwind
|
|
62771
|
-
export const SCREEN_BREAKPOINT_MD = 768;
|
|
62772
|
-
/** Tailwind lg breakpoint (px). Tablet when 768 <= width < 1024. */
|
|
63628
|
+
/** Tailwind lg breakpoint (px). Tablet from the library's mobile cut up to here. */
|
|
62773
63629
|
export const SCREEN_BREAKPOINT_LG = 1024;
|
|
62774
63630
|
|
|
62775
63631
|
const useScreenSizesImpl = () => {
|
|
62776
63632
|
const { width } = useWindowSize();
|
|
63633
|
+
// The library's own signal (below the plugin's `mobileMaxWidth`), so a page
|
|
63634
|
+
// and the components on it never disagree about "mobile".
|
|
63635
|
+
const { isMobile } = useMobile();
|
|
62777
63636
|
|
|
62778
|
-
const isMobile = computed(() => width.value < SCREEN_BREAKPOINT_MD);
|
|
62779
63637
|
const isTablet = computed(
|
|
62780
|
-
() =>
|
|
62781
|
-
|
|
63638
|
+
() => !isMobile.value && width.value < SCREEN_BREAKPOINT_LG
|
|
63639
|
+
);
|
|
63640
|
+
const isDesktop = computed(
|
|
63641
|
+
() => !isMobile.value && width.value >= SCREEN_BREAKPOINT_LG
|
|
62782
63642
|
);
|
|
62783
|
-
const isDesktop = computed(() => width.value >= SCREEN_BREAKPOINT_LG);
|
|
62784
63643
|
|
|
62785
63644
|
return { width, isMobile, isTablet, isDesktop };
|
|
62786
63645
|
};
|
|
@@ -72408,6 +73267,54 @@ Nothing here fails to compile — these change what existing code does.
|
|
|
72408
73267
|
stay open across picks (a settings panel of several radios): set
|
|
72409
73268
|
`:close-on-select="false"`; `true` closes after every change, a
|
|
72410
73269
|
multiple pick included.
|
|
73270
|
+
113. **`useQueryObject` takes Inertia's visit options in one place** (after
|
|
73271
|
+
3.0.0-beta.64, the Inertia helper, after `npx bitboss-ui helpers
|
|
73272
|
+
update`). Move `only`, `preserveState`, `preserveScroll` and `replace`
|
|
73273
|
+
into `visitOptions: { only: ['invoices'] }`. `shouldNavigate: () => !x`
|
|
73274
|
+
becomes `paused: () => x` (inverted); resuming now sends the object's
|
|
73275
|
+
current state once, so a page that reassigned the object to catch up
|
|
73276
|
+
after its own visit can drop that. `resetPage: true, resetKeys: [k]`
|
|
73277
|
+
becomes `resetKeys: ['page', k]`: the default is `['page']` and a
|
|
73278
|
+
passed list replaces it. `transform` is gone (pass a getter that builds
|
|
73279
|
+
the keys) and so is `arrayFormat` (`QUERY_ARRAY_FORMAT` in
|
|
73280
|
+
`useQuery.ts`, one value for every helper). `server: false` writes the
|
|
73281
|
+
URL with no request.
|
|
73282
|
+
114. **The URL helpers read and write the URL as the server does** (after
|
|
73283
|
+
3.0.0-beta.64, `useQuery` / `useRouteQuery`, after a helpers update).
|
|
73284
|
+
`useQuery` decodes once: `?q=R%26D` reads `R&D`, and an encoded `%2C`
|
|
73285
|
+
separates a comma array as PHP and Spatie read it (with the comma
|
|
73286
|
+
format a comma typed into free text reads back as a separator, as on
|
|
73287
|
+
the server). Writes made before the server answers build on each other
|
|
73288
|
+
(sort and direction in one click both land), and a write changes only
|
|
73289
|
+
its own key. `useRouteQuery`: move `preserveScroll` / `preserveState`
|
|
73290
|
+
into `visitOptions`; `null` / `undefined` now remove the key (it used
|
|
73291
|
+
to write the string `'null'`); a repeated key reads its last value.
|
|
73292
|
+
115. **The pager pushes history, and the visit helpers settle** (after
|
|
73293
|
+
3.0.0-beta.64, after a helpers update). A `page` write is a history
|
|
73294
|
+
entry (Back goes to the previous page; `visitOptions: { replace: true }`
|
|
73295
|
+
keeps the old behaviour); a new `perPage` returns to page 1 in the same
|
|
73296
|
+
visit; a page past the last goes to the last. `items` is a copy that a
|
|
73297
|
+
visit asking for the prop resets, while other prop swaps keep its edits
|
|
73298
|
+
(this replaces item 102's rule). New options `pageQueryKey` /
|
|
73299
|
+
`perPageQueryKey` for a custom Laravel `pageName`. `usePromiseForm`
|
|
73300
|
+
resolves `false` when a guard cancels and rejects an Inertia error page;
|
|
73301
|
+
`useConfirmOnRouteLeave`'s `whitelist` now only lets submits through, so
|
|
73302
|
+
a Cancel link to the list asks.
|
|
73303
|
+
116. **The leave guard asks the browser too, and dirty checks stop crying
|
|
73304
|
+
wolf** (after 3.0.0-beta.64, after a helpers update).
|
|
73305
|
+
`useConfirmOnRouteLeave` now also gets the browser's "Leave site?"
|
|
73306
|
+
prompt on a tab close, reload, typed URL or plain `<a>` (not for a
|
|
73307
|
+
whitelisted save that leaves through `Inertia::location()`), takes
|
|
73308
|
+
`stack`, and takes its words from the new `messages.ts` in the library's
|
|
73309
|
+
`locale` (add your locale there instead of passing labels per page). `useHasChanged` no longer reads an untouched form with Dates,
|
|
73310
|
+
Files or `undefined` as changed. `items` in the paginated helpers is a
|
|
73311
|
+
deep copy, so editing a row leaves the page prop alone. `useScreenSizes`
|
|
73312
|
+
follows the plugin's `mobileMaxWidth`; import `useMobile` from
|
|
73313
|
+
`bitboss-ui` where you used `SCREEN_BREAKPOINT_MD`.
|
|
73314
|
+
117. **Inertia 2 or 3** (3.0.0-beta.65). The optional `@inertiajs/vue3` peer
|
|
73315
|
+
now starts at 2.0.0. The Inertia helpers run and type-check on both
|
|
73316
|
+
majors; on Inertia 2, `useLazyProp` cannot keep the page's validation
|
|
73317
|
+
errors across its reload.
|
|
72411
73318
|
|
|
72412
73319
|
## 7. Class overrides → variants
|
|
72413
73320
|
|
|
@@ -74747,8 +75654,10 @@ part accepts a function** on the instance, beside a plain value:
|
|
|
74747
75654
|
- **The row's facts ride beside them.** Today that is `variant`, a typed
|
|
74748
75655
|
string, where the rows have one: a dropdown entry's own (`default` when it
|
|
74749
75656
|
has none), a toast's, or the component's (`BbTabs`). On `BbSelect` /
|
|
74750
|
-
`BbSelectPopover` it is `known`: `true` for a `known-items` stand-in
|
|
74751
|
-
|
|
75657
|
+
`BbSelectPopover` it is `known`: `true` for a `known-items` stand-in (a row
|
|
75658
|
+
the form already held, named before the real options load; see the
|
|
75659
|
+
[Fetch items playbook](https://llms-bitboss-ui.bitboss.it/dist/ai/guides/fetch-items-playbook.md)), whose `item` is then
|
|
75660
|
+
`{ value, label }`, as in the `option` slot. Other props are
|
|
74752
75661
|
not in it: you wrote the function where you pass them.
|
|
74753
75662
|
- The result is a normal `PtValue` (a class list or `{ class, style, attrs }`);
|
|
74754
75663
|
`false` / `null` / `undefined` mean _leave the row as it is_.
|
|
@@ -78042,7 +78951,7 @@ export function provideApprovalsInbox(): ApprovalsInboxContext {
|
|
|
78042
78951
|
});
|
|
78043
78952
|
// The search waits for typing to settle; every other filter visits at once.
|
|
78044
78953
|
useQueryObject(filters, {
|
|
78045
|
-
only: [...RELOAD_PROPS],
|
|
78954
|
+
visitOptions: { only: [...RELOAD_PROPS] },
|
|
78046
78955
|
debounce: { search: 300 },
|
|
78047
78956
|
});
|
|
78048
78957
|
|
|
@@ -79929,7 +80838,7 @@ export function provideBacklogWorkspace(): BacklogWorkspaceContext {
|
|
|
79929
80838
|
: String(filters.assignee.id ?? 'none'),
|
|
79930
80839
|
}),
|
|
79931
80840
|
// The search waits for typing to settle; every other filter visits at once.
|
|
79932
|
-
{ only: [...RELOAD_PROPS], debounce: { search: 300 } }
|
|
80841
|
+
{ visitOptions: { only: [...RELOAD_PROPS] }, debounce: { search: 300 } }
|
|
79933
80842
|
);
|
|
79934
80843
|
|
|
79935
80844
|
const hasActiveFilters = computed(
|
|
@@ -84272,7 +85181,7 @@ export function provideOwnershipAtlas(): OwnershipAtlasContext {
|
|
|
84272
85181
|
});
|
|
84273
85182
|
// The search waits for typing to settle; every other filter visits at once.
|
|
84274
85183
|
useQueryObject(filters, {
|
|
84275
|
-
only: [...RELOAD_PROPS],
|
|
85184
|
+
visitOptions: { only: [...RELOAD_PROPS] },
|
|
84276
85185
|
debounce: { search: 300 },
|
|
84277
85186
|
});
|
|
84278
85187
|
|
|
@@ -87381,7 +88290,7 @@ export function provideInvoicesWorkspace(): InvoicesWorkspaceContext {
|
|
|
87381
88290
|
});
|
|
87382
88291
|
// The search waits for typing to settle; every other filter visits at once.
|
|
87383
88292
|
useQueryObject(filters, {
|
|
87384
|
-
only: [...RELOAD_PROPS],
|
|
88293
|
+
visitOptions: { only: [...RELOAD_PROPS] },
|
|
87385
88294
|
debounce: { search: 300 },
|
|
87386
88295
|
});
|
|
87387
88296
|
|
|
@@ -92554,7 +93463,7 @@ export function provideUploadCenter(): UploadCenterContext {
|
|
|
92554
93463
|
});
|
|
92555
93464
|
// The search waits for typing to settle; every other filter visits at once.
|
|
92556
93465
|
useQueryObject(filters, {
|
|
92557
|
-
only: [...RELOAD_PROPS],
|
|
93466
|
+
visitOptions: { only: [...RELOAD_PROPS] },
|
|
92558
93467
|
debounce: { search: 300 },
|
|
92559
93468
|
});
|
|
92560
93469
|
|