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