@lotics/app-sdk 0.20.0 → 0.22.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.
@@ -2,7 +2,15 @@ import type { AppWorkflows, AppQueries } from "./types.js";
2
2
  import type { ResolvedMember } from "./members.js";
3
3
  interface QueryState<R> {
4
4
  rows: R[];
5
+ /**
6
+ * True only on the initial load — a request is in flight and there are no
7
+ * rows yet. Stays false during background revalidation and while typing a new
8
+ * query (the previous rows remain visible), so consumers never blank data to a
9
+ * spinner on refetch. Use `isValidating` for a subtle refetch indicator.
10
+ */
5
11
  loading: boolean;
12
+ /** True whenever any request is in flight (initial load or revalidation). */
13
+ isValidating: boolean;
6
14
  error: string | null;
7
15
  /**
8
16
  * Re-run the query from the first page. Use after a known mutation point —
@@ -37,6 +45,14 @@ export interface QueryOptions {
37
45
  * whole table on first paint. Default `true`.
38
46
  */
39
47
  enabled?: boolean;
48
+ /**
49
+ * When `false`, the query does not auto-refetch on window focus / tab return /
50
+ * network reconnect (`refetch()` still works). Default `true`, right for
51
+ * dashboards that should stay fresh. Set `false` for transient queries — a
52
+ * search bound to an ephemeral term, or on-demand detail — where a refocus
53
+ * re-run is wasted work and a visible reload.
54
+ */
55
+ revalidateOnFocus?: boolean;
40
56
  }
41
57
  /**
42
58
  * Trigger a workflow by alias from the app's manifest.
@@ -130,21 +146,33 @@ interface MembersState {
130
146
  loading: boolean;
131
147
  error: string | null;
132
148
  }
149
+ /** Options for `useMembers`. */
150
+ export interface MembersOptions {
151
+ /**
152
+ * Restrict to one member group (a group ID). The group must be declared on a
153
+ * `member` workflow input's `group` — listing an undeclared group errors. Omit
154
+ * to list the whole org roster.
155
+ */
156
+ group?: string;
157
+ }
133
158
  /**
134
159
  * List the members of the app's organization — the candidate set for an
135
- * "assign to a member" picker. Resolves through the host (member-only; an
136
- * anonymous public visitor gets an error). Names may be empty for members
160
+ * "assign to a member" picker. Each member is `{ id, name, email, image }`
161
+ * (`image` = avatar URL, may be null). Resolves through the host (member-only;
162
+ * an anonymous public visitor gets an error). Names may be empty for members
137
163
  * without a display name set — fall back to `email`.
138
164
  *
139
165
  * Gated: the app must DECLARE that it works with members — it needs a workflow
140
- * whose manifest declares a `member`-typed input (i.e. it assigns members).
141
- * Without one the host returns an error, so member access stays a declared,
142
- * auditable surface rather than something any app can read ambiently.
166
+ * whose manifest declares a `member`-typed input. Passing `{ group }` restricts
167
+ * to that group, and is only honored if some member input declares that
168
+ * `group` so an app can only list (and assign into) groups it declares.
143
169
  *
144
170
  * ```tsx
145
- * const { members } = useMembers();
146
- * // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
171
+ * const { members } = useMembers({ group: "grp_..." });
172
+ * // <Picker options={members.map((m) => ({
173
+ * // value: m.id, label: m.name || m.email || m.id, image: m.image,
174
+ * // }))} />
147
175
  * ```
148
176
  */
149
- export declare function useMembers(): MembersState;
177
+ export declare function useMembers(opts?: MembersOptions): MembersState;
150
178
  export {};
