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,4 +1,5 @@
1
1
  // inertia-helpers/useConfirmOnRouteLeave.ts
2
+ import { useHelperMessages } from './messages';
2
3
  import { router } from '@inertiajs/vue3';
3
4
  import { useConfirm, type BbConfirmMessage } from 'bitboss-ui';
4
5
  import { onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue';
@@ -12,7 +13,11 @@ type Visit = {
12
13
  };
13
14
 
14
15
  type Options = {
15
- /** URLs that bypass the dialog (e.g. the form's own submit target). */
16
+ /**
17
+ * Submit targets that bypass the dialog (the form's own save). Only a
18
+ * submit (POST / PUT / PATCH / DELETE) passes: a link to the same path
19
+ * (Cancel → the list) still asks.
20
+ */
16
21
  whitelist?: MaybeRefOrGetter<string[]>;
17
22
  /** Disable the guard (bind to `!hasChanged` / `!form.isDirty`). */
18
23
  disabled?: MaybeRefOrGetter<boolean>;
@@ -24,12 +29,14 @@ type Options = {
24
29
  text?: string;
25
30
  /**
26
31
  * The dialog's buttons and look, BbConfirm's own options: a label string
27
- * or a full button config. Defaults: `'Discard changes'` / `'Keep editing'`,
28
- * `variant: 'destructive'`. Pass translated labels in a non-English app.
32
+ * or a full button config. Default words: `leave` in `messages.ts`, in the
33
+ * library's `locale`; `variant: 'destructive'`.
29
34
  */
30
35
  yes?: BbConfirmMessage['yes'];
31
36
  no?: BbConfirmMessage['no'];
32
37
  variant?: BbConfirmMessage['variant'];
38
+ /** The `BbDialog` stack the dialog joins (BbConfirm's `stack`). */
39
+ stack?: BbConfirmMessage['stack'];
33
40
  };
34
41
 
35
42
  const normalizeUrl = (value: string | URL) => {
@@ -56,24 +63,26 @@ export const useConfirmOnRouteLeave = ({
56
63
  disabled = false,
57
64
  onBefore = () => true,
58
65
  onLeave = () => true,
59
- title = 'Unsaved changes',
60
- text = 'You have unsaved changes. Leave without saving?',
61
- yes = 'Discard changes',
62
- no = 'Keep editing',
66
+ title,
67
+ text,
68
+ yes,
69
+ no,
63
70
  variant = 'destructive',
71
+ stack,
64
72
  }: Options = {}) => {
65
73
  // Captured in setup so the built-in dialog is owned by the guarded page:
66
74
  // if the page unmounts anyway, the dialog dismisses itself quietly.
67
75
  const { confirm } = useConfirm();
76
+ const messages = useHelperMessages();
68
77
  let bypassNextVisit = false;
69
78
 
70
79
  const unbind = router.on('before', (event) => {
71
80
  const visit = event.detail.visit as Visit;
72
81
 
73
82
  // A hover / mount prefetch (`<Link prefetch>`, `<BbButton href prefetch>`)
74
- // fires `before` too, but leaves nothing: no dialog, and it must not use up
75
- // the bypass the re-sent visit below is waiting for.
76
- if (visit.prefetch) return;
83
+ // fires `before` too, but leaves nothing: no dialog. A visit another
84
+ // guard on the page already stopped is that guard's to ask about.
85
+ if (visit.prefetch || event.defaultPrevented) return;
77
86
 
78
87
  if (bypassNextVisit) {
79
88
  bypassNextVisit = false;
@@ -85,32 +94,123 @@ export const useConfirmOnRouteLeave = ({
85
94
 
86
95
  const nextPath = normalizeUrl(visit.url.href);
87
96
  if (nextPath === normalizeUrl(window.location.href)) return;
88
- if (toValue(whitelist).map(normalizeUrl).includes(nextPath)) return;
97
+ if (
98
+ visit.method !== 'get' &&
99
+ toValue(whitelist).map(normalizeUrl).includes(nextPath)
100
+ ) {
101
+ // Marked: a save answered with a 409 redirect (a redirect with a
102
+ // #fragment) is re-sent by Inertia with these same headers, and that
103
+ // follow-up must not ask either.
104
+ visit.headers = { ...visit.headers, [WHITELIST_HEADER]: '1' };
105
+ return;
106
+ }
89
107
 
90
108
  // `return`, not `return false`: an Inertia `before` listener that returns
91
109
  // false CANCELS the visit, which is the opposite of what `onBefore`
92
110
  // promises (false = let this visit through, no dialog).
93
111
  if (onBefore(visit) === false) return;
94
112
  event.preventDefault();
113
+ // Held, not refused: the dialog decides. `usePromiseForm` reads this
114
+ // flag and waits instead of answering `false` at once.
115
+ visit.awaitingConfirm = true;
95
116
 
96
117
  void (async () => {
97
- const confirmed = await confirm({ variant, title, text, yes, no });
98
- if (!confirmed) return;
118
+ const words = messages.value.leave;
119
+ const confirmed = await confirm({
120
+ title: title ?? words.title,
121
+ text: text ?? words.text,
122
+ yes: yes ?? words.yes,
123
+ no: no ?? words.no,
124
+ variant,
125
+ ...(stack && { stack }),
126
+ });
127
+ if (!confirmed) {
128
+ // "Keep editing" ends the held visit the way Inertia ends a
129
+ // cancelled one, so its own callbacks (a form's processing state,
130
+ // a pending promise) settle.
131
+ const callbacks = visit as {
132
+ onCancel?: () => void;
133
+ onFinish?: (visit: Visit) => void;
134
+ };
135
+ callbacks.onCancel?.();
136
+ callbacks.onFinish?.({ ...visit, cancelled: true });
137
+ return;
138
+ }
99
139
 
100
140
  await onLeave(visit);
141
+ // Re-sent as a plain visit: never as a prefetch or a background one,
142
+ // and without the visit's own onBefore (its `confirm('Sure?')` was
143
+ // already answered).
144
+ const {
145
+ prefetch: _prefetch,
146
+ async: _async,
147
+ onBefore: _onBefore,
148
+ awaitingConfirm: _awaitingConfirm,
149
+ ...rest
150
+ } = visit;
151
+ // `before` fires inside router.visit(): the bypass covers exactly
152
+ // that visit, and is never left armed for the next one (a re-send
153
+ // its own callbacks stop before `before`).
101
154
  bypassNextVisit = true;
102
- // Re-sent as a plain visit: never as a prefetch or a background one.
103
- const { prefetch: _prefetch, async: _async, ...rest } = visit;
104
155
  router.visit(visit.url, {
105
156
  ...(rest as Parameters<typeof router.visit>[1]),
106
157
  headers: { ...visit.headers, [WHITELIST_HEADER]: '1' },
107
158
  });
159
+ bypassNextVisit = false;
108
160
  })();
109
161
  });
110
162
 
163
+ // Leaving the app (closing the tab, reloading, a typed URL, a plain `<a>`)
164
+ // never reaches `before`: the browser's own prompt covers it. Not for a
165
+ // visit this guard let through: a save answered with `Inertia::location()`
166
+ // is a real unload with the form still dirty. Inertia sets
167
+ // `window.location` inside that visit, and WebKit asks only after the visit
168
+ // finished, so a let-through visit that completed with no answer (no page,
169
+ // no error bag, no error response: Inertia left) keeps it quiet until the
170
+ // next visit starts.
171
+ const released = new Set<unknown>();
172
+ let answered = false;
173
+ let leaving = false;
174
+ const marked = (visit: { headers?: Record<string, string> }) =>
175
+ getHeaderValue(visit.headers, WHITELIST_HEADER) === '1';
176
+ const unbindStart = router.on('start', ({ detail: { visit } }) => {
177
+ leaving = false;
178
+ answered = false;
179
+ if (marked(visit)) released.add(visit);
180
+ });
181
+ // Every way a visit gets an answer; the last two pairs are Inertia 3's
182
+ // names and Inertia 2's (each fires only on its own version).
183
+ const onRouterEvent = (name: string, listener: () => void) =>
184
+ router.on(name as 'start', listener);
185
+ const unbindAnswers = [
186
+ 'success',
187
+ 'error',
188
+ 'httpException',
189
+ 'invalid',
190
+ 'networkError',
191
+ 'exception',
192
+ ].map((name) => onRouterEvent(name, () => void (answered = true)));
193
+ const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
194
+ released.delete(visit);
195
+ if (marked(visit) && visit.completed && !answered) leaving = true;
196
+ });
197
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
198
+ if (toValue(disabled) || released.size || leaving) return;
199
+ event.preventDefault();
200
+ event.returnValue = '';
201
+ };
202
+ const inBrowser = typeof window !== 'undefined';
203
+ if (inBrowser) window.addEventListener('beforeunload', onBeforeUnload);
204
+
111
205
  // `router.on` is a global bus that outlives this component: unbind it.
112
206
  // `onScopeDispose` over `onUnmounted`: it fires on unmount too (`setup()`
113
207
  // runs inside a component's own effect scope), but also works if this
114
208
  // composable is ever called from outside one.
115
- onScopeDispose(unbind);
209
+ onScopeDispose(() => {
210
+ unbind();
211
+ unbindStart();
212
+ unbindAnswers.forEach((unbindAnswer) => unbindAnswer());
213
+ unbindFinish();
214
+ if (inBrowser) window.removeEventListener('beforeunload', onBeforeUnload);
215
+ });
116
216
  };
@@ -1,36 +1,54 @@
1
1
  // inertia-helpers/useHasChanged.ts
2
- import { isEqual } from './utils';
2
+ import { isPlainObject } from './utils';
3
+ import { hash } from 'object-code';
3
4
  import { computed, ref, toValue, type WatchSource } from 'vue';
4
5
 
5
6
  export function useHasChanged<T extends WatchSource<unknown>[]>(
6
7
  dependencies: T
7
8
  ) {
8
9
  /*
9
- * Baselines are JSON round-trips, NOT `structuredClone`. This composable's
10
- * documented inputs are refs and reactive objects, and `structuredClone`
11
- * throws `DataCloneError` on every one of them — a Vue proxy is not a
12
- * structured-cloneable type. It took the page down at setup.
13
- *
14
- * The comparison needs no copy at all: `isEqual` is hash-based and reads
15
- * straight through a proxy, so the live value can be hashed as-is. Only the
16
- * BASELINE needs detaching from live state, which is what the round-trip is
17
- * for.
10
+ * The baseline is a structural HASH, not a copy. A copy has to survive the
11
+ * values a form holds: a JSON round-trip turns a Date into a string, a File,
12
+ * Set or Map into `{}`, drops `undefined` (and throws on a bare one), so
13
+ * an untouched form read as changed; `structuredClone` throws on Vue proxies.
14
+ * `object-code` hashes straight through a proxy and keeps Dates (by time),
15
+ * Sets / Maps (by entries), `undefined` and NaN apart, and a number is all
16
+ * the baseline needs to hold. It hashes every File alike (no own keys), so
17
+ * a File is read by name, size, type and date first: picking another file
18
+ * over a picked one counts as a change.
18
19
  */
19
20
  const snapshot = (dep: WatchSource<unknown>) =>
20
- JSON.parse(JSON.stringify(toValue(dep)));
21
+ hash(withFileFingerprints(toValue(dep)));
21
22
 
22
- const baselineValues = ref<unknown[]>(dependencies.map(snapshot));
23
+ const baselines = ref<number[]>(dependencies.map(snapshot));
23
24
 
24
25
  /** Re-baseline (call after a successful save). */
25
26
  const takeSnapshot = (): void => {
26
- baselineValues.value = dependencies.map(snapshot);
27
+ baselines.value = dependencies.map(snapshot);
27
28
  };
28
29
 
29
30
  const hasChanged = computed(() =>
30
- dependencies.some(
31
- (dep, index) => !isEqual(toValue(dep), baselineValues.value[index])
32
- )
31
+ dependencies.some((dep, index) => snapshot(dep) !== baselines.value[index])
33
32
  );
34
33
 
35
34
  return { hasChanged, takeSnapshot };
36
35
  }
36
+
37
+ const withFileFingerprints = (value: unknown): unknown =>
38
+ value instanceof Blob
39
+ ? [
40
+ value.size,
41
+ value.type,
42
+ (value as Partial<File>).name,
43
+ (value as Partial<File>).lastModified,
44
+ ]
45
+ : Array.isArray(value)
46
+ ? value.map(withFileFingerprints)
47
+ : isPlainObject(value)
48
+ ? Object.fromEntries(
49
+ Object.entries(value).map(([key, item]) => [
50
+ key,
51
+ withFileFingerprints(item),
52
+ ])
53
+ )
54
+ : value;
@@ -1,8 +1,16 @@
1
1
  // inertia-helpers/useLazyProp.ts
2
2
  import { isEqual, isNil, isNotNil } from './utils';
3
+ import type { PageProps } from '@inertiajs/core';
3
4
  import { router, usePage } from '@inertiajs/vue3';
4
5
  import type { Ref } from 'vue';
5
- import { customRef, onBeforeUnmount, readonly, ref, watch } from 'vue';
6
+ import {
7
+ customRef,
8
+ nextTick,
9
+ onBeforeUnmount,
10
+ readonly,
11
+ ref,
12
+ watch,
13
+ } from 'vue';
6
14
 
7
15
  export type UseLazyPropOptions = {
8
16
  /** Keep the last non-nil value across visits that reset the prop. Default true. */
@@ -34,7 +42,9 @@ export function useLazyProp<T>(
34
42
  initialValue?: T,
35
43
  options: UseLazyPropOptions = {}
36
44
  ): UseLazyProp<T | undefined> {
37
- const page = usePage<{ [key: string]: T | undefined }>();
45
+ // `PageProps &` keeps usePage's constraint satisfied in an app that
46
+ // augments Inertia's `PageProps` (TS2344 otherwise).
47
+ const page = usePage<PageProps & { [key: string]: T | undefined }>();
38
48
  const { cache = true } = options;
39
49
  const loading = ref(false);
40
50
  const loaded = ref(page.props[propName] !== undefined);
@@ -42,50 +52,107 @@ export function useLazyProp<T>(
42
52
  let inFlightPromise: Promise<void> | null = null;
43
53
  let isUnmounted = false;
44
54
  let cached = page.props[propName];
55
+ // Settles a reload a navigation cancelled, once that navigation is over.
56
+ let resumeCancelled: (() => void) | null = null;
45
57
 
46
58
  onBeforeUnmount(() => {
47
59
  isUnmounted = true;
60
+ resumeCancelled?.();
48
61
  });
49
62
 
50
63
  const loadProp = (onSuccess: () => void): Promise<void> => {
51
64
  if (inFlightPromise) return inFlightPromise;
52
65
 
53
66
  error.value = null;
67
+ let started = false;
68
+ let succeeded = false;
69
+ let settle!: () => void;
70
+ const promise = new Promise<void>((resolve) => {
71
+ settle = () => {
72
+ if (!isUnmounted) loading.value = false;
73
+ if (inFlightPromise === promise) inFlightPromise = null;
74
+ resolve();
75
+ };
76
+ });
77
+ inFlightPromise = promise;
54
78
 
55
- inFlightPromise = new Promise<void>((resolve) => {
56
- router.reload({
57
- only: [propName],
58
- replace: true,
59
- ...(options.headers ? { headers: options.headers } : {}),
60
- onStart: () => {
61
- loading.value = true;
62
- },
63
- onSuccess: () => {
64
- if (!isUnmounted) {
65
- loaded.value = true;
66
- cached = page.props[propName];
67
- onSuccess();
68
- }
69
- },
70
- onError: (errors) => {
71
- if (!isUnmounted) {
72
- error.value =
73
- errors instanceof Error
74
- ? errors
75
- : new Error(`Failed to load lazy prop "${propName}"`);
76
- }
77
- },
78
- onFinish: () => {
79
- if (!isUnmounted) {
80
- loading.value = false;
81
- }
82
- inFlightPromise = null;
83
- resolve();
84
- },
85
- });
79
+ router.reload({
80
+ only: [propName],
81
+ replace: true,
82
+ // Like Inertia's own deferred-props loader: keep the page's
83
+ // validation errors (Inertia 3; Inertia 2 has no such option).
84
+ ...({ preserveErrors: true } as object),
85
+ ...(options.headers ? { headers: options.headers } : {}),
86
+ onStart: () => {
87
+ started = true;
88
+ loading.value = true;
89
+ },
90
+ onSuccess: () => {
91
+ succeeded = true;
92
+ if (!isUnmounted) {
93
+ loaded.value = true;
94
+ cached = page.props[propName];
95
+ onSuccess();
96
+ }
97
+ },
98
+ onError: (errors) => {
99
+ if (!isUnmounted) {
100
+ error.value =
101
+ errors instanceof Error
102
+ ? errors
103
+ : new Error(`Failed to load lazy prop "${propName}"`);
104
+ }
105
+ },
106
+ onFinish: (visit) => {
107
+ // A visit to another page cancels this reload: no failure. Stay
108
+ // loading (a reader must not re-fire it at the outgoing URL) until
109
+ // that navigation is over, then retry if this page is still
110
+ // mounted (a same-component visit) or settle if it unmounted.
111
+ if (visit.cancelled) {
112
+ const cancelledOn = page.url;
113
+ const unbindFinish = router.on('finish', (event) => {
114
+ const next = event.detail.visit;
115
+ if (next.cancelled || next.interrupted) return;
116
+ // An async visit is over too (a mutation answered on another
117
+ // URL redirects back with no other visit), unless it is one
118
+ // of this page's own reloads (a poll, another lazy prop).
119
+ if (
120
+ next.async &&
121
+ next.url.href === new URL(cancelledOn, next.url).href
122
+ )
123
+ return;
124
+ resumeCancelled?.();
125
+ });
126
+ // Back / Forward swaps the page with no finish event.
127
+ const unbindNavigate = router.on('navigate', () =>
128
+ resumeCancelled?.()
129
+ );
130
+ resumeCancelled = () => {
131
+ resumeCancelled = null;
132
+ unbindFinish();
133
+ unbindNavigate();
134
+ void nextTick(() => {
135
+ inFlightPromise = null;
136
+ if (isUnmounted || loaded.value) settle();
137
+ else void loadProp(onSuccess).then(settle);
138
+ });
139
+ };
140
+ return;
141
+ }
142
+ // A network error or a non-Inertia response (Inertia's error
143
+ // dialog) reaches only onFinish: report it. The self-loading read
144
+ // does not retry a failed load; `execute()` does.
145
+ if (!succeeded && !error.value && !isUnmounted) {
146
+ error.value = new Error(`Failed to load lazy prop "${propName}"`);
147
+ }
148
+ settle();
149
+ },
86
150
  });
151
+ // Refused before it started (a `before` listener cancelled it, or no
152
+ // `window` during SSR): no callback will ever come.
153
+ if (!started) settle();
87
154
 
88
- return inFlightPromise;
155
+ return promise;
89
156
  };
90
157
 
91
158
  /** Self-loading ref: reading it while the prop is absent starts the fetch. */
@@ -94,8 +161,7 @@ export function useLazyProp<T>(
94
161
  track();
95
162
  const isMissing = page.props[propName] === undefined;
96
163
 
97
- if (isMissing && !loaded.value && !loading.value) {
98
- loading.value = true;
164
+ if (isMissing && !loaded.value && !loading.value && !error.value) {
99
165
  void loadProp(trigger);
100
166
  }
101
167
 
@@ -39,7 +39,9 @@ export function useLazyPropGetter<T, U = T>(
39
39
  if (!loaded.value) {
40
40
  await execute();
41
41
  }
42
- if (isNil(value.value)) {
42
+ // `loaded` first: reading `value` while unloaded would start a
43
+ // second, background reload.
44
+ if (!loaded.value || isNil(value.value)) {
43
45
  throw new Error(`Could not load lazy prop "${propName}"`);
44
46
  }
45
47
  if (isNil(formatter)) return value.value as unknown as U;