@lotics/app-sdk 0.19.0 → 0.21.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.d.ts +29 -8
- package/dist/src/hooks.js +22 -12
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +1 -0
- package/dist/src/members.d.ts +3 -0
- package/dist/src/rpc.js +4 -3
- package/dist/src/use_recents.d.ts +19 -0
- package/dist/src/use_recents.js +71 -0
- package/package.json +1 -1
package/dist/src/hooks.d.ts
CHANGED
|
@@ -28,6 +28,15 @@ export interface QueryOptions {
|
|
|
28
28
|
* `loadMore()` appends the next.
|
|
29
29
|
*/
|
|
30
30
|
pageSize?: number;
|
|
31
|
+
/**
|
|
32
|
+
* When `false`, the query does not run: `rows` stays empty, `loading` is
|
|
33
|
+
* false, and no request is sent. Flip it back to `true` to fetch. This is the
|
|
34
|
+
* primitive for search-as-you-type (skip until the user types) and for detail
|
|
35
|
+
* queries (skip until a row is selected) — a parameterized search filter
|
|
36
|
+
* matches everything on an empty term, so an always-on query would dump the
|
|
37
|
+
* whole table on first paint. Default `true`.
|
|
38
|
+
*/
|
|
39
|
+
enabled?: boolean;
|
|
31
40
|
}
|
|
32
41
|
/**
|
|
33
42
|
* Trigger a workflow by alias from the app's manifest.
|
|
@@ -121,21 +130,33 @@ interface MembersState {
|
|
|
121
130
|
loading: boolean;
|
|
122
131
|
error: string | null;
|
|
123
132
|
}
|
|
133
|
+
/** Options for `useMembers`. */
|
|
134
|
+
export interface MembersOptions {
|
|
135
|
+
/**
|
|
136
|
+
* Restrict to one member group (a group ID). The group must be declared on a
|
|
137
|
+
* `member` workflow input's `group` — listing an undeclared group errors. Omit
|
|
138
|
+
* to list the whole org roster.
|
|
139
|
+
*/
|
|
140
|
+
group?: string;
|
|
141
|
+
}
|
|
124
142
|
/**
|
|
125
143
|
* List the members of the app's organization — the candidate set for an
|
|
126
|
-
* "assign to a member" picker.
|
|
127
|
-
*
|
|
144
|
+
* "assign to a member" picker. Each member is `{ id, name, email, image }`
|
|
145
|
+
* (`image` = avatar URL, may be null). Resolves through the host (member-only;
|
|
146
|
+
* an anonymous public visitor gets an error). Names may be empty for members
|
|
128
147
|
* without a display name set — fall back to `email`.
|
|
129
148
|
*
|
|
130
149
|
* Gated: the app must DECLARE that it works with members — it needs a workflow
|
|
131
|
-
* whose manifest declares a `member`-typed input
|
|
132
|
-
*
|
|
133
|
-
*
|
|
150
|
+
* whose manifest declares a `member`-typed input. Passing `{ group }` restricts
|
|
151
|
+
* to that group, and is only honored if some member input declares that
|
|
152
|
+
* `group` — so an app can only list (and assign into) groups it declares.
|
|
134
153
|
*
|
|
135
154
|
* ```tsx
|
|
136
|
-
* const { members } = useMembers();
|
|
137
|
-
* // <Picker options={members.map((m) => ({
|
|
155
|
+
* const { members } = useMembers({ group: "grp_..." });
|
|
156
|
+
* // <Picker options={members.map((m) => ({
|
|
157
|
+
* // value: m.id, label: m.name || m.email || m.id, image: m.image,
|
|
158
|
+
* // }))} />
|
|
138
159
|
* ```
|
|
139
160
|
*/
|
|
140
|
-
export declare function useMembers(): MembersState;
|
|
161
|
+
export declare function useMembers(opts?: MembersOptions): MembersState;
|
|
141
162
|
export {};
|
package/dist/src/hooks.js
CHANGED
|
@@ -35,6 +35,7 @@ export function useWorkflow(alias) {
|
|
|
35
35
|
}
|
|
36
36
|
export function useQuery(alias, params, opts) {
|
|
37
37
|
const pageSize = opts?.pageSize;
|
|
38
|
+
const enabled = opts?.enabled ?? true;
|
|
38
39
|
// Stringified params key — structurally-equal-but-new param objects don't
|
|
39
40
|
// re-fire the effect every render.
|
|
40
41
|
const paramsKey = JSON.stringify(params ?? {});
|
|
@@ -48,7 +49,8 @@ export function useQuery(alias, params, opts) {
|
|
|
48
49
|
const mockRows = getMockRows(alias);
|
|
49
50
|
if (mockRows)
|
|
50
51
|
return { rows: mockRows, loading: false, error: null, hasMore: false };
|
|
51
|
-
|
|
52
|
+
// A disabled query starts idle (no loading flash), not pending.
|
|
53
|
+
return { rows: [], loading: enabled, error: null, hasMore: false };
|
|
52
54
|
});
|
|
53
55
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
54
56
|
useEffect(() => {
|
|
@@ -59,6 +61,10 @@ export function useQuery(alias, params, opts) {
|
|
|
59
61
|
setState({ rows: mockRows, loading: false, error: null, hasMore: false });
|
|
60
62
|
return;
|
|
61
63
|
}
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
setState({ rows: [], loading: false, error: null, hasMore: false });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
62
68
|
let cancelled = false;
|
|
63
69
|
setState((s) => ({ ...s, loading: true, error: null }));
|
|
64
70
|
rpc("query", {
|
|
@@ -87,7 +93,7 @@ export function useQuery(alias, params, opts) {
|
|
|
87
93
|
cancelled = true;
|
|
88
94
|
};
|
|
89
95
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
-
}, [alias, paramsKey, refetchToken, pageSize]);
|
|
96
|
+
}, [alias, paramsKey, refetchToken, pageSize, enabled]);
|
|
91
97
|
const refetch = useCallback(() => {
|
|
92
98
|
setRefetchToken((n) => n + 1);
|
|
93
99
|
}, []);
|
|
@@ -181,21 +187,25 @@ export function useFileUpload() {
|
|
|
181
187
|
}
|
|
182
188
|
/**
|
|
183
189
|
* List the members of the app's organization — the candidate set for an
|
|
184
|
-
* "assign to a member" picker.
|
|
185
|
-
*
|
|
190
|
+
* "assign to a member" picker. Each member is `{ id, name, email, image }`
|
|
191
|
+
* (`image` = avatar URL, may be null). Resolves through the host (member-only;
|
|
192
|
+
* an anonymous public visitor gets an error). Names may be empty for members
|
|
186
193
|
* without a display name set — fall back to `email`.
|
|
187
194
|
*
|
|
188
195
|
* Gated: the app must DECLARE that it works with members — it needs a workflow
|
|
189
|
-
* whose manifest declares a `member`-typed input
|
|
190
|
-
*
|
|
191
|
-
*
|
|
196
|
+
* whose manifest declares a `member`-typed input. Passing `{ group }` restricts
|
|
197
|
+
* to that group, and is only honored if some member input declares that
|
|
198
|
+
* `group` — so an app can only list (and assign into) groups it declares.
|
|
192
199
|
*
|
|
193
200
|
* ```tsx
|
|
194
|
-
* const { members } = useMembers();
|
|
195
|
-
* // <Picker options={members.map((m) => ({
|
|
201
|
+
* const { members } = useMembers({ group: "grp_..." });
|
|
202
|
+
* // <Picker options={members.map((m) => ({
|
|
203
|
+
* // value: m.id, label: m.name || m.email || m.id, image: m.image,
|
|
204
|
+
* // }))} />
|
|
196
205
|
* ```
|
|
197
206
|
*/
|
|
198
|
-
export function useMembers() {
|
|
207
|
+
export function useMembers(opts) {
|
|
208
|
+
const group = opts?.group;
|
|
199
209
|
const [state, setState] = useState({
|
|
200
210
|
members: [],
|
|
201
211
|
loading: true,
|
|
@@ -204,7 +214,7 @@ export function useMembers() {
|
|
|
204
214
|
useEffect(() => {
|
|
205
215
|
let cancelled = false;
|
|
206
216
|
setState((s) => ({ ...s, loading: true, error: null }));
|
|
207
|
-
rpc("members", {})
|
|
217
|
+
rpc("members", { group })
|
|
208
218
|
.then((r) => {
|
|
209
219
|
if (!cancelled)
|
|
210
220
|
setState({ members: r.members ?? [], loading: false, error: null });
|
|
@@ -216,6 +226,6 @@ export function useMembers() {
|
|
|
216
226
|
return () => {
|
|
217
227
|
cancelled = true;
|
|
218
228
|
};
|
|
219
|
-
}, []);
|
|
229
|
+
}, [group]);
|
|
220
230
|
return state;
|
|
221
231
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
export { mount } from "./mount.js";
|
|
18
18
|
export type { MountOptions } from "./mount.js";
|
|
19
19
|
export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
|
|
20
|
-
export type { UploadedFile, QueryOptions, WorkflowResult } from "./hooks.js";
|
|
20
|
+
export type { UploadedFile, QueryOptions, WorkflowResult, MembersOptions } from "./hooks.js";
|
|
21
21
|
export { rpc } from "./rpc.js";
|
|
22
22
|
export type { RpcOp } from "./rpc.js";
|
|
23
23
|
export { openExternal } from "./open_external.js";
|
|
@@ -31,3 +31,5 @@ export { row, readLinks } from "./row.js";
|
|
|
31
31
|
export type { ResolvedLink } from "./row.js";
|
|
32
32
|
export { useOptimistic } from "./use_optimistic.js";
|
|
33
33
|
export type { OptimisticApi } from "./use_optimistic.js";
|
|
34
|
+
export { useRecents } from "./use_recents.js";
|
|
35
|
+
export type { RecentsApi, RecentsOptions } from "./use_recents.js";
|
package/dist/src/index.js
CHANGED
package/dist/src/members.d.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface ResolvedMember {
|
|
|
18
18
|
/** Present only on authenticated responses. Omitted on public-app
|
|
19
19
|
* responses (no PII exposure to anonymous visitors). */
|
|
20
20
|
email?: string | null;
|
|
21
|
+
/** Avatar URL. Present on the `useMembers` roster (may be null when the
|
|
22
|
+
* member has no profile image); absent on resolved `select_member` cells. */
|
|
23
|
+
image?: string | null;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
23
26
|
* Parse a `useQuery` cell value into `ResolvedMember[]`. Returns `[]` for
|
package/dist/src/rpc.js
CHANGED
|
@@ -218,16 +218,17 @@ function rpcStandalone(op, payload) {
|
|
|
218
218
|
case "upload":
|
|
219
219
|
return standaloneUpload(payload.file);
|
|
220
220
|
case "members":
|
|
221
|
-
return standaloneMembers();
|
|
221
|
+
return standaloneMembers(payload);
|
|
222
222
|
case "context":
|
|
223
223
|
return standaloneContext();
|
|
224
224
|
case "openExternal":
|
|
225
225
|
return standaloneOpenExternal(payload);
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
|
-
async function standaloneMembers() {
|
|
228
|
+
async function standaloneMembers(p) {
|
|
229
229
|
const { app_id } = await boot();
|
|
230
|
-
const
|
|
230
|
+
const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
|
|
231
|
+
const r = (await apiCall("GET", `/v1/apps/${app_id}/members${qs}`, undefined, {
|
|
231
232
|
appId: app_id,
|
|
232
233
|
}));
|
|
233
234
|
return { members: r.members ?? [] };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RecentsApi<T> {
|
|
2
|
+
/** Remembered items, most-recent first (deduped by `keyOf`, capped at `max`). */
|
|
3
|
+
recents: T[];
|
|
4
|
+
/** Record `item` as most-recent: moves an existing match to the front, caps,
|
|
5
|
+
* and persists. Call this on select. */
|
|
6
|
+
remember: (item: T) => void;
|
|
7
|
+
/** Drop one remembered item (matched by `keyOf`). */
|
|
8
|
+
forget: (item: T) => void;
|
|
9
|
+
/** Clear the whole list. */
|
|
10
|
+
clear: () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface RecentsOptions<T> {
|
|
13
|
+
/** Stable identity per item — used to dedup and to match `forget`. Default:
|
|
14
|
+
* `JSON.stringify`. Pass the item's id for objects you'll re-create. */
|
|
15
|
+
keyOf?: (item: T) => string;
|
|
16
|
+
/** Maximum items kept. Default 5. */
|
|
17
|
+
max?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function useRecents<T>(key: string, options?: RecentsOptions<T>): RecentsApi<T>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember a small, most-recent-first list of items across sessions — the
|
|
3
|
+
* "recently used" affordance a search box shows when focused but empty.
|
|
4
|
+
*
|
|
5
|
+
* Persistence is `localStorage`, namespaced per `key`, so two comboboxes on one
|
|
6
|
+
* page keep separate lists and a deployed app's recents survive reloads. The
|
|
7
|
+
* SDK is the right home (not `@lotics/ui`): `localStorage` is web-only, and
|
|
8
|
+
* `@lotics/ui` also builds for native, where it doesn't exist. The list is
|
|
9
|
+
* always state-backed, so the hook keeps working in-memory if storage is
|
|
10
|
+
* unavailable (private mode / quota) — recents is an enhancement, never a
|
|
11
|
+
* reason to crash the app.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
14
|
+
const PREFIX = "lotics.recents.";
|
|
15
|
+
function read(storageKey) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = localStorage.getItem(storageKey);
|
|
18
|
+
if (raw == null)
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Storage unavailable or corrupt JSON: start empty, keep working in-memory.
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function write(storageKey, items) {
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(storageKey, JSON.stringify(items));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Storage unavailable (private mode / quota): the in-memory list still
|
|
34
|
+
// updates; only cross-reload persistence is lost.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function useRecents(key, options = {}) {
|
|
38
|
+
const storageKey = PREFIX + key;
|
|
39
|
+
// Config is captured in refs so the returned callbacks stay referentially
|
|
40
|
+
// stable across renders even when the caller passes an inline `keyOf`.
|
|
41
|
+
const keyOfRef = useRef(options.keyOf ?? ((item) => JSON.stringify(item)));
|
|
42
|
+
keyOfRef.current = options.keyOf ?? ((item) => JSON.stringify(item));
|
|
43
|
+
const maxRef = useRef(options.max ?? 5);
|
|
44
|
+
maxRef.current = options.max ?? 5;
|
|
45
|
+
const [recents, setRecents] = useState(() => read(storageKey).slice(0, maxRef.current));
|
|
46
|
+
// Re-hydrate when the namespace changes (a different list on the same screen).
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setRecents(read(storageKey).slice(0, maxRef.current));
|
|
49
|
+
}, [storageKey]);
|
|
50
|
+
const remember = useCallback((item) => {
|
|
51
|
+
setRecents((prev) => {
|
|
52
|
+
const k = keyOfRef.current(item);
|
|
53
|
+
const next = [item, ...prev.filter((p) => keyOfRef.current(p) !== k)].slice(0, maxRef.current);
|
|
54
|
+
write(storageKey, next);
|
|
55
|
+
return next;
|
|
56
|
+
});
|
|
57
|
+
}, [storageKey]);
|
|
58
|
+
const forget = useCallback((item) => {
|
|
59
|
+
setRecents((prev) => {
|
|
60
|
+
const k = keyOfRef.current(item);
|
|
61
|
+
const next = prev.filter((p) => keyOfRef.current(p) !== k);
|
|
62
|
+
write(storageKey, next);
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}, [storageKey]);
|
|
66
|
+
const clear = useCallback(() => {
|
|
67
|
+
write(storageKey, []);
|
|
68
|
+
setRecents([]);
|
|
69
|
+
}, [storageKey]);
|
|
70
|
+
return { recents, remember, forget, clear };
|
|
71
|
+
}
|