@lotics/app-sdk 0.68.0 → 0.70.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 +12 -0
- package/dist/src/members.d.ts +21 -2
- package/dist/src/members.js +12 -7
- package/docs/data_fetching.md +2 -2
- package/docs/members_and_options.md +24 -18
- package/docs/queries.md +11 -6
- package/package.json +2 -2
package/dist/src/hooks.d.ts
CHANGED
|
@@ -103,6 +103,18 @@ interface PaginatedQueryState<R> extends QueryStateBase {
|
|
|
103
103
|
export interface QuerySortKey {
|
|
104
104
|
field_key: string;
|
|
105
105
|
order: "asc" | "desc";
|
|
106
|
+
/**
|
|
107
|
+
* Where rows BLANK in this column sit — `"bottom"` when omitted, which is what
|
|
108
|
+
* you want for "the ones with a value first".
|
|
109
|
+
*
|
|
110
|
+
* It earns its place on a MULTI-KEY sort over columns that are blank by
|
|
111
|
+
* position rather than by accident — a pipeline's per-step date stamps, say,
|
|
112
|
+
* where the first non-blank column IS the row's rank. Reading such a ladder
|
|
113
|
+
* from the top with blanks at the bottom orders it furthest-along-first; the
|
|
114
|
+
* reverse reading needs blanks on TOP, and with only the default there is no
|
|
115
|
+
* way to express it, so the column can be sorted one way and not the other.
|
|
116
|
+
*/
|
|
117
|
+
blank_position?: "top" | "bottom";
|
|
106
118
|
}
|
|
107
119
|
/** A filter condition over one output column (wire shape of a filter node). */
|
|
108
120
|
export interface QueryFilterCondition {
|
package/dist/src/members.d.ts
CHANGED
|
@@ -9,6 +9,20 @@
|
|
|
9
9
|
*
|
|
10
10
|
* If the wire format changes, the resolver and this reader move together.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* A member, in the ONE shape every door returns — a `select_member` cell in a
|
|
14
|
+
* `useQuery` row and the `useMembers` roster alike.
|
|
15
|
+
*
|
|
16
|
+
* The two used to disagree: the roster carried an avatar, a resolved cell did
|
|
17
|
+
* not, and both were typed `ResolvedMember`, so an app author holding one could
|
|
18
|
+
* not tell which. That is why no register row ever rendered an avatar — the
|
|
19
|
+
* data was absent and nothing said so.
|
|
20
|
+
*
|
|
21
|
+
* The private fields share ONE boundary, not three: an authenticated member of
|
|
22
|
+
* the app's own org sees `email`, `image` and `groups`; an anonymous visitor to
|
|
23
|
+
* a public app sees `id` and `name`. Absent ≠ empty — `image: null` means the
|
|
24
|
+
* member has no photo, `image` MISSING means you were never told.
|
|
25
|
+
*/
|
|
12
26
|
export interface ResolvedMember {
|
|
13
27
|
id: string;
|
|
14
28
|
/** `null` when the id resolves outside the app's org (e.g. removed
|
|
@@ -18,9 +32,14 @@ export interface ResolvedMember {
|
|
|
18
32
|
/** Present only on authenticated responses. Omitted on public-app
|
|
19
33
|
* responses (no PII exposure to anonymous visitors). */
|
|
20
34
|
email?: string | null;
|
|
21
|
-
/** Avatar URL
|
|
22
|
-
*
|
|
35
|
+
/** Avatar URL, already presigned — render it directly. `null` when the member
|
|
36
|
+
* has no profile image (the common case: photos are opt-in). Omitted on
|
|
37
|
+
* public-app responses. */
|
|
23
38
|
image?: string | null;
|
|
39
|
+
/** The member's group names — the platform's "department". There is no
|
|
40
|
+
* department field; a member group is what an org uses to say Sale, Kế toán,
|
|
41
|
+
* CSKH. `[]` when the member is in none. Omitted on public-app responses. */
|
|
42
|
+
groups?: string[];
|
|
24
43
|
}
|
|
25
44
|
/**
|
|
26
45
|
* Parse a `useQuery` cell value into `ResolvedMember[]`. Returns `[]` for
|
package/dist/src/members.js
CHANGED
|
@@ -28,13 +28,18 @@ export function readMembers(value) {
|
|
|
28
28
|
continue;
|
|
29
29
|
const name = typeof obj.name === "string" ? obj.name : obj.name === null ? null : null;
|
|
30
30
|
const m = { id, name };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
// PRESENT ⇒ copy, ABSENT ⇒ leave off. The distinction is the permission
|
|
32
|
+
// boundary itself: a missing key means a public reader was never told,
|
|
33
|
+
// while `null` means the member genuinely has none. Defaulting either to
|
|
34
|
+
// null here would erase that and make a gated field look like an empty one.
|
|
35
|
+
if ("email" in obj)
|
|
36
|
+
m.email = typeof obj.email === "string" ? obj.email : null;
|
|
37
|
+
if ("image" in obj)
|
|
38
|
+
m.image = typeof obj.image === "string" ? obj.image : null;
|
|
39
|
+
if ("groups" in obj) {
|
|
40
|
+
m.groups = Array.isArray(obj.groups)
|
|
41
|
+
? obj.groups.filter((g) => typeof g === "string")
|
|
42
|
+
: [];
|
|
38
43
|
}
|
|
39
44
|
out.push(m);
|
|
40
45
|
}
|
package/docs/data_fetching.md
CHANGED
|
@@ -240,7 +240,7 @@ Wire shapes per output column type:
|
|
|
240
240
|
| `date` | `"YYYY-MM-DD"` — a calendar day, no time component (a reduced-precision date field may also carry `"YYYY-MM"` or `"YYYY"`; see below) |
|
|
241
241
|
| `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
|
|
242
242
|
| `select` | `Array<{ key, label }>` — one entry per selected option |
|
|
243
|
-
| `select_member` | `Array<{ id, name, email? }>` —
|
|
243
|
+
| `select_member` | `Array<{ id, name, email?, image?, groups? }>` — the last three only for authenticated viewers of the app's own org; `image` presigned |
|
|
244
244
|
| `select_record_link` | `Array<{ id, display }>` — target record id + its display text |
|
|
245
245
|
| `files` | `Array<{ id, filename, mime_type, url, thumbnail_url?, size?, created_at? }>` — presigned |
|
|
246
246
|
|
|
@@ -268,7 +268,7 @@ emits none of these" is a fact the type states rather than one you have to remem
|
|
|
268
268
|
| `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
|
|
269
269
|
| `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
|
|
270
270
|
| `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
|
|
271
|
-
| `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email? }[]` (`[]` when empty) |
|
|
271
|
+
| `readMembers(cell)` | member cell → `ResolvedMember[]` | `{ id, name, email?, image?, groups? }[]` (`[]` when empty) |
|
|
272
272
|
| `readFiles(cell)` | files cell → `AppFile[]` | attached files with presigned URLs (`[]` when empty) |
|
|
273
273
|
| `readLocked(rowObj)` | the whole **row** → `boolean` | the record's lock state from `__source_locked` |
|
|
274
274
|
|
|
@@ -16,8 +16,8 @@ Every select and member value reaches the app in one of two shapes, and most scr
|
|
|
16
16
|
| --- | --- | --- |
|
|
17
17
|
| Render a stored select value | `readSelect(cell)` | The options the record actually holds — `{ key, label }`, **no color** |
|
|
18
18
|
| Populate a select picker, or color a stored value | `useFieldOptions(alias)` | Each select column's **complete** option list — `{ key, label, color }` — plus a `byKey` index |
|
|
19
|
-
| Render a stored member value | `readMembers(cell)` | The members the record actually holds
|
|
20
|
-
| Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster —
|
|
19
|
+
| Render a stored member value | `readMembers(cell)` | The members the record actually holds |
|
|
20
|
+
| Populate a member picker (assign UIs) | `useMembers(opts?)` | The org roster — every member, not only the referenced ones |
|
|
21
21
|
|
|
22
22
|
Cells are **self-describing**: the server rewrites raw storage shapes into resolved objects before
|
|
23
23
|
rows reach the app, so an app never maintains a hardcoded key→label or id→name map. Catalogs are
|
|
@@ -108,18 +108,24 @@ cell's own `{ key, label }`, which renders as a neutral badge. Never hand-map op
|
|
|
108
108
|
## Member cells (`readMembers`)
|
|
109
109
|
|
|
110
110
|
A `select_member` column's storage shape is a bare array of member ids. The server rewrites every
|
|
111
|
-
projected `select_member` cell to `Array<{ id, name, email? }
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
- **
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
projected `select_member` cell to `Array<{ id, name, email?, image?, groups? }>` — **the same shape
|
|
112
|
+
`useMembers` returns**, so a member is a member wherever you got them from:
|
|
113
|
+
|
|
114
|
+
- **`email`, `image` and `groups` share ONE gate.** All three are present only for authenticated
|
|
115
|
+
members of the app's own organization. Anonymous visitors to a public app — and members of *other*
|
|
116
|
+
orgs viewing a publicly shared app — get `{ id, name }` only. A face and a team are org-internal
|
|
117
|
+
exactly as an address is.
|
|
118
|
+
- **Absent is not null.** A missing `image` key means you were not told; `image: null` means the
|
|
119
|
+
member genuinely has no photo. Same for `groups`: absent vs `[]`. Rendering the two the same way
|
|
120
|
+
turns a permission boundary into a missing-data bug.
|
|
121
|
+
- **`image` is already presigned** — render it directly. It is a `/v1/files/…` object behind a
|
|
122
|
+
cookie-authenticated proxy, so an unsigned URL would not load inside an app iframe at all.
|
|
123
|
+
- **`groups` is the platform's "department".** There is no department field; a member group is what
|
|
124
|
+
an org uses to say Sale, Kế toán, CSKH. `[]` when the member is in none.
|
|
125
|
+
- **An id that no longer resolves** (removed member, id outside the org) comes back with
|
|
126
|
+
`name: null` — an explicit missing state, never an empty string. Render a placeholder.
|
|
121
127
|
- Only ids already present in the projected rows are resolved — a member cell never exposes the
|
|
122
|
-
wider directory.
|
|
128
|
+
wider directory, and only their avatars are signed.
|
|
123
129
|
|
|
124
130
|
Decode with **`readMembers(cell)`** (`dist/src/members.d.ts`) → `ResolvedMember[]`. Same defensive
|
|
125
131
|
contract as `readSelect`: `[]` for null/empty/unexpected cells, malformed entries dropped.
|
|
@@ -133,9 +139,9 @@ const { members, loading, error } = useMembers({ group: "grp_fulfillment" });
|
|
|
133
139
|
<MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
|
|
134
140
|
```
|
|
135
141
|
|
|
136
|
-
Each member is `{ id, name, email, image }`.
|
|
137
|
-
hours, or the member's external OAuth photo) and
|
|
138
|
-
members without a display name — fall back to `email`. On failure the hook does not throw: it
|
|
142
|
+
Each member is the same `ResolvedMember` a cell carries — `{ id, name, email, image, groups }`.
|
|
143
|
+
`image` is the avatar URL (a presigned URL valid 24 hours, or the member's external OAuth photo) and
|
|
144
|
+
may be `null`. `name` may be null/empty for members without a display name — fall back to `email`. On failure the hook does not throw: it
|
|
139
145
|
resolves `{ members: [], loading: false, error }`.
|
|
140
146
|
|
|
141
147
|
**Limitation:** `useMembers` is not SWR-cached — every mounted hook instance issues its own
|
|
@@ -294,7 +300,7 @@ directly (full props: `node_modules/@lotics/ui/AGENTS.md` and its `docs/`):
|
|
|
294
300
|
| Value | Component | Feed it |
|
|
295
301
|
| --- | --- | --- |
|
|
296
302
|
| A select value (stored or picker option) | `OptionBadge` | A `useFieldOptions` option, or `byKey(readSelect(cell)[0]?.key)`; accepts a single option, an array (multi → one badge each), or null (renders nothing). Missing/unknown color → neutral. |
|
|
297
|
-
| A person, inline | `MemberChip` | `name` / `image`
|
|
303
|
+
| A person, inline | `MemberChip` | `name` / `image` from a roster or a cell — both carry it; no image → initials |
|
|
298
304
|
| A member picker | `MemberSelect` | `members={useMembers().members}` — renders each option as a `MemberChip`; `MEMBER_UNASSIGNED` marks its optional "unassigned" option |
|
|
299
305
|
| A comment thread | `CommentList` + `CommentComposer` | `useComments` state; `resolveMember` bridges `member_id` → your member source |
|
|
300
306
|
|
|
@@ -305,7 +311,7 @@ The SDK never imports `@lotics/ui` — the app owns the (thin) data→UI adapter
|
|
|
305
311
|
| Surface | Embedded (signed-in member) | Standalone / public (anonymous) |
|
|
306
312
|
| --- | --- | --- |
|
|
307
313
|
| `readSelect` cell enrichment | ✓ | ✓ |
|
|
308
|
-
| `readMembers` cell enrichment | ✓ (email
|
|
314
|
+
| `readMembers` cell enrichment | ✓ (email + image + groups, same-org) | ✓ name-only |
|
|
309
315
|
| `useFieldOptions` | ✓ | ✓ |
|
|
310
316
|
| `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
|
|
311
317
|
| `useViewer` | member id (view-as target) | `null` |
|
package/docs/queries.md
CHANGED
|
@@ -124,8 +124,10 @@ Delivery-layer enrichment (applied to the response, per request):
|
|
|
124
124
|
server-bounded (§10). Public apps hand out direct presigned URLs — anonymous-fetchable,
|
|
125
125
|
time-boxed. (App workflow-execute responses presign returned files the same way, so their
|
|
126
126
|
URLs also work for anonymous public-app viewers — see [files.md](./files.md).)
|
|
127
|
-
- **`select_member` cells** — bare member-id arrays become
|
|
128
|
-
|
|
127
|
+
- **`select_member` cells** — bare member-id arrays become
|
|
128
|
+
`{ id, name, email?, image?, groups? }[]`, the same shape `useMembers` returns. The last three
|
|
129
|
+
are org-internal and share one gate: present only for authenticated members of the app's own org,
|
|
130
|
+
and `image` arrives presigned.
|
|
129
131
|
- **`select` cells** — bare option-key arrays become `{ key, label }[]` (colors ride in
|
|
130
132
|
`useFieldOptions`, not the cell — see [members_and_options.md](./members_and_options.md)).
|
|
131
133
|
**Enrichment needs the column's source addressing**, which a passthrough projection carries, a
|
|
@@ -768,10 +770,13 @@ template as derived nodes, in this order: `filter` (narrow) → `sort` (order)
|
|
|
768
770
|
it. Record-link membership (`has_any_of` by id) **works** here; so does `is_current_member`
|
|
769
771
|
on a member column. Traversals / `locked` / `current_member` do not — bake those into the
|
|
770
772
|
template.
|
|
771
|
-
- **`sort`** — `[{ field_key, order }]` over output columns — the typed
|
|
772
|
-
|
|
773
|
-
bottom
|
|
774
|
-
|
|
773
|
+
- **`sort`** — `[{ field_key, order, blank_position? }]` over output columns — the typed
|
|
774
|
+
`QuerySortKey` the hooks accept. `blank_position` is `"top" | "bottom"`, defaulting to
|
|
775
|
+
`"bottom"`. It matters on a **multi-key** sort over columns whose blankness IS the ranking —
|
|
776
|
+
a pipeline's per-step date stamps, where the first non-blank column gives the row's rung.
|
|
777
|
+
Listing the ladder top-down with blanks at the bottom orders it furthest-along-first; the
|
|
778
|
+
ascending reading needs `blank_position: "top"` on every key, and without it that direction
|
|
779
|
+
cannot be expressed at all.
|
|
775
780
|
- **`limit` / `offset` / keyset `cursor`** — pagination. `useQuery` / `usePaginatedQuery` page by
|
|
776
781
|
`offset` (`limit` clamped to the 10,000-row cap, §10); `useInfiniteQuery` opts into **keyset
|
|
777
782
|
(seek)** by sending `keyset: true` + the prior page's `cursor`, and the server returns the next
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Runtime SDK for Lotics custom-code apps
|
|
3
|
+
"version": "0.70.0",
|
|
4
|
+
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|