@lotics/app-sdk 0.25.0 → 0.27.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/comments.d.ts +71 -0
- package/dist/src/comments.js +169 -0
- package/dist/src/hooks.d.ts +83 -19
- package/dist/src/hooks.js +118 -43
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/rpc.d.ts +7 -1
- package/dist/src/rpc.js +19 -1
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A file attached to a comment, in the resolved (serving) shape the list returns. */
|
|
2
|
+
export interface AppCommentFile {
|
|
3
|
+
id: string;
|
|
4
|
+
/** Storage key — needed to re-send the file when editing a comment. */
|
|
5
|
+
file_storage_key: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
mime_type: string;
|
|
8
|
+
url?: string;
|
|
9
|
+
thumbnail_url?: string;
|
|
10
|
+
preview_url?: string;
|
|
11
|
+
}
|
|
12
|
+
/** One record comment, as returned by the list op. */
|
|
13
|
+
export interface AppComment {
|
|
14
|
+
id: string;
|
|
15
|
+
record_id: string;
|
|
16
|
+
table_id: string;
|
|
17
|
+
/** The author's member id. */
|
|
18
|
+
member_id: string;
|
|
19
|
+
content: string;
|
|
20
|
+
files: AppCommentFile[] | null;
|
|
21
|
+
workspace_id: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
}
|
|
25
|
+
export interface CommentsState {
|
|
26
|
+
/** Comments on the record, oldest first. Empty while loading or unavailable. */
|
|
27
|
+
comments: AppComment[];
|
|
28
|
+
/** True on the initial load only — false during background revalidation. */
|
|
29
|
+
loading: boolean;
|
|
30
|
+
error: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* True only when a member is signed in AND the app declared the `comments`
|
|
33
|
+
* capability in its manifest. False in a standalone / public (anonymous) app,
|
|
34
|
+
* and for any app that didn't opt in. Gate the composer on this; mutations
|
|
35
|
+
* reject (and the backend re-checks the capability) when false.
|
|
36
|
+
*/
|
|
37
|
+
available: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Post a new comment as the viewing member. `file_ids` are ids from
|
|
40
|
+
* `useFileUpload().upload()`. No-op for empty content with no files.
|
|
41
|
+
*/
|
|
42
|
+
createComment: (input: {
|
|
43
|
+
content: string;
|
|
44
|
+
file_ids?: string[];
|
|
45
|
+
}) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Edit a comment (author-only, enforced server-side). Pass `files` to replace
|
|
48
|
+
* the attachment set; omit it to keep the comment's current files — a
|
|
49
|
+
* text-only edit never silently drops attachments.
|
|
50
|
+
*/
|
|
51
|
+
updateComment: (id: string, input: {
|
|
52
|
+
content: string;
|
|
53
|
+
files?: AppCommentFile[];
|
|
54
|
+
}) => Promise<void>;
|
|
55
|
+
/** Delete a comment (author-only, enforced server-side). */
|
|
56
|
+
deleteComment: (id: string) => Promise<void>;
|
|
57
|
+
/** Re-pull the thread from the server (e.g. after an external change). */
|
|
58
|
+
refetch: () => void;
|
|
59
|
+
}
|
|
60
|
+
export interface UseCommentsArgs {
|
|
61
|
+
/** The record whose comments to read and write. */
|
|
62
|
+
record_id: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* ```tsx
|
|
66
|
+
* const { comments, available, createComment } = useComments({
|
|
67
|
+
* record_id: row.__source_record_id,
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare function useComments(args: UseCommentsArgs): CommentsState;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useComments` — read and write the comments on a record from a custom-code app.
|
|
3
|
+
*
|
|
4
|
+
* Comments are a *members-only* surface and behave differently from `useQuery`
|
|
5
|
+
* / `useWorkflow`. Those run under the app OWNER's authority (the app is the
|
|
6
|
+
* capability), which is what lets a public app expose owner-curated data to an
|
|
7
|
+
* anonymous visitor. Comments are member-to-member discussion tied to a real
|
|
8
|
+
* author identity, so they run under the VIEWING MEMBER's own authority instead:
|
|
9
|
+
*
|
|
10
|
+
* - read / write authorized against the viewer's own table access + row-scope
|
|
11
|
+
* - the comment's author is the viewer (correct attribution)
|
|
12
|
+
* - edit / delete are author-only, checked against the viewer
|
|
13
|
+
*
|
|
14
|
+
* The bridged host (`app_iframe_host`) holds the member session and proxies each
|
|
15
|
+
* op to the member's `/v1/records/.../comments` endpoints. A standalone (public)
|
|
16
|
+
* app has no signed-in member: `available` is false, the list stays empty, and a
|
|
17
|
+
* mutation rejects. Check `available` before rendering a composer.
|
|
18
|
+
*
|
|
19
|
+
* Freshness mirrors `useQuery`: SWR-cached, revalidate on focus / reconnect, and
|
|
20
|
+
* an explicit refetch after every mutation. There is no realtime push to apps,
|
|
21
|
+
* so a thread updated by another viewer appears on the next focus / refetch.
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback } from "react";
|
|
24
|
+
import useSWR from "swr";
|
|
25
|
+
import { rpc } from "./rpc.js";
|
|
26
|
+
import { captureAppEvent } from "./analytics.js";
|
|
27
|
+
/** The reduced storage shape the update endpoint accepts for `files`. */
|
|
28
|
+
function toStorageFiles(files) {
|
|
29
|
+
return (files ?? []).map((f) => ({
|
|
30
|
+
id: f.id,
|
|
31
|
+
file_storage_key: f.file_storage_key,
|
|
32
|
+
filename: f.filename,
|
|
33
|
+
mime_type: f.mime_type,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the app's context once, shared across every hook via a stable SWR key.
|
|
38
|
+
* Comments need a signed-in member (members-only) AND the app's declared
|
|
39
|
+
* `comments` capability — both come from here.
|
|
40
|
+
*/
|
|
41
|
+
function useAppContext() {
|
|
42
|
+
const { data } = useSWR("app-context", () => rpc("context", {}), {
|
|
43
|
+
revalidateOnFocus: false,
|
|
44
|
+
revalidateIfStale: false,
|
|
45
|
+
revalidateOnReconnect: false,
|
|
46
|
+
shouldRetryOnError: false,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
memberId: data?.member_id ?? null,
|
|
50
|
+
commentsEnabled: data?.comments_enabled ?? false,
|
|
51
|
+
resolved: data !== undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
let optimisticCounter = 0;
|
|
55
|
+
/**
|
|
56
|
+
* ```tsx
|
|
57
|
+
* const { comments, available, createComment } = useComments({
|
|
58
|
+
* record_id: row.__source_record_id,
|
|
59
|
+
* });
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function useComments(args) {
|
|
63
|
+
const { record_id } = args;
|
|
64
|
+
const { memberId, commentsEnabled, resolved } = useAppContext();
|
|
65
|
+
const available = memberId != null && commentsEnabled;
|
|
66
|
+
const swr = useSWR(available ? ["app-comments", record_id] : null, () => rpc("comments.list", { record_id }), {
|
|
67
|
+
shouldRetryOnError: false,
|
|
68
|
+
});
|
|
69
|
+
const comments = swr.data?.comments ?? [];
|
|
70
|
+
const refetch = useCallback(() => {
|
|
71
|
+
void swr.mutate();
|
|
72
|
+
}, [swr]);
|
|
73
|
+
const createComment = useCallback(async (input) => {
|
|
74
|
+
if (!available || memberId == null) {
|
|
75
|
+
throw new Error("Comments are not available in this app.");
|
|
76
|
+
}
|
|
77
|
+
const content = input.content.trim();
|
|
78
|
+
const fileIds = input.file_ids ?? [];
|
|
79
|
+
if (!content && fileIds.length === 0)
|
|
80
|
+
return;
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
const optimistic = {
|
|
83
|
+
id: `cmt_optimistic_${Date.now()}_${optimisticCounter++}`,
|
|
84
|
+
record_id,
|
|
85
|
+
// table_id / workspace_id resolve on the refetch (server-owned); the
|
|
86
|
+
// optimistic row only needs to render until then.
|
|
87
|
+
table_id: "",
|
|
88
|
+
member_id: memberId,
|
|
89
|
+
content,
|
|
90
|
+
// The real attachments resolve on the refetch; show none meanwhile.
|
|
91
|
+
files: null,
|
|
92
|
+
workspace_id: "",
|
|
93
|
+
created_at: now,
|
|
94
|
+
updated_at: now,
|
|
95
|
+
};
|
|
96
|
+
await swr.mutate(async () => {
|
|
97
|
+
await rpc("comments.create", {
|
|
98
|
+
record_id,
|
|
99
|
+
content,
|
|
100
|
+
file_ids: fileIds.length > 0 ? fileIds : undefined,
|
|
101
|
+
});
|
|
102
|
+
return rpc("comments.list", { record_id });
|
|
103
|
+
}, {
|
|
104
|
+
optimisticData: (current) => ({
|
|
105
|
+
comments: [...(current?.comments ?? []), optimistic],
|
|
106
|
+
}),
|
|
107
|
+
rollbackOnError: true,
|
|
108
|
+
revalidate: false,
|
|
109
|
+
populateCache: true,
|
|
110
|
+
});
|
|
111
|
+
captureAppEvent("app_comment_created", { has_files: fileIds.length > 0 });
|
|
112
|
+
}, [available, memberId, record_id, swr]);
|
|
113
|
+
const updateComment = useCallback(async (id, input) => {
|
|
114
|
+
if (!available)
|
|
115
|
+
throw new Error("Comments are not available in this app.");
|
|
116
|
+
const content = input.content.trim();
|
|
117
|
+
// Omitted files → keep the comment's current attachments (never drop them
|
|
118
|
+
// on a text-only edit). The update endpoint replaces the whole set.
|
|
119
|
+
const currentFiles = input.files !== undefined
|
|
120
|
+
? input.files
|
|
121
|
+
: (swr.data?.comments.find((c) => c.id === id)?.files ?? null);
|
|
122
|
+
const now = new Date().toISOString();
|
|
123
|
+
await swr.mutate(async () => {
|
|
124
|
+
await rpc("comments.update", {
|
|
125
|
+
record_id,
|
|
126
|
+
comment_id: id,
|
|
127
|
+
content,
|
|
128
|
+
files: toStorageFiles(currentFiles),
|
|
129
|
+
});
|
|
130
|
+
return rpc("comments.list", { record_id });
|
|
131
|
+
}, {
|
|
132
|
+
optimisticData: (current) => ({
|
|
133
|
+
comments: (current?.comments ?? []).map((c) => c.id === id ? { ...c, content, files: currentFiles, updated_at: now } : c),
|
|
134
|
+
}),
|
|
135
|
+
rollbackOnError: true,
|
|
136
|
+
revalidate: false,
|
|
137
|
+
populateCache: true,
|
|
138
|
+
});
|
|
139
|
+
captureAppEvent("app_comment_updated", {});
|
|
140
|
+
}, [available, record_id, swr]);
|
|
141
|
+
const deleteComment = useCallback(async (id) => {
|
|
142
|
+
if (!available)
|
|
143
|
+
throw new Error("Comments are not available in this app.");
|
|
144
|
+
await swr.mutate(async () => {
|
|
145
|
+
await rpc("comments.delete", { record_id, comment_id: id });
|
|
146
|
+
return rpc("comments.list", { record_id });
|
|
147
|
+
}, {
|
|
148
|
+
optimisticData: (current) => ({
|
|
149
|
+
comments: (current?.comments ?? []).filter((c) => c.id !== id),
|
|
150
|
+
}),
|
|
151
|
+
rollbackOnError: true,
|
|
152
|
+
revalidate: false,
|
|
153
|
+
populateCache: true,
|
|
154
|
+
});
|
|
155
|
+
captureAppEvent("app_comment_deleted", {});
|
|
156
|
+
}, [available, record_id, swr]);
|
|
157
|
+
return {
|
|
158
|
+
comments,
|
|
159
|
+
// Unavailable resolves instantly (no fetch); available shows the initial
|
|
160
|
+
// load until the list lands. Waiting on context counts as loading.
|
|
161
|
+
loading: available ? swr.isLoading : !resolved,
|
|
162
|
+
error: swr.error ? swr.error.message : null,
|
|
163
|
+
available,
|
|
164
|
+
createComment,
|
|
165
|
+
updateComment,
|
|
166
|
+
deleteComment,
|
|
167
|
+
refetch,
|
|
168
|
+
};
|
|
169
|
+
}
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AppWorkflows, AppQueries } from "./types.js";
|
|
2
2
|
import type { ResolvedMember } from "./members.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** Fields shared by every query hook's return value. */
|
|
4
|
+
interface QueryStateBase {
|
|
5
5
|
/**
|
|
6
6
|
* True only on the initial load — a request is in flight and there are no
|
|
7
7
|
* rows yet. Stays false during background revalidation and while typing a new
|
|
@@ -13,14 +13,22 @@ interface QueryState<R> {
|
|
|
13
13
|
isValidating: boolean;
|
|
14
14
|
error: string | null;
|
|
15
15
|
/**
|
|
16
|
-
* Re-run the query
|
|
17
|
-
*
|
|
16
|
+
* Re-run the query. Use after a known mutation point — a successful
|
|
17
|
+
* `useWorkflow(alias)()` call — to pull the latest state.
|
|
18
18
|
*/
|
|
19
19
|
refetch: () => void;
|
|
20
|
+
}
|
|
21
|
+
/** Return value of `useQuery` — a single fetch, no pagination. */
|
|
22
|
+
interface QueryState<R> extends QueryStateBase {
|
|
23
|
+
rows: R[];
|
|
24
|
+
}
|
|
25
|
+
/** Return value of `useInfiniteQuery` — append/load-more. */
|
|
26
|
+
interface InfiniteQueryState<R> extends QueryStateBase {
|
|
27
|
+
/** All loaded pages, flattened and accumulated. */
|
|
28
|
+
rows: R[];
|
|
20
29
|
/**
|
|
21
|
-
* Fetch the next page and append it to `rows`. No-op when
|
|
22
|
-
*
|
|
23
|
-
* while it runs.
|
|
30
|
+
* Fetch the next page and append it to `rows`. No-op when there are no more
|
|
31
|
+
* rows (`hasMore` is false). `loadingMore` is true while it runs.
|
|
24
32
|
*/
|
|
25
33
|
loadMore: () => void;
|
|
26
34
|
/** True when the last page came back full, so more rows may exist. */
|
|
@@ -28,6 +36,23 @@ interface QueryState<R> {
|
|
|
28
36
|
/** True while a `loadMore` request is in flight. */
|
|
29
37
|
loadingMore: boolean;
|
|
30
38
|
}
|
|
39
|
+
/** Return value of `usePaginatedQuery` — page-model with a total. */
|
|
40
|
+
interface PaginatedQueryState<R> extends QueryStateBase {
|
|
41
|
+
/** Rows of the current page only (≤ `pageSize`). */
|
|
42
|
+
rows: R[];
|
|
43
|
+
/** Total rows in the filtered set (across all pages). `undefined` until the
|
|
44
|
+
* count resolves. */
|
|
45
|
+
total: number | undefined;
|
|
46
|
+
/** `ceil(total / pageSize)`, or `undefined` until the count resolves. */
|
|
47
|
+
totalPages: number | undefined;
|
|
48
|
+
/** Current 0-indexed page. */
|
|
49
|
+
page: number;
|
|
50
|
+
pageSize: number;
|
|
51
|
+
/** True when a next page exists. */
|
|
52
|
+
hasMore: boolean;
|
|
53
|
+
/** Jump to a page (0-indexed). Clamped at 0. */
|
|
54
|
+
setPage: (page: number) => void;
|
|
55
|
+
}
|
|
31
56
|
/**
|
|
32
57
|
* One sort key — the wire shape of the query RPC's `sort`. The server applies
|
|
33
58
|
* these AFTER the named query, bounded to the query's output columns (an
|
|
@@ -55,17 +80,11 @@ export interface QueryFilterGroup {
|
|
|
55
80
|
/**
|
|
56
81
|
* Runtime filter applied AFTER the named query, bounded to its output columns
|
|
57
82
|
* (same exposure invariant as `sort`). Build a group from per-column filters
|
|
58
|
-
* with `columnFilterToConditions` (`@lotics/ui/
|
|
83
|
+
* with `columnFilterToConditions` (`@lotics/ui/column_filter`).
|
|
59
84
|
*/
|
|
60
85
|
export type QueryFilter = QueryFilterCondition | QueryFilterGroup;
|
|
61
|
-
/** Options
|
|
62
|
-
export interface
|
|
63
|
-
/**
|
|
64
|
-
* Page size. Omit to fetch all rows in one request (the server still caps
|
|
65
|
-
* the maximum). Set it to paginate: the first render loads one page, and
|
|
66
|
-
* `loadMore()` appends the next.
|
|
67
|
-
*/
|
|
68
|
-
pageSize?: number;
|
|
86
|
+
/** Options shared by every query hook. */
|
|
87
|
+
export interface BaseQueryOptions {
|
|
69
88
|
/**
|
|
70
89
|
* When `false`, the query does not run: `rows` stays empty, `loading` is
|
|
71
90
|
* false, and no request is sent. Flip it back to `true` to fetch. This is the
|
|
@@ -94,6 +113,22 @@ export interface QueryOptions {
|
|
|
94
113
|
*/
|
|
95
114
|
filter?: QueryFilter;
|
|
96
115
|
}
|
|
116
|
+
/** Options for `useQuery` — a single fetch. */
|
|
117
|
+
export interface QueryOptions extends BaseQueryOptions {
|
|
118
|
+
/** Max rows to fetch in the one request (a cap, not pagination). The server
|
|
119
|
+
* still clamps to its own maximum. Omit to fetch up to the server cap. */
|
|
120
|
+
pageSize?: number;
|
|
121
|
+
}
|
|
122
|
+
/** Options for `useInfiniteQuery` — append/load-more. */
|
|
123
|
+
export interface InfiniteQueryOptions extends BaseQueryOptions {
|
|
124
|
+
/** Rows per page. `loadMore()` appends the next page. */
|
|
125
|
+
pageSize: number;
|
|
126
|
+
}
|
|
127
|
+
/** Options for `usePaginatedQuery` — page-model with a total. */
|
|
128
|
+
export interface PaginatedQueryOptions extends BaseQueryOptions {
|
|
129
|
+
/** Rows per page. Default 25. */
|
|
130
|
+
pageSize?: number;
|
|
131
|
+
}
|
|
97
132
|
/**
|
|
98
133
|
* Trigger a workflow by alias from the app's manifest.
|
|
99
134
|
*
|
|
@@ -128,9 +163,11 @@ export interface WorkflowResult {
|
|
|
128
163
|
message?: string;
|
|
129
164
|
files?: UploadedFile[];
|
|
130
165
|
}
|
|
131
|
-
type
|
|
166
|
+
type QueryArgs<K extends keyof AppQueries & string, O> = AppQueries[K] extends Record<string, never> ? [params?: Record<string, never>, opts?: O] : [params: AppQueries[K], opts?: O];
|
|
132
167
|
/**
|
|
133
|
-
* Read rows from a query the app's author declared in `lotics.queries
|
|
168
|
+
* Read rows from a query the app's author declared in `lotics.queries` — a
|
|
169
|
+
* single fetch, no pagination. For long lists use `usePaginatedQuery`
|
|
170
|
+
* (numbered pages + total) or `useInfiniteQuery` (load-more).
|
|
134
171
|
*
|
|
135
172
|
* The app never sends a raw query AST — it invokes a named query by alias and
|
|
136
173
|
* fills the template's declared `{{params.x}}` value holes. The server holds
|
|
@@ -145,8 +182,35 @@ type UseQueryArgs<K extends keyof AppQueries & string> = AppQueries[K] extends R
|
|
|
145
182
|
* with the declared alias → param-type map, so an undeclared alias is a
|
|
146
183
|
* compile-time error and params are typed per the manifest.
|
|
147
184
|
*/
|
|
148
|
-
export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args:
|
|
185
|
+
export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, QueryOptions>): QueryState<Record<string, unknown>>;
|
|
149
186
|
export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
|
|
187
|
+
/**
|
|
188
|
+
* Like `useQuery` but append/load-more: the first render loads one page and
|
|
189
|
+
* `loadMore()` appends the next, accumulating into `rows` (infinite scroll).
|
|
190
|
+
* For numbered pages + a total, use `usePaginatedQuery`.
|
|
191
|
+
*
|
|
192
|
+
* ```tsx
|
|
193
|
+
* const { rows, loadMore, hasMore } = useInfiniteQuery("feed", {}, { pageSize: 30 });
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
export declare function useInfiniteQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, InfiniteQueryOptions>): InfiniteQueryState<Record<string, unknown>>;
|
|
197
|
+
export declare function useInfiniteQuery(alias: string, params?: Record<string, unknown>, opts?: InfiniteQueryOptions): InfiniteQueryState<Record<string, unknown>>;
|
|
198
|
+
/**
|
|
199
|
+
* Page-model query with a total — the data hook behind a numbered, jumpable
|
|
200
|
+
* table (`@lotics/ui/table_picker`, `@lotics/ui/pagination`). It owns the page
|
|
201
|
+
* cursor and fetches two things: the current page of rows, and a `count` over
|
|
202
|
+
* the filtered set (keyed independently of page + sort, so paging and
|
|
203
|
+
* re-sorting never recount). The `(params, filter)` tuple is the result-set
|
|
204
|
+
* identity: changing it resets to page 0 AND recounts; changing only `sort`
|
|
205
|
+
* does neither.
|
|
206
|
+
*
|
|
207
|
+
* ```tsx
|
|
208
|
+
* const { rows, total, page, setPage, hasMore } =
|
|
209
|
+
* usePaginatedQuery("orders", { q }, { pageSize: 25, sort, filter });
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export declare function usePaginatedQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, PaginatedQueryOptions>): PaginatedQueryState<Record<string, unknown>>;
|
|
213
|
+
export declare function usePaginatedQuery(alias: string, params?: Record<string, unknown>, opts?: PaginatedQueryOptions): PaginatedQueryState<Record<string, unknown>>;
|
|
150
214
|
/** A file the host has stored and resolved serving URLs for. */
|
|
151
215
|
export interface UploadedFile {
|
|
152
216
|
id: string;
|
package/dist/src/hooks.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed React hooks for Lotics app data access.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Reads come in three shapes — `useQuery` (single fetch), `useInfiniteQuery`
|
|
5
|
+
* (append / load-more), and `usePaginatedQuery` (numbered pages + total) — and
|
|
6
|
+
* every mutation is a `useWorkflow`; `useFileUpload` attaches files. App code
|
|
7
|
+
* never writes records directly — all writes flow through declared workflows,
|
|
8
|
+
* which gives the app owner a typed, audited chokepoint and means a
|
|
9
|
+
* publicly-shared app exposes no anonymous direct-write path. An uploaded file
|
|
10
|
+
* is inert until a workflow attaches it, so file upload keeps that same property.
|
|
10
11
|
*
|
|
11
12
|
* 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
|
-
*
|
|
13
|
+
* does the actual API calls, results stream back through `rpc()`. The read
|
|
14
|
+
* hooks cache through SWR keyed by (alias, params, …): reads dedupe and the
|
|
14
15
|
* cache survives unmount/remount. Mutation / member hooks keep their own local
|
|
15
16
|
* `useState` — they have nothing to share.
|
|
16
17
|
*/
|
|
17
18
|
import { useCallback, useEffect, useState } from "react";
|
|
19
|
+
import useSWR from "swr";
|
|
18
20
|
import useSWRInfinite from "swr/infinite";
|
|
19
21
|
import { rpc } from "./rpc.js";
|
|
20
22
|
import { getMockRows } from "./mock.js";
|
|
@@ -32,31 +34,62 @@ export function useWorkflow(alias) {
|
|
|
32
34
|
}
|
|
33
35
|
}, [alias]);
|
|
34
36
|
}
|
|
37
|
+
// Shared SWR config: surface a failed query immediately, keep the last good
|
|
38
|
+
// rows (no retry loop that masks the error), and honor the focus/reconnect
|
|
39
|
+
// opt-out.
|
|
40
|
+
function swrConfig(revalidateOnFocus) {
|
|
41
|
+
return {
|
|
42
|
+
revalidateOnFocus,
|
|
43
|
+
revalidateOnReconnect: revalidateOnFocus,
|
|
44
|
+
shouldRetryOnError: false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
35
47
|
export function useQuery(alias, params, opts) {
|
|
36
48
|
const pageSize = opts?.pageSize;
|
|
37
49
|
const enabled = opts?.enabled ?? true;
|
|
38
50
|
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
39
51
|
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
40
52
|
const filter = opts?.filter;
|
|
41
|
-
// A registered fixture short-circuits the network in mock / dev mode.
|
|
42
53
|
const mockRows = getMockRows(alias);
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
// A `null` key disables the fetch (mock / disabled). SWR canonicalizes the
|
|
55
|
+
// params/sort/filter objects via its stable hash, so identical reads dedupe to
|
|
56
|
+
// one request + one cache entry that survives unmount/remount.
|
|
57
|
+
const key = mockRows || !enabled
|
|
58
|
+
? null
|
|
59
|
+
: ["app-query", alias, params ?? {}, pageSize ?? null, sort ?? null, filter ?? null];
|
|
60
|
+
const swr = useSWR(key, () => rpc("query", {
|
|
61
|
+
alias,
|
|
62
|
+
params: params ?? {},
|
|
63
|
+
limit: pageSize,
|
|
64
|
+
offset: 0,
|
|
65
|
+
sort,
|
|
66
|
+
filter,
|
|
67
|
+
}), swrConfig(revalidateOnFocus));
|
|
68
|
+
const refetch = useCallback(() => {
|
|
69
|
+
void swr.mutate();
|
|
70
|
+
}, [swr]);
|
|
71
|
+
return {
|
|
72
|
+
rows: mockRows ?? swr.data?.rows ?? [],
|
|
73
|
+
loading: mockRows ? false : swr.isLoading,
|
|
74
|
+
isValidating: mockRows ? false : swr.isValidating,
|
|
75
|
+
error: swr.error ? swr.error.message : null,
|
|
76
|
+
refetch,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function useInfiniteQuery(alias, params, opts) {
|
|
80
|
+
const pageSize = opts?.pageSize ?? 30;
|
|
81
|
+
const enabled = opts?.enabled ?? true;
|
|
82
|
+
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
83
|
+
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
84
|
+
const filter = opts?.filter;
|
|
85
|
+
const mockRows = getMockRows(alias);
|
|
50
86
|
const getKey = (index, prev) => {
|
|
51
87
|
if (mockRows || !enabled)
|
|
52
88
|
return null;
|
|
53
|
-
//
|
|
54
|
-
if (index > 0 && (
|
|
89
|
+
// Stop once a short page returns.
|
|
90
|
+
if (index > 0 && (prev == null || prev.rows.length < pageSize))
|
|
55
91
|
return null;
|
|
56
|
-
}
|
|
57
|
-
// sort/filter join the key so a re-sort or re-filter is a distinct cache
|
|
58
|
-
// entry — SWR refetches instead of serving the previous order/subset.
|
|
59
|
-
return ["app-query", alias, params ?? {}, pageSize ?? null, sort ?? null, filter ?? null, index];
|
|
92
|
+
return ["app-query-infinite", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, index];
|
|
60
93
|
};
|
|
61
94
|
const swr = useSWRInfinite(getKey, (key) => {
|
|
62
95
|
const index = Number(key[6]);
|
|
@@ -64,42 +97,29 @@ export function useQuery(alias, params, opts) {
|
|
|
64
97
|
alias,
|
|
65
98
|
params: params ?? {},
|
|
66
99
|
limit: pageSize,
|
|
67
|
-
offset:
|
|
100
|
+
offset: index * pageSize,
|
|
68
101
|
sort,
|
|
69
102
|
filter,
|
|
70
103
|
});
|
|
71
|
-
}, {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
revalidateOnFocus,
|
|
76
|
-
revalidateOnReconnect: revalidateOnFocus,
|
|
77
|
-
// Surface a failed query immediately and keep the last good rows — no
|
|
78
|
-
// silent retry loop that would mask the error from the app.
|
|
79
|
-
shouldRetryOnError: false,
|
|
80
|
-
});
|
|
81
|
-
// SWRInfinite leaves an in-flight page slot `undefined` until it resolves
|
|
82
|
-
// (the typed `QueryPage[]` understates this) — operate on resolved pages only,
|
|
83
|
-
// so a page being appended never crashes the flatten or skews the counts.
|
|
104
|
+
}, { revalidateFirstPage: false, ...swrConfig(revalidateOnFocus) });
|
|
105
|
+
// SWRInfinite leaves an in-flight page slot `undefined` until it resolves —
|
|
106
|
+
// operate on resolved pages only so a page being appended never crashes the
|
|
107
|
+
// flatten or skews the counts.
|
|
84
108
|
const pages = (swr.data ?? []).filter((p) => p != null);
|
|
85
109
|
const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
|
|
86
110
|
const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
|
|
87
|
-
const hasMore =
|
|
88
|
-
// Appending a page: more pages are requested than have resolved.
|
|
111
|
+
const hasMore = lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
|
|
89
112
|
const loadingMore = swr.isValidating && swr.size > pages.length;
|
|
90
113
|
const refetch = useCallback(() => {
|
|
91
114
|
void swr.mutate();
|
|
92
115
|
}, [swr]);
|
|
93
116
|
const loadMore = useCallback(() => {
|
|
94
|
-
if (
|
|
117
|
+
if (!hasMore || loadingMore)
|
|
95
118
|
return;
|
|
96
119
|
void swr.setSize((n) => n + 1);
|
|
97
|
-
}, [
|
|
120
|
+
}, [hasMore, loadingMore, swr]);
|
|
98
121
|
return {
|
|
99
122
|
rows,
|
|
100
|
-
// `loading` = initial load (validating with nothing to show yet);
|
|
101
|
-
// `isValidating` = any request in flight. Gating a skeleton on `loading`
|
|
102
|
-
// never flashes on revalidation; use `isValidating` for a subtle indicator.
|
|
103
123
|
loading: mockRows ? false : swr.isLoading,
|
|
104
124
|
isValidating: mockRows ? false : swr.isValidating,
|
|
105
125
|
error: swr.error ? swr.error.message : null,
|
|
@@ -109,6 +129,61 @@ export function useQuery(alias, params, opts) {
|
|
|
109
129
|
loadingMore,
|
|
110
130
|
};
|
|
111
131
|
}
|
|
132
|
+
export function usePaginatedQuery(alias, params, opts) {
|
|
133
|
+
const pageSize = opts?.pageSize ?? 25;
|
|
134
|
+
const enabled = opts?.enabled ?? true;
|
|
135
|
+
const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
|
|
136
|
+
const sort = opts?.sort && opts.sort.length > 0 ? opts.sort : undefined;
|
|
137
|
+
const filter = opts?.filter;
|
|
138
|
+
const mockRows = getMockRows(alias);
|
|
139
|
+
// The result-set identity. When it changes, `page` derives back to 0 (not via
|
|
140
|
+
// an effect, so the stale page never fires a wasted fetch) and the count key
|
|
141
|
+
// changes (recount). `setPage` re-stamps the current identity.
|
|
142
|
+
const resetKey = JSON.stringify([params ?? {}, filter ?? null]);
|
|
143
|
+
const [pageState, setPageState] = useState({ key: resetKey, page: 0 });
|
|
144
|
+
const page = pageState.key === resetKey ? pageState.page : 0;
|
|
145
|
+
const setPage = useCallback((p) => setPageState({ key: resetKey, page: Math.max(0, p) }), [resetKey]);
|
|
146
|
+
const rowsKey = mockRows || !enabled
|
|
147
|
+
? null
|
|
148
|
+
: ["app-query-page", alias, params ?? {}, pageSize, sort ?? null, filter ?? null, page];
|
|
149
|
+
const rowsSwr = useSWR(rowsKey, () => rpc("query", {
|
|
150
|
+
alias,
|
|
151
|
+
params: params ?? {},
|
|
152
|
+
limit: pageSize,
|
|
153
|
+
offset: page * pageSize,
|
|
154
|
+
sort,
|
|
155
|
+
filter,
|
|
156
|
+
}),
|
|
157
|
+
// Keep the previous page's rows on screen while the next page loads.
|
|
158
|
+
{ keepPreviousData: true, ...swrConfig(revalidateOnFocus) });
|
|
159
|
+
// Count key omits page AND sort — one count per result-set identity, reused
|
|
160
|
+
// across page clicks and re-sorts.
|
|
161
|
+
const countKey = mockRows || !enabled
|
|
162
|
+
? null
|
|
163
|
+
: ["app-query-count", alias, params ?? {}, filter ?? null];
|
|
164
|
+
const countSwr = useSWR(countKey, () => rpc("query", { alias, params: params ?? {}, filter, count: true }), swrConfig(revalidateOnFocus));
|
|
165
|
+
const rows = mockRows ?? rowsSwr.data?.rows ?? [];
|
|
166
|
+
const total = mockRows ? mockRows.length : countSwr.data?.total;
|
|
167
|
+
const totalPages = total != null ? Math.max(1, Math.ceil(total / pageSize)) : undefined;
|
|
168
|
+
const hasMore = total != null ? (page + 1) * pageSize < total : rows.length === pageSize;
|
|
169
|
+
const refetch = useCallback(() => {
|
|
170
|
+
void rowsSwr.mutate();
|
|
171
|
+
void countSwr.mutate();
|
|
172
|
+
}, [rowsSwr, countSwr]);
|
|
173
|
+
return {
|
|
174
|
+
rows,
|
|
175
|
+
total,
|
|
176
|
+
totalPages,
|
|
177
|
+
page,
|
|
178
|
+
pageSize,
|
|
179
|
+
hasMore,
|
|
180
|
+
setPage,
|
|
181
|
+
loading: mockRows ? false : rowsSwr.isLoading,
|
|
182
|
+
isValidating: mockRows ? false : rowsSwr.isValidating || countSwr.isValidating,
|
|
183
|
+
error: rowsSwr.error?.message ?? countSwr.error?.message ?? null,
|
|
184
|
+
refetch,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
112
187
|
/**
|
|
113
188
|
* Upload files from an app. The bytes are stored via a presigned
|
|
114
189
|
* direct-to-storage upload the host mediates; the API server never proxies
|
package/dist/src/index.d.ts
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export type { MountOptions } from "./mount.js";
|
|
19
|
-
export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
|
|
20
|
-
export type { UploadedFile, QueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
|
|
19
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
|
|
20
|
+
export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
|
|
21
|
+
export { useComments } from "./comments.js";
|
|
22
|
+
export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs } from "./comments.js";
|
|
21
23
|
export { rpc } from "./rpc.js";
|
|
22
24
|
export type { RpcOp } from "./rpc.js";
|
|
23
25
|
export { openExternal } from "./open_external.js";
|
package/dist/src/index.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
|
|
16
16
|
*/
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
|
-
export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
|
|
18
|
+
export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
|
|
19
|
+
export { useComments } from "./comments.js";
|
|
19
20
|
export { rpc } from "./rpc.js";
|
|
20
21
|
export { openExternal } from "./open_external.js";
|
|
21
22
|
export { readMembers } from "./members.js";
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* app → host: { id, op, payload }
|
|
19
19
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
20
|
*/
|
|
21
|
-
export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal";
|
|
21
|
+
export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete";
|
|
22
22
|
/**
|
|
23
23
|
* The app's identity, resolved once at startup to tag PostHog events.
|
|
24
24
|
* Assembled by whichever transport is active:
|
|
@@ -38,5 +38,11 @@ export interface AppContext {
|
|
|
38
38
|
workspace_id: string;
|
|
39
39
|
organization_id: string;
|
|
40
40
|
member_id: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Whether the app declared the `comments` capability. `useComments` is
|
|
43
|
+
* available only when this is true AND a member is signed in. False for any
|
|
44
|
+
* app that didn't opt in, and (vacuously) for standalone visitors.
|
|
45
|
+
*/
|
|
46
|
+
comments_enabled: boolean;
|
|
41
47
|
}
|
|
42
48
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
package/dist/src/rpc.js
CHANGED
|
@@ -223,8 +223,24 @@ function rpcStandalone(op, payload) {
|
|
|
223
223
|
return standaloneContext();
|
|
224
224
|
case "openExternal":
|
|
225
225
|
return standaloneOpenExternal(payload);
|
|
226
|
+
case "comments.list":
|
|
227
|
+
case "comments.create":
|
|
228
|
+
case "comments.update":
|
|
229
|
+
case "comments.delete":
|
|
230
|
+
return rejectCommentsStandalone();
|
|
226
231
|
}
|
|
227
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Comments are a members-only collaboration surface. A comment must have an
|
|
235
|
+
* authenticated member as its author, and a thread can carry member-to-member
|
|
236
|
+
* discussion — so a standalone (public, anonymous) app has no member to author
|
|
237
|
+
* as and no business reading other members' threads. `useComments` detects this
|
|
238
|
+
* up front via the null context `member_id` and never sends a comment op; this
|
|
239
|
+
* rejection is the belt-and-braces backstop for any direct `rpc()` caller.
|
|
240
|
+
*/
|
|
241
|
+
function rejectCommentsStandalone() {
|
|
242
|
+
return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
|
|
243
|
+
}
|
|
228
244
|
async function standaloneMembers(p) {
|
|
229
245
|
const { app_id } = await boot();
|
|
230
246
|
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
|
@@ -261,8 +277,10 @@ async function standaloneContext() {
|
|
|
261
277
|
app_name: info.app_name,
|
|
262
278
|
workspace_id: info.workspace_id,
|
|
263
279
|
organization_id: info.organization_id,
|
|
264
|
-
// No host session in standalone mode — the visitor is anonymous
|
|
280
|
+
// No host session in standalone mode — the visitor is anonymous, so
|
|
281
|
+
// comments are unavailable regardless of the capability flag.
|
|
265
282
|
member_id: null,
|
|
283
|
+
comments_enabled: info.comments_enabled,
|
|
266
284
|
};
|
|
267
285
|
}
|
|
268
286
|
async function standaloneQuery(p) {
|