package/dist/src/hooks.js CHANGED
@@ -36,6 +36,7 @@ export function useWorkflow(alias) {
36
36
  export function useQuery(alias, params, opts) {
37
37
  const pageSize = opts?.pageSize;
38
38
  const enabled = opts?.enabled ?? true;
39
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
39
40
  // Stringified params key — structurally-equal-but-new param objects don't
40
41
  // re-fire the effect every render.
41
42
  const paramsKey = JSON.stringify(params ?? {});
@@ -48,9 +49,9 @@ export function useQuery(alias, params, opts) {
48
49
  const [state, setState] = useState(() => {
49
50
  const mockRows = getMockRows(alias);
50
51
  if (mockRows)
51
- return { rows: mockRows, loading: false, error: null, hasMore: false };
52
- // A disabled query starts idle (no loading flash), not pending.
53
- return { rows: [], loading: enabled, error: null, hasMore: false };
52
+ return { rows: mockRows, isValidating: false, error: null, hasMore: false };
53
+ // A disabled query starts idle; an enabled one is validating its first page.
54
+ return { rows: [], isValidating: enabled, error: null, hasMore: false };
54
55
  });
55
56
  const [loadingMore, setLoadingMore] = useState(false);
56
57
  useEffect(() => {
@@ -58,15 +59,17 @@ export function useQuery(alias, params, opts) {
58
59
  // without a hard reload. Fixture short-circuits the RPC.
59
60
  const mockRows = getMockRows(alias);
60
61
  if (mockRows) {
61
- setState({ rows: mockRows, loading: false, error: null, hasMore: false });
62
+ setState({ rows: mockRows, isValidating: false, error: null, hasMore: false });
62
63
  return;
63
64
  }
64
65
  if (!enabled) {
65
- setState({ rows: [], loading: false, error: null, hasMore: false });
66
+ setState({ rows: [], isValidating: false, error: null, hasMore: false });
66
67
  return;
67
68
  }
68
69
  let cancelled = false;
69
- setState((s) => ({ ...s, loading: true, error: null }));
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 }));
70
73
  rpc("query", {
71
74
  alias,
72
75
  params: params ?? {},
@@ -79,7 +82,7 @@ export function useQuery(alias, params, opts) {
79
82
  const rows = result.rows ?? [];
80
83
  setState({
81
84
  rows,
82
- loading: false,
85
+ isValidating: false,
83
86
  error: null,
84
87
  hasMore: pageSize != null && rows.length === pageSize,
85
88
  });
@@ -87,7 +90,8 @@ export function useQuery(alias, params, opts) {
87
90
  .catch((err) => {
88
91
  if (cancelled)
89
92
  return;
90
- setState({ rows: [], loading: false, error: err.message, hasMore: false });
93
+ // Keep the last good rows on a failed revalidation; surface the error.
94
+ setState((s) => ({ ...s, isValidating: false, error: err.message }));
91
95
  });
92
96
  return () => {
93
97
  cancelled = true;
@@ -103,6 +107,8 @@ export function useQuery(alias, params, opts) {
103
107
  // an app calls after a `useWorkflow()` mutation. Throttled so a tab return
104
108
  // that fires both `focus` and `visibilitychange` only refetches once.
105
109
  useEffect(() => {
110
+ if (!revalidateOnFocus)
111
+ return;
106
112
  let last = 0;
107
113
  const revalidate = () => {
108
114
  if (document.visibilityState === "hidden")
@@ -121,9 +127,9 @@ export function useQuery(alias, params, opts) {
121
127
  window.removeEventListener("online", revalidate);
122
128
  document.removeEventListener("visibilitychange", revalidate);
123
129
  };
124
- }, []);
130
+ }, [revalidateOnFocus]);
125
131
  const loadMore = useCallback(() => {
126
- if (pageSize == null || loadingMore || state.loading || !state.hasMore)
132
+ if (pageSize == null || loadingMore || state.isValidating || !state.hasMore)
127
133
  return;
128
134
  setLoadingMore(true);
129
135
  rpc("query", {
@@ -148,8 +154,10 @@ export function useQuery(alias, params, opts) {
148
154
  })
149
155
  .finally(() => setLoadingMore(false));
150
156
  // eslint-disable-next-line react-hooks/exhaustive-deps
151
- }, [alias, paramsKey, pageSize, loadingMore, state.loading, state.hasMore, state.rows.length]);
152
- return { ...state, refetch, loadMore, loadingMore };
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 };
153
161
  }
154
162
  /**
155
163
  * Upload files from an app. The bytes are stored via a presigned
@@ -187,21 +195,25 @@ export function useFileUpload() {
187
195
  }
188
196
  /**
189
197
  * List the members of the app's organization — the candidate set for an
190
- * "assign to a member" picker. Resolves through the host (member-only; an
191
- * anonymous public visitor gets an error). Names may be empty for members
198
+ * "assign to a member" picker. Each member is `{ id, name, email, image }`
199
+ * (`image` = avatar URL, may be null). Resolves through the host (member-only;
200
+ * an anonymous public visitor gets an error). Names may be empty for members
192
201
  * without a display name set — fall back to `email`.
193
202
  *
194
203
  * Gated: the app must DECLARE that it works with members — it needs a workflow
195
- * whose manifest declares a `member`-typed input (i.e. it assigns members).
196
- * Without one the host returns an error, so member access stays a declared,
197
- * auditable surface rather than something any app can read ambiently.
204
+ * whose manifest declares a `member`-typed input. Passing `{ group }` restricts
205
+ * to that group, and is only honored if some member input declares that
206
+ * `group` so an app can only list (and assign into) groups it declares.
198
207
  *
199
208
  * ```tsx
200
- * const { members } = useMembers();
201
- * // <Picker options={members.map((m) => ({ value: m.id, label: m.name || m.email || m.id }))} />
209
+ * const { members } = useMembers({ group: "grp_..." });
210
+ * // <Picker options={members.map((m) => ({
211
+ * // value: m.id, label: m.name || m.email || m.id, image: m.image,
212
+ * // }))} />
202
213
  * ```
203
214
  */
204
- export function useMembers() {
215
+ export function useMembers(opts) {
216
+ const group = opts?.group;
205
217
  const [state, setState] = useState({
206
218
  members: [],
207
219
  loading: true,
@@ -210,7 +222,7 @@ export function useMembers() {
210
222
  useEffect(() => {
211
223
  let cancelled = false;
212
224
  setState((s) => ({ ...s, loading: true, error: null }));
213
- rpc("members", {})
225
+ rpc("members", { group })
214
226
  .then((r) => {
215
227
  if (!cancelled)
216
228
  setState({ members: r.members ?? [], loading: false, error: null });
@@ -222,6 +234,6 @@ export function useMembers() {
222
234
  return () => {
223
235
  cancelled = true;
224
236
  };
225
- }, []);
237
+ }, [group]);
226
238
  return state;
227
239
  }
@@ -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";
@@ -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 r = (await apiCall("GET", `/v1/apps/${app_id}/members`, undefined, {
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 ?? [] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.20.0",
3
+ "version": "0.22.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": {