@lotics/app-sdk 0.22.0 → 0.24.0
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/src/hooks.js +63 -119
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/row.d.ts +21 -0
- package/dist/src/row.js +29 -0
- package/package.json +3 -2
package/dist/src/hooks.js
CHANGED
|
@@ -9,17 +9,16 @@
|
|
|
9
9
|
* a workflow attaches it, so file upload keeps that same property.
|
|
10
10
|
*
|
|
11
11
|
* Every hook is a thin wrapper over the postMessage RPC bridge — the parent
|
|
12
|
-
* does the actual API calls, results stream back through `rpc()`.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* does the actual API calls, results stream back through `rpc()`. `useQuery`
|
|
13
|
+
* caches through SWR keyed by (alias, params, pageSize): reads dedupe and the
|
|
14
|
+
* cache survives unmount/remount. Mutation / member hooks keep their own local
|
|
15
|
+
* `useState` — they have nothing to share.
|
|
15
16
|
*/
|
|
16
17
|
import { useCallback, useEffect, useState } from "react";
|
|
18
|
+
import useSWRInfinite from "swr/infinite";
|
|
17
19
|
import { rpc } from "./rpc.js";
|
|
18
20
|
import { getMockRows } from "./mock.js";
|
|
19
21
|
import { captureAppEvent } from "./analytics.js";
|
|
20
|
-
// A tab return can fire both `focus` and `visibilitychange` back-to-back;
|
|
21
|
-
// coalesce them into a single revalidation.
|
|
22
|
-
const FOCUS_REVALIDATE_THROTTLE_MS = 2000;
|
|
23
22
|
export function useWorkflow(alias) {
|
|
24
23
|
return useCallback(async (inputs) => {
|
|
25
24
|
try {
|
|
@@ -37,127 +36,72 @@ export function useQuery(alias, params, opts) {
|
|
|
37
36
|
const pageSize = opts?.pageSize;
|
|
38
37
|
const enabled = opts?.enabled ?? true;
|
|
39
38
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
});
|
|
56
|
-
const [loadingMore, setLoadingMore] = useState(false);
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
// Re-check on every effect run so HMR updates to the fixture propagate
|
|
59
|
-
// without a hard reload. Fixture short-circuits the RPC.
|
|
60
|
-
const mockRows = getMockRows(alias);
|
|
61
|
-
if (mockRows) {
|
|
62
|
-
setState({ rows: mockRows, isValidating: false, error: null, hasMore: false });
|
|
63
|
-
return;
|
|
39
|
+
// A registered fixture short-circuits the network in mock / dev mode.
|
|
40
|
+
const mockRows = getMockRows(alias);
|
|
41
|
+
// The cache key is (alias, params, pageSize, pageIndex), built HERE so an app
|
|
42
|
+
// never constructs a key — it just calls `useQuery("hoSo")`. SWR canonicalizes
|
|
43
|
+
// the params object via its stable hash, so every `useQuery` with the same
|
|
44
|
+
// (alias, params, pageSize) dedupes to one request and one shared cache entry,
|
|
45
|
+
// and that entry survives unmount/remount — navigating back to a screen shows
|
|
46
|
+
// cached rows instantly and revalidates in the background (no flash). A `null`
|
|
47
|
+
// key disables the fetch (mock / disabled / past the last page).
|
|
48
|
+
const getKey = (index, prev) => {
|
|
49
|
+
if (mockRows || !enabled)
|
|
50
|
+
return null;
|
|
51
|
+
// Non-paginated has only page 0; paginated stops once a short page returns.
|
|
52
|
+
if (index > 0 && (pageSize == null || prev == null || prev.rows.length < pageSize)) {
|
|
53
|
+
return null;
|
|
64
54
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// Keep prior rows in flight (stale-while-revalidate) — `loading` only shows
|
|
71
|
-
// when there's nothing to show yet, so refetches and keystrokes don't blank.
|
|
72
|
-
setState((s) => ({ ...s, isValidating: true, error: null }));
|
|
73
|
-
rpc("query", {
|
|
55
|
+
return ["app-query", alias, params ?? {}, pageSize ?? null, index];
|
|
56
|
+
};
|
|
57
|
+
const swr = useSWRInfinite(getKey, (key) => {
|
|
58
|
+
const index = Number(key[4]);
|
|
59
|
+
return rpc("query", {
|
|
74
60
|
alias,
|
|
75
61
|
params: params ?? {},
|
|
76
62
|
limit: pageSize,
|
|
77
|
-
offset: 0,
|
|
78
|
-
})
|
|
79
|
-
.then((result) => {
|
|
80
|
-
if (cancelled)
|
|
81
|
-
return;
|
|
82
|
-
const rows = result.rows ?? [];
|
|
83
|
-
setState({
|
|
84
|
-
rows,
|
|
85
|
-
isValidating: false,
|
|
86
|
-
error: null,
|
|
87
|
-
hasMore: pageSize != null && rows.length === pageSize,
|
|
88
|
-
});
|
|
89
|
-
})
|
|
90
|
-
.catch((err) => {
|
|
91
|
-
if (cancelled)
|
|
92
|
-
return;
|
|
93
|
-
// Keep the last good rows on a failed revalidation; surface the error.
|
|
94
|
-
setState((s) => ({ ...s, isValidating: false, error: err.message }));
|
|
63
|
+
offset: pageSize != null ? index * pageSize : 0,
|
|
95
64
|
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
65
|
+
}, {
|
|
66
|
+
// loadMore appends pages — don't re-fetch earlier pages when `size` grows.
|
|
67
|
+
revalidateFirstPage: false,
|
|
68
|
+
// The opt-out covers focus AND reconnect, per the documented contract.
|
|
69
|
+
revalidateOnFocus,
|
|
70
|
+
revalidateOnReconnect: revalidateOnFocus,
|
|
71
|
+
// Surface a failed query immediately and keep the last good rows — no
|
|
72
|
+
// silent retry loop that would mask the error from the app.
|
|
73
|
+
shouldRetryOnError: false,
|
|
74
|
+
});
|
|
75
|
+
// SWRInfinite leaves an in-flight page slot `undefined` until it resolves
|
|
76
|
+
// (the typed `QueryPage[]` understates this) — operate on resolved pages only,
|
|
77
|
+
// so a page being appended never crashes the flatten or skews the counts.
|
|
78
|
+
const pages = (swr.data ?? []).filter((p) => p != null);
|
|
79
|
+
const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
|
|
80
|
+
const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
|
|
81
|
+
const hasMore = pageSize != null && lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
|
|
82
|
+
// Appending a page: more pages are requested than have resolved.
|
|
83
|
+
const loadingMore = swr.isValidating && swr.size > pages.length;
|
|
101
84
|
const refetch = useCallback(() => {
|
|
102
|
-
|
|
103
|
-
}, []);
|
|
104
|
-
// Revalidate on focus / reconnect. An app can't hold a realtime socket, so
|
|
105
|
-
// freshness comes from re-running the query (first page) when the user
|
|
106
|
-
// returns to it or the network comes back — plus the explicit `refetch()`
|
|
107
|
-
// an app calls after a `useWorkflow()` mutation. Throttled so a tab return
|
|
108
|
-
// that fires both `focus` and `visibilitychange` only refetches once.
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (!revalidateOnFocus)
|
|
111
|
-
return;
|
|
112
|
-
let last = 0;
|
|
113
|
-
const revalidate = () => {
|
|
114
|
-
if (document.visibilityState === "hidden")
|
|
115
|
-
return;
|
|
116
|
-
const now = performance.now();
|
|
117
|
-
if (now - last < FOCUS_REVALIDATE_THROTTLE_MS)
|
|
118
|
-
return;
|
|
119
|
-
last = now;
|
|
120
|
-
setRefetchToken((n) => n + 1);
|
|
121
|
-
};
|
|
122
|
-
window.addEventListener("focus", revalidate);
|
|
123
|
-
window.addEventListener("online", revalidate);
|
|
124
|
-
document.addEventListener("visibilitychange", revalidate);
|
|
125
|
-
return () => {
|
|
126
|
-
window.removeEventListener("focus", revalidate);
|
|
127
|
-
window.removeEventListener("online", revalidate);
|
|
128
|
-
document.removeEventListener("visibilitychange", revalidate);
|
|
129
|
-
};
|
|
130
|
-
}, [revalidateOnFocus]);
|
|
85
|
+
void swr.mutate();
|
|
86
|
+
}, [swr]);
|
|
131
87
|
const loadMore = useCallback(() => {
|
|
132
|
-
if (pageSize == null ||
|
|
88
|
+
if (pageSize == null || !hasMore || loadingMore)
|
|
133
89
|
return;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.catch((err) => {
|
|
150
|
-
// Keep the rows we have — a failed page-append shouldn't blank the
|
|
151
|
-
// list — but surface the error instead of swallowing it. `hasMore`
|
|
152
|
-
// stays true, so the app can retry `loadMore()`.
|
|
153
|
-
setState((cur) => ({ ...cur, error: err.message }));
|
|
154
|
-
})
|
|
155
|
-
.finally(() => setLoadingMore(false));
|
|
156
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
157
|
-
}, [alias, paramsKey, pageSize, loadingMore, state.isValidating, state.hasMore, state.rows.length]);
|
|
158
|
-
// `loading` = initial load only (validating with nothing to show yet).
|
|
159
|
-
const loading = state.isValidating && state.rows.length === 0;
|
|
160
|
-
return { ...state, loading, refetch, loadMore, loadingMore };
|
|
90
|
+
void swr.setSize((n) => n + 1);
|
|
91
|
+
}, [pageSize, hasMore, loadingMore, swr]);
|
|
92
|
+
return {
|
|
93
|
+
rows,
|
|
94
|
+
// `loading` = initial load (validating with nothing to show yet);
|
|
95
|
+
// `isValidating` = any request in flight. Gating a skeleton on `loading`
|
|
96
|
+
// never flashes on revalidation; use `isValidating` for a subtle indicator.
|
|
97
|
+
loading: mockRows ? false : swr.isLoading,
|
|
98
|
+
isValidating: mockRows ? false : swr.isValidating,
|
|
99
|
+
error: swr.error ? swr.error.message : null,
|
|
100
|
+
refetch,
|
|
101
|
+
loadMore,
|
|
102
|
+
hasMore,
|
|
103
|
+
loadingMore,
|
|
104
|
+
};
|
|
161
105
|
}
|
|
162
106
|
/**
|
|
163
107
|
* Upload files from an app. The bytes are stored via a presigned
|
package/dist/src/index.d.ts
CHANGED
|
@@ -27,8 +27,8 @@ export { readSelect } from "./select.js";
|
|
|
27
27
|
export type { ResolvedOption } from "./select.js";
|
|
28
28
|
export type { AppFixture } from "./mock.js";
|
|
29
29
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
30
|
-
export { row, readLinks } from "./row.js";
|
|
31
|
-
export type { ResolvedLink } from "./row.js";
|
|
30
|
+
export { row, readLinks, readFiles } from "./row.js";
|
|
31
|
+
export type { ResolvedLink, AppFile } from "./row.js";
|
|
32
32
|
export { useOptimistic } from "./use_optimistic.js";
|
|
33
33
|
export type { OptimisticApi } from "./use_optimistic.js";
|
|
34
34
|
export { useRecents } from "./use_recents.js";
|
package/dist/src/index.js
CHANGED
|
@@ -20,6 +20,6 @@ export { rpc } from "./rpc.js";
|
|
|
20
20
|
export { openExternal } from "./open_external.js";
|
|
21
21
|
export { readMembers } from "./members.js";
|
|
22
22
|
export { readSelect } from "./select.js";
|
|
23
|
-
export { row, readLinks } from "./row.js";
|
|
23
|
+
export { row, readLinks, readFiles } from "./row.js";
|
|
24
24
|
export { useOptimistic } from "./use_optimistic.js";
|
|
25
25
|
export { useRecents } from "./use_recents.js";
|
package/dist/src/row.d.ts
CHANGED
|
@@ -44,6 +44,27 @@ export interface ResolvedLink {
|
|
|
44
44
|
declare function link(v: unknown): ResolvedLink | null;
|
|
45
45
|
/** select_record_link → ALL linked records as `{ id, display }[]` (empty if none). */
|
|
46
46
|
export declare function readLinks(v: unknown): ResolvedLink[];
|
|
47
|
+
/**
|
|
48
|
+
* A `files`-field cell entry, as the app query serializes it. The server
|
|
49
|
+
* presigns each file (24h) so `url`/`thumbnail_url` load directly — including
|
|
50
|
+
* from the sandboxed app iframe and for public apps — so the app can preview or
|
|
51
|
+
* download without deriving its own URL.
|
|
52
|
+
*/
|
|
53
|
+
export interface AppFile {
|
|
54
|
+
id: string;
|
|
55
|
+
filename: string;
|
|
56
|
+
mime_type: string;
|
|
57
|
+
/** Presigned serving URL — render in an <Image>/preview or pass to openExternal. */
|
|
58
|
+
url: string;
|
|
59
|
+
/** Presigned thumbnail URL for images, when the server produced one. */
|
|
60
|
+
thumbnail_url?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* files field → the attached files with their presigned `url` (empty if none).
|
|
64
|
+
* Skips entries the server didn't presign (no `url`) so a consumer never renders
|
|
65
|
+
* an unservable file. Map to `@lotics/ui` `DisplayFile` for FileThumbnail/Gallery.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readFiles(v: unknown): AppFile[];
|
|
47
68
|
export declare const row: {
|
|
48
69
|
opt: typeof opt;
|
|
49
70
|
text: typeof text;
|
package/dist/src/row.js
CHANGED
|
@@ -89,4 +89,33 @@ export function readLinks(v) {
|
|
|
89
89
|
}
|
|
90
90
|
return v.map(asLink).filter((x) => x !== null);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* files field → the attached files with their presigned `url` (empty if none).
|
|
94
|
+
* Skips entries the server didn't presign (no `url`) so a consumer never renders
|
|
95
|
+
* an unservable file. Map to `@lotics/ui` `DisplayFile` for FileThumbnail/Gallery.
|
|
96
|
+
*/
|
|
97
|
+
export function readFiles(v) {
|
|
98
|
+
if (!Array.isArray(v))
|
|
99
|
+
return [];
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const f of v) {
|
|
102
|
+
if (!f || typeof f !== "object")
|
|
103
|
+
continue;
|
|
104
|
+
const id = f.id;
|
|
105
|
+
const url = f.url;
|
|
106
|
+
if (typeof id !== "string" || !id || typeof url !== "string" || !url)
|
|
107
|
+
continue;
|
|
108
|
+
const filename = f.filename;
|
|
109
|
+
const mime = f.mime_type;
|
|
110
|
+
const thumb = f.thumbnail_url;
|
|
111
|
+
out.push({
|
|
112
|
+
id,
|
|
113
|
+
filename: typeof filename === "string" ? filename : "",
|
|
114
|
+
mime_type: typeof mime === "string" ? mime : "",
|
|
115
|
+
url,
|
|
116
|
+
thumbnail_url: typeof thumb === "string" ? thumb : undefined,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
92
121
|
export const row = { opt, text, num, bool, date, link };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"prepublishOnly": "npm run build"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"posthog-js": "^1.352.0"
|
|
21
|
+
"posthog-js": "^1.352.0",
|
|
22
|
+
"swr": "^2.4.1"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
25
|
"react": "^19.2.0",
|