@colixsystems/widget-sdk 0.269.1 → 0.271.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/README.md +47 -3
- package/dist/basic-style.js +37 -0
- package/dist/contract.cjs +57 -9
- package/dist/contract.js +57 -9
- package/dist/datastore-figure.js +3 -2
- package/dist/hooks.js +39 -10
- package/dist/index.d.ts +36 -1
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +30 -11
- package/dist/linter.js +29 -11
- package/dist/live-table.js +105 -0
- package/dist/stats-hooks.js +1 -1
- package/package.json +1 -1
- package/src/contract.js +57 -9
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
50
50
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
51
51
|
| **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
|
|
52
52
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
53
|
-
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*`. `options` is `{ filter, filterMode, q, sort, limit, offset, includeCapabilities }`, applied on the server: see *Querying records*. |
|
|
53
|
+
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*`. `options` is `{ filter, filterMode, q, sort, limit, offset, includeCapabilities, live }`, applied on the server: see *Querying records*. `live: true` re-reads when anyone else changes the table: see *Live lists*. |
|
|
54
54
|
| **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
|
|
55
55
|
| **DATASTORE** | `useDatastoreRecord(table, id, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching`; `{ includeCapabilities: true }` adds `data._capabilities` | `records(table).get` — `datastore.read:<table>` |
|
|
56
56
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
@@ -59,7 +59,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
59
59
|
| **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read (with the `search` text and `filters` it read with), or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
|
|
60
60
|
| **ACTIONS** | `useAction(actionRef)` | `{ available, run, running, result, error }` | `ctx.actions` — `actions.run:*`. Run one of the workspace's Actions from an event handler (never on mount) and read the value it passed to `respond()`. The Action needs the app trigger. `available` is false when nothing is bound or the host brokers no client. Errors are `ActionError` with a stable `code`. |
|
|
61
61
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. A successful write re-reads every query, record, figure and aggregate on that table in every widget on the screen (a run of writes at most once a second, always once more after the last), so never refetch after it, and never write from an effect that re-runs on the data it reads unless a ref or Set marks what it already wrote. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
62
|
-
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the realtime socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
62
|
+
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. To keep a list, record or figure current, pass `live: true` to its read hook instead (*Live lists*); this hook is for acting on each change. Live `onCreated` / `onUpdated` / `onDeleted` off the realtime socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
63
63
|
| **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
|
|
64
64
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
65
65
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
@@ -86,7 +86,35 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
86
86
|
|
|
87
87
|
## Status
|
|
88
88
|
|
|
89
|
-
`v0.
|
|
89
|
+
`v0.271.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
90
|
+
|
|
91
|
+
### What's new in 0.271.0 (contract 1.221.0)
|
|
92
|
+
|
|
93
|
+
**Basic styling in one call.** Spread `BASIC_STYLE_SCHEMA` into `manifest.styleSchema` and call `basicStyle(style)` in the component: the five basic style fields (`background`, `textColor`, `align`, `padding`, `margin`) are declared and applied without wiring each by hand. See *Basic styling* under *Design & visual polish*.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.270.0 (contract 1.220.0)
|
|
96
|
+
|
|
97
|
+
**Live lists.** Add `live: true` to the object you already pass `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreFigure` or `useDatastoreAggregate`, and the read stays current when anyone else changes the table: `useDatastoreQuery(tableId, { sort: "created_at:desc", limit: 50, live: true })`. See *Live lists* below. The publish checker refuses a `setInterval` that re-reads records (`widget.refetchPoll`).
|
|
98
|
+
|
|
99
|
+
#### Live lists
|
|
100
|
+
|
|
101
|
+
- **What it does.** The read rides the table's realtime stream on the widget's one datastore socket (the same subscription `useDatastoreSubscription` uses), and each pushed change re-reads it the way a write does: a burst folds into one read, a busy table re-reads at most once a second and always once more after the last change. Without `live`, a read behaves exactly as before.
|
|
102
|
+
- **Permissions.** The server admits the whole-table stream only for a viewer who can read every row. Otherwise a signed-in viewer streams the rows granted to them, filtered per row, and a signed-out visitor gets no stream. The re-read is the ordinary permission-checked read, so a change to a row the viewer cannot read never changes what they see. Where nothing can stream (no socket, a refused visitor) the read re-checks every 30 seconds.
|
|
103
|
+
- **Table ids.** Pass the table id a `tableRef` property binds. A table name still reads, but only re-checks every 30 seconds.
|
|
104
|
+
- **No jumping.** A live re-read keeps `data` on screen with `loading: false`; render rows with `key={row.id}` so the ones already shown keep their place. For a newest-first list people scroll, hold new rows behind an "N new" button instead of pushing the list down:
|
|
105
|
+
|
|
106
|
+
```jsx
|
|
107
|
+
const { data, loading } = useDatastoreQuery(tableId, { sort: "created_at:desc", limit: 50, live: true });
|
|
108
|
+
const [seen, setSeen] = useState(null);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!loading && seen === null) setSeen(new Set(data.map((r) => r.id)));
|
|
111
|
+
}, [loading, data, seen]);
|
|
112
|
+
const shown = seen ? data.filter((r) => seen.has(r.id)) : data;
|
|
113
|
+
const fresh = seen ? data.length - shown.length : 0;
|
|
114
|
+
// {fresh > 0 ? <Button title={`${fresh} new`} onPress={() => setSeen(new Set(data.map((r) => r.id)))} /> : null}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **Never hand-poll.** Don't call `refetch()` from `setInterval`, and don't wire `useDatastoreSubscription` just to refresh a list. Keep that hook for acting on each change (a toast, a sound).
|
|
90
118
|
|
|
91
119
|
### What's new in 0.269.1 (contract 1.219.0)
|
|
92
120
|
|
|
@@ -3321,6 +3349,22 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3321
3349
|
|
|
3322
3350
|
**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
|
|
3323
3351
|
|
|
3352
|
+
- **Basic styling: one schema, one call.** Every generated widget offers five whole-widget style fields: `background`, `textColor`, `align`, `padding` and `margin`. Spread `BASIC_STYLE_SCHEMA` into your manifest's `styleSchema`, then apply them with `basicStyle(style)`. It returns `{ root, text }`: spread `root` on the outermost `View` (background, align as `alignItems`, per-side padding and margin) and `text` on each `Text` in a theme text colour. A field the author left unset emits nothing, so your theme-based styles show through. `appstudio-widget lint` counts the call as reading all five. When `submit_widget_source` or the Studio's widget builder publishes a widget that calls `basicStyle()`, any of the five its manifest leaves out is declared for it.
|
|
3353
|
+
|
|
3354
|
+
```jsx
|
|
3355
|
+
import { View, Text, basicStyle, BASIC_STYLE_SCHEMA } from "@colixsystems/widget-sdk";
|
|
3356
|
+
|
|
3357
|
+
export const manifest = { /* … */ styleSchema: { ...BASIC_STYLE_SCHEMA, titleColor: { type: "color", label: "Title", ui: { group: "Title" } } } };
|
|
3358
|
+
|
|
3359
|
+
export default function Hero({ title, style = {} }) {
|
|
3360
|
+
const basic = basicStyle(style);
|
|
3361
|
+
return (
|
|
3362
|
+
<View style={[styles.root, basic.root]}>
|
|
3363
|
+
<Text style={[styles.title, basic.text, style.titleColor && { color: style.titleColor }]}>{title}</Text>
|
|
3364
|
+
</View>
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
```
|
|
3324
3368
|
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
|
|
3325
3369
|
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
3326
3370
|
- **Style text by its ROLE, not by inventing a size and weight.** `theme.typography.roles` carries the workspace's type scale as six semantic tiers — `display`, `title`, `subtitle`, `body`, `caption`, `overline` — each already resolved to a `fontSize`, `fontWeight`, `fontStyle`, `lineHeight` (a MULTIPLE of the size), `letterSpacing`, `textTransform` and a `colorToken`. Pick the tier the text IS, rather than choosing `fontSize: 16, fontWeight: "600"` yourself: your widget's section title is `title`, each card or row title is `subtitle`, running text is `body`, timestamps and helper text are `caption`, a small kicker or column label is `overline`, and a headline stat is `display`. This is what stops one widget rendering a subtitle bold while the widget beside it renders one italic, and it lets the workspace re-tune every subtitle in the app from Theme Settings in one edit. **Two tokens need converting rather than copying** — `lineHeight` is a multiple (React Native wants absolute pixels, so the raw `1.15` gives you a one-pixel line box) and `colorToken` names a colour rather than being one:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The five style fields every generated widget declares, in one call: spread
|
|
2
|
+
// BASIC_STYLE_SCHEMA into `manifest.styleSchema`, apply `basicStyle(style)`.
|
|
3
|
+
// Platform-free, so the web and native entries export this one module.
|
|
4
|
+
|
|
5
|
+
import { CONTRACT } from "./contract.js";
|
|
6
|
+
import { spacingOverlay } from "./card-style.js";
|
|
7
|
+
|
|
8
|
+
export const BASIC_STYLE_SCHEMA = CONTRACT.basicStyleSchema;
|
|
9
|
+
|
|
10
|
+
const ALIGN_ITEMS = Object.freeze({
|
|
11
|
+
left: "flex-start",
|
|
12
|
+
center: "center",
|
|
13
|
+
right: "flex-end",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The basic style fields as React Native styles. Each is emitted only when the
|
|
18
|
+
* author set it, so the widget's own theme-based styles show through.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [style] the widget's `props.style`
|
|
21
|
+
* @returns {{ root: object, text: object|null }} `root` for the outermost
|
|
22
|
+
* View (backgroundColor, alignItems, padding, margin); `text` (`{ color }`)
|
|
23
|
+
* for each Text coloured by a theme text token.
|
|
24
|
+
*/
|
|
25
|
+
export function basicStyle(style) {
|
|
26
|
+
const s = style || {};
|
|
27
|
+
const alignItems = ALIGN_ITEMS[s.align];
|
|
28
|
+
return {
|
|
29
|
+
root: {
|
|
30
|
+
...(s.background ? { backgroundColor: s.background } : null),
|
|
31
|
+
...(alignItems ? { alignItems } : null),
|
|
32
|
+
...spacingOverlay(s.padding, "padding"),
|
|
33
|
+
...spacingOverlay(s.margin, "margin"),
|
|
34
|
+
},
|
|
35
|
+
text: s.textColor ? { color: s.textColor } : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/contract.cjs
CHANGED
|
@@ -389,6 +389,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
389
389
|
default: 1,
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
+
// The style fields every generated widget declares, with the controls the
|
|
393
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
394
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
395
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
396
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
397
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
398
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
399
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
400
|
+
align: Object.freeze({
|
|
401
|
+
type: "select",
|
|
402
|
+
label: "Align",
|
|
403
|
+
enum: Object.freeze([
|
|
404
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
405
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
406
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
407
|
+
]),
|
|
408
|
+
ui: BASIC_STYLE_GROUP,
|
|
409
|
+
}),
|
|
410
|
+
padding: Object.freeze({
|
|
411
|
+
type: "spacing",
|
|
412
|
+
label: "Padding",
|
|
413
|
+
validation: BASIC_SPACING_RANGE,
|
|
414
|
+
ui: BASIC_STYLE_GROUP,
|
|
415
|
+
}),
|
|
416
|
+
margin: Object.freeze({
|
|
417
|
+
type: "spacing",
|
|
418
|
+
label: "Margin",
|
|
419
|
+
validation: BASIC_SPACING_RANGE,
|
|
420
|
+
ui: BASIC_STYLE_GROUP,
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
|
|
392
424
|
// The card-surface field names shared by every widget that paints its own card
|
|
393
425
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
394
426
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1354,7 +1386,8 @@ const HOOKS = [
|
|
|
1354
1386
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1355
1387
|
"different table or record id resets to loading. " +
|
|
1356
1388
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1357
|
-
"for useDatastoreQuery."
|
|
1389
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1390
|
+
"changes the table.",
|
|
1358
1391
|
returnShape: {
|
|
1359
1392
|
data: "Record | null",
|
|
1360
1393
|
loading: "boolean",
|
|
@@ -1941,7 +1974,12 @@ const HOOKS = [
|
|
|
1941
1974
|
"meta.total), not just the page in `data`; null until known. A " +
|
|
1942
1975
|
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1943
1976
|
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1944
|
-
"subscribe for your own writes."
|
|
1977
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1978
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1979
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1980
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1981
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1982
|
+
"or wire useDatastoreSubscription for that.",
|
|
1945
1983
|
returnShape: {
|
|
1946
1984
|
data: "Record[]",
|
|
1947
1985
|
total: "number | null",
|
|
@@ -1955,8 +1993,8 @@ const HOOKS = [
|
|
|
1955
1993
|
},
|
|
1956
1994
|
{
|
|
1957
1995
|
name: "useDatastoreFigure",
|
|
1958
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1959
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1996
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1997
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1960
1998
|
description:
|
|
1961
1999
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1962
2000
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1971,7 +2009,8 @@ const HOOKS = [
|
|
|
1971
2009
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1972
2010
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1973
2011
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1974
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
2012
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
2013
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1975
2014
|
returnShape: {
|
|
1976
2015
|
value: "number | null",
|
|
1977
2016
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1989,8 +2028,8 @@ const HOOKS = [
|
|
|
1989
2028
|
{
|
|
1990
2029
|
name: "useDatastoreAggregate",
|
|
1991
2030
|
signature:
|
|
1992
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1993
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
2031
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
2032
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1994
2033
|
description:
|
|
1995
2034
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1996
2035
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2009,7 +2048,8 @@ const HOOKS = [
|
|
|
2009
2048
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2010
2049
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2011
2050
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2012
|
-
"table until a bound table is known."
|
|
2051
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2052
|
+
"anyone else changes the table.",
|
|
2013
2053
|
returnShape: {
|
|
2014
2054
|
buckets:
|
|
2015
2055
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2874,6 +2914,9 @@ const HOOKS = [
|
|
|
2874
2914
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2875
2915
|
optionalArgs: ["options"],
|
|
2876
2916
|
description:
|
|
2917
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2918
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2919
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2877
2920
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2878
2921
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2879
2922
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6495,7 +6538,11 @@ const CONTRACT = deepFreeze({
|
|
|
6495
6538
|
// not reach the script.
|
|
6496
6539
|
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6497
6540
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6498
|
-
|
|
6541
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6542
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6543
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6544
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6545
|
+
version: "1.221.0",
|
|
6499
6546
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6500
6547
|
hooks: HOOKS,
|
|
6501
6548
|
primitives: PRIMITIVES,
|
|
@@ -6520,6 +6567,7 @@ const CONTRACT = deepFreeze({
|
|
|
6520
6567
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6521
6568
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6522
6569
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6570
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6523
6571
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6524
6572
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6525
6573
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/contract.js
CHANGED
|
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
386
386
|
default: 1,
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
// The style fields every generated widget declares, with the controls the
|
|
390
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
391
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
392
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
393
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
394
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
395
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
396
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
397
|
+
align: Object.freeze({
|
|
398
|
+
type: "select",
|
|
399
|
+
label: "Align",
|
|
400
|
+
enum: Object.freeze([
|
|
401
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
402
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
403
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
404
|
+
]),
|
|
405
|
+
ui: BASIC_STYLE_GROUP,
|
|
406
|
+
}),
|
|
407
|
+
padding: Object.freeze({
|
|
408
|
+
type: "spacing",
|
|
409
|
+
label: "Padding",
|
|
410
|
+
validation: BASIC_SPACING_RANGE,
|
|
411
|
+
ui: BASIC_STYLE_GROUP,
|
|
412
|
+
}),
|
|
413
|
+
margin: Object.freeze({
|
|
414
|
+
type: "spacing",
|
|
415
|
+
label: "Margin",
|
|
416
|
+
validation: BASIC_SPACING_RANGE,
|
|
417
|
+
ui: BASIC_STYLE_GROUP,
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
|
|
389
421
|
// The card-surface field names shared by every widget that paints its own card
|
|
390
422
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
391
423
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1351,7 +1383,8 @@ const HOOKS = [
|
|
|
1351
1383
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1352
1384
|
"different table or record id resets to loading. " +
|
|
1353
1385
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1354
|
-
"for useDatastoreQuery."
|
|
1386
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1387
|
+
"changes the table.",
|
|
1355
1388
|
returnShape: {
|
|
1356
1389
|
data: "Record | null",
|
|
1357
1390
|
loading: "boolean",
|
|
@@ -1938,7 +1971,12 @@ const HOOKS = [
|
|
|
1938
1971
|
"meta.total), not just the page in `data`; null until known. A " +
|
|
1939
1972
|
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1940
1973
|
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1941
|
-
"subscribe for your own writes."
|
|
1974
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1975
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1976
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1977
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1978
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1979
|
+
"or wire useDatastoreSubscription for that.",
|
|
1942
1980
|
returnShape: {
|
|
1943
1981
|
data: "Record[]",
|
|
1944
1982
|
total: "number | null",
|
|
@@ -1952,8 +1990,8 @@ const HOOKS = [
|
|
|
1952
1990
|
},
|
|
1953
1991
|
{
|
|
1954
1992
|
name: "useDatastoreFigure",
|
|
1955
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1956
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1993
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1994
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1957
1995
|
description:
|
|
1958
1996
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1959
1997
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1968,7 +2006,8 @@ const HOOKS = [
|
|
|
1968
2006
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1969
2007
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1970
2008
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1971
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
2009
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
2010
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1972
2011
|
returnShape: {
|
|
1973
2012
|
value: "number | null",
|
|
1974
2013
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1986,8 +2025,8 @@ const HOOKS = [
|
|
|
1986
2025
|
{
|
|
1987
2026
|
name: "useDatastoreAggregate",
|
|
1988
2027
|
signature:
|
|
1989
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1990
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
2028
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
2029
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1991
2030
|
description:
|
|
1992
2031
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1993
2032
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2006,7 +2045,8 @@ const HOOKS = [
|
|
|
2006
2045
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2007
2046
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2008
2047
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2009
|
-
"table until a bound table is known."
|
|
2048
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2049
|
+
"anyone else changes the table.",
|
|
2010
2050
|
returnShape: {
|
|
2011
2051
|
buckets:
|
|
2012
2052
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2871,6 +2911,9 @@ const HOOKS = [
|
|
|
2871
2911
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2872
2912
|
optionalArgs: ["options"],
|
|
2873
2913
|
description:
|
|
2914
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2915
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2916
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2874
2917
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2875
2918
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2876
2919
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6492,7 +6535,11 @@ const CONTRACT = deepFreeze({
|
|
|
6492
6535
|
// not reach the script.
|
|
6493
6536
|
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6494
6537
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6495
|
-
|
|
6538
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6539
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6540
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6541
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6542
|
+
version: "1.221.0",
|
|
6496
6543
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6497
6544
|
hooks: HOOKS,
|
|
6498
6545
|
primitives: PRIMITIVES,
|
|
@@ -6517,6 +6564,7 @@ const CONTRACT = deepFreeze({
|
|
|
6517
6564
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6518
6565
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6519
6566
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6567
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6520
6568
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6521
6569
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6522
6570
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/datastore-figure.js
CHANGED
|
@@ -9,8 +9,9 @@ import { figureQuery, reduceFigure } from "./figure.js";
|
|
|
9
9
|
* `filter` keeps the previous figure on screen while `fetching`.
|
|
10
10
|
*/
|
|
11
11
|
export function useDatastoreFigure(table, options) {
|
|
12
|
-
const { aggregate, column, filter } = options || {};
|
|
13
|
-
const
|
|
12
|
+
const { aggregate, column, filter, live } = options || {};
|
|
13
|
+
const query = figureQuery(filter, aggregate);
|
|
14
|
+
const rows = useDatastoreQuery(table || null, live === true ? { ...query, live } : query);
|
|
14
15
|
const figure =
|
|
15
16
|
table && !rows.loading && !rows.error
|
|
16
17
|
? reduceFigure(rows.data, { aggregate, column }, rows.total)
|
package/dist/hooks.js
CHANGED
|
@@ -28,6 +28,7 @@ import React, {
|
|
|
28
28
|
useRef,
|
|
29
29
|
useState,
|
|
30
30
|
} from "react";
|
|
31
|
+
import { watchTableChanges } from "./live-table.js";
|
|
31
32
|
// REQ-L10N-WIDGET / REQ-L10N-SHARED: the translation key formats live in ONE
|
|
32
33
|
// place (contract.js). useI18n (lookup) and the backend seeder (write) both
|
|
33
34
|
// call them so the namespaced keys can never drift between the two sides.
|
|
@@ -2894,6 +2895,8 @@ const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
|
|
|
2894
2895
|
// write through any widget re-reads every other widget's rows on that table.
|
|
2895
2896
|
// A burst of writes folds into one re-read; a long run of them re-reads each
|
|
2896
2897
|
// widget at most once a second, and always once more after the last write.
|
|
2898
|
+
// sc-14423 — a change someone else made reaches only `live` readers, paced
|
|
2899
|
+
// the same way.
|
|
2897
2900
|
const TABLE_WRITE_SETTLE_MS = 50;
|
|
2898
2901
|
const TABLE_REREAD_INTERVAL_MS = 1000;
|
|
2899
2902
|
const tableWrites = new Map();
|
|
@@ -2909,7 +2912,7 @@ export function stampFetchStart(ref) {
|
|
|
2909
2912
|
function tableWritesOf(table) {
|
|
2910
2913
|
let entry = tableWrites.get(table);
|
|
2911
2914
|
if (!entry) {
|
|
2912
|
-
entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0 };
|
|
2915
|
+
entry = { listeners: new Set(), timer: null, lastRereadAt: -Infinity, wroteAt: 0, changedAt: 0 };
|
|
2913
2916
|
tableWrites.set(table, entry);
|
|
2914
2917
|
}
|
|
2915
2918
|
return entry;
|
|
@@ -2924,7 +2927,7 @@ function rereadTable(table, entry) {
|
|
|
2924
2927
|
entry.lastRereadAt = Date.now();
|
|
2925
2928
|
for (const listener of Array.from(entry.listeners)) {
|
|
2926
2929
|
try {
|
|
2927
|
-
listener(entry
|
|
2930
|
+
listener(entry);
|
|
2928
2931
|
} catch (_e) {
|
|
2929
2932
|
// A reader's failure must never reach the write that already succeeded.
|
|
2930
2933
|
}
|
|
@@ -2932,10 +2935,11 @@ function rereadTable(table, entry) {
|
|
|
2932
2935
|
forgetIfIdle(table, entry);
|
|
2933
2936
|
}
|
|
2934
2937
|
|
|
2935
|
-
function
|
|
2938
|
+
function announceTableChange(table, { local }) {
|
|
2936
2939
|
const entry = tableWritesOf(table);
|
|
2937
2940
|
readWriteOrder += 1;
|
|
2938
|
-
entry.
|
|
2941
|
+
entry.changedAt = readWriteOrder;
|
|
2942
|
+
if (local) entry.wroteAt = readWriteOrder;
|
|
2939
2943
|
if (entry.timer) return;
|
|
2940
2944
|
const wait = Math.max(TABLE_WRITE_SETTLE_MS, entry.lastRereadAt + TABLE_REREAD_INTERVAL_MS - Date.now());
|
|
2941
2945
|
entry.timer = setTimeout(() => rereadTable(table, entry), wait);
|
|
@@ -2947,15 +2951,22 @@ function announceTableWrite(table) {
|
|
|
2947
2951
|
* widget, unless the reader began a fetch of its own (stamped into
|
|
2948
2952
|
* `fetchStartRef` by stampFetchStart) after that write. Keyed by the table
|
|
2949
2953
|
* reference exactly as the widgets pass it.
|
|
2954
|
+
*
|
|
2955
|
+
* sc-14423 — `live` also re-reads when anyone else changes the table, through
|
|
2956
|
+
* the widget's datastore client's realtime subscription (see live-table.js).
|
|
2950
2957
|
*/
|
|
2951
|
-
export function useRereadOnWrite(table, reread, fetchStartRef) {
|
|
2958
|
+
export function useRereadOnWrite(table, reread, fetchStartRef, live = false) {
|
|
2959
|
+
const ctx = useContext(HostWidgetContext);
|
|
2952
2960
|
const rereadRef = useRef(reread);
|
|
2953
2961
|
rereadRef.current = reread;
|
|
2962
|
+
const liveRef = useRef(live);
|
|
2963
|
+
liveRef.current = live;
|
|
2954
2964
|
useEffect(() => {
|
|
2955
2965
|
if (!table) return undefined;
|
|
2956
2966
|
const entry = tableWritesOf(table);
|
|
2957
|
-
const listener = (wroteAt) => {
|
|
2958
|
-
|
|
2967
|
+
const listener = ({ wroteAt, changedAt }) => {
|
|
2968
|
+
const since = liveRef.current ? changedAt : wroteAt;
|
|
2969
|
+
if (!since || (fetchStartRef && fetchStartRef.current > since)) return;
|
|
2959
2970
|
rereadRef.current();
|
|
2960
2971
|
};
|
|
2961
2972
|
entry.listeners.add(listener);
|
|
@@ -2965,6 +2976,24 @@ export function useRereadOnWrite(table, reread, fetchStartRef) {
|
|
|
2965
2976
|
};
|
|
2966
2977
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2967
2978
|
}, [table]);
|
|
2979
|
+
|
|
2980
|
+
const recordsRef = useRef(null);
|
|
2981
|
+
recordsRef.current = ctx && ctx.datastore && typeof ctx.datastore.records === "function" ? ctx.datastore.records : null;
|
|
2982
|
+
const signedIn = Boolean(ctx && ctx.user && ctx.user.id);
|
|
2983
|
+
useEffect(() => {
|
|
2984
|
+
if (!live || !table || !recordsRef.current) return undefined;
|
|
2985
|
+
let ns;
|
|
2986
|
+
try {
|
|
2987
|
+
ns = recordsRef.current(table);
|
|
2988
|
+
} catch (_e) {
|
|
2989
|
+
return undefined;
|
|
2990
|
+
}
|
|
2991
|
+
return watchTableChanges(ns, {
|
|
2992
|
+
tableId: table,
|
|
2993
|
+
signedIn,
|
|
2994
|
+
onChange: () => announceTableChange(table, { local: false }),
|
|
2995
|
+
});
|
|
2996
|
+
}, [table, live, signedIn]);
|
|
2968
2997
|
}
|
|
2969
2998
|
const recordResultKey = (table, recordId, includeCapabilities = false) =>
|
|
2970
2999
|
JSON.stringify([table, recordId, includeCapabilities]);
|
|
@@ -3121,7 +3150,7 @@ export function useDatastoreQuery(table, query) {
|
|
|
3121
3150
|
// sc-1179 — auto-subscribe to the page-level refresh tick so a
|
|
3122
3151
|
// pull-to-refresh on mobile re-runs the query without per-widget code.
|
|
3123
3152
|
useRefresh(refetch);
|
|
3124
|
-
useRereadOnWrite(table, refetch, fetchStartRef);
|
|
3153
|
+
useRereadOnWrite(table, refetch, fetchStartRef, Boolean(query && query.live === true));
|
|
3125
3154
|
|
|
3126
3155
|
const currentKey = table ? queryResultKey(table, queryKey) : null;
|
|
3127
3156
|
const busy = Boolean(table) && (settled.key !== currentKey || inFlight);
|
|
@@ -3232,7 +3261,7 @@ export function useDatastoreRecord(table, recordId, options) {
|
|
|
3232
3261
|
|
|
3233
3262
|
// sc-1179 — auto-subscribe to the page-level refresh tick.
|
|
3234
3263
|
useRefresh(refetch);
|
|
3235
|
-
useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef);
|
|
3264
|
+
useRereadOnWrite(recordId ? table : null, refetch, fetchStartRef, options?.live === true);
|
|
3236
3265
|
|
|
3237
3266
|
const currentKey =
|
|
3238
3267
|
table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
|
|
@@ -4146,7 +4175,7 @@ export function useDatastoreMutation(table) {
|
|
|
4146
4175
|
} catch (err) {
|
|
4147
4176
|
throw toDatastoreError(err);
|
|
4148
4177
|
}
|
|
4149
|
-
|
|
4178
|
+
announceTableChange(t, { local: true });
|
|
4150
4179
|
return result;
|
|
4151
4180
|
}, []);
|
|
4152
4181
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1099,6 +1099,11 @@ export interface Query {
|
|
|
1099
1099
|
* that row. Gate a row's Edit / Delete on it.
|
|
1100
1100
|
*/
|
|
1101
1101
|
includeCapabilities?: boolean;
|
|
1102
|
+
/**
|
|
1103
|
+
* Re-read when anyone else changes the table, paced like a write and keeping
|
|
1104
|
+
* the rows on screen. For lists other people change; never poll by hand.
|
|
1105
|
+
*/
|
|
1106
|
+
live?: boolean;
|
|
1102
1107
|
}
|
|
1103
1108
|
|
|
1104
1109
|
/**
|
|
@@ -1148,6 +1153,11 @@ export interface FigureOptions {
|
|
|
1148
1153
|
filter?:
|
|
1149
1154
|
| Query["filter"]
|
|
1150
1155
|
| Array<{ column: string; operator: string; value?: unknown; valueMode?: string }>;
|
|
1156
|
+
/**
|
|
1157
|
+
* Re-read when anyone else changes the table, paced like a write and keeping
|
|
1158
|
+
* the figure on screen. Never poll by hand.
|
|
1159
|
+
*/
|
|
1160
|
+
live?: boolean;
|
|
1151
1161
|
}
|
|
1152
1162
|
|
|
1153
1163
|
export interface FigureResult {
|
|
@@ -1180,6 +1190,8 @@ export interface AggregateBucketSpec {
|
|
|
1180
1190
|
/** A plain number column to add up per bucket. */
|
|
1181
1191
|
sumColumn?: string;
|
|
1182
1192
|
filter?: FigureOptions["filter"];
|
|
1193
|
+
/** Re-read when anyone else changes the table. */
|
|
1194
|
+
live?: boolean;
|
|
1183
1195
|
}
|
|
1184
1196
|
|
|
1185
1197
|
export interface AggregateGroupSpec {
|
|
@@ -1192,6 +1204,8 @@ export interface AggregateGroupSpec {
|
|
|
1192
1204
|
order?: "count:desc" | "count:asc" | "sum:desc" | "sum:asc" | "group:asc" | "group:desc";
|
|
1193
1205
|
/** 1–1000. */
|
|
1194
1206
|
limit?: number;
|
|
1207
|
+
/** Re-read when anyone else changes the table. */
|
|
1208
|
+
live?: boolean;
|
|
1195
1209
|
}
|
|
1196
1210
|
|
|
1197
1211
|
export interface AggregateBucket {
|
|
@@ -1626,7 +1640,7 @@ export function useWidgetStyle(): Record<string, unknown>;
|
|
|
1626
1640
|
export function useDatastoreRecord(
|
|
1627
1641
|
tableId: string | null | undefined,
|
|
1628
1642
|
recordId: string | null | undefined,
|
|
1629
|
-
options?: { includeCapabilities?: boolean },
|
|
1643
|
+
options?: { includeCapabilities?: boolean; /** Re-read when anyone else changes the table. */ live?: boolean },
|
|
1630
1644
|
): {
|
|
1631
1645
|
data: unknown | null;
|
|
1632
1646
|
loading: boolean;
|
|
@@ -5562,6 +5576,27 @@ export function roleColor(tokens: any, colors: any): string | undefined;
|
|
|
5562
5576
|
*/
|
|
5563
5577
|
export function textRoleStyle(role: TextRole | string, typography: any, colors?: any): Record<string, any>;
|
|
5564
5578
|
|
|
5579
|
+
/**
|
|
5580
|
+
* The five style fields every generated widget declares — `background`,
|
|
5581
|
+
* `textColor`, `align`, `padding` and `margin` — as styleSchema entries.
|
|
5582
|
+
* Spread it into `manifest.styleSchema`; `basicStyle()` applies it.
|
|
5583
|
+
*/
|
|
5584
|
+
export const BASIC_STYLE_SCHEMA: Readonly<WidgetPropertySchema>;
|
|
5585
|
+
/**
|
|
5586
|
+
* The basic style fields as React Native styles, each only when set. Spread
|
|
5587
|
+
* `root` on the outermost View and `text` on each Text in a theme text colour.
|
|
5588
|
+
*
|
|
5589
|
+
* @example
|
|
5590
|
+
* const basic = basicStyle(style);
|
|
5591
|
+
* <View style={[styles.root, basic.root]}>
|
|
5592
|
+
* <Text style={[styles.title, basic.text]}>{title}</Text>
|
|
5593
|
+
* </View>
|
|
5594
|
+
*/
|
|
5595
|
+
export function basicStyle(style?: Record<string, any> | null): {
|
|
5596
|
+
root: Record<string, any>;
|
|
5597
|
+
text: { color: string } | null;
|
|
5598
|
+
};
|
|
5599
|
+
|
|
5565
5600
|
// ----------------------------------------------------- formatted content
|
|
5566
5601
|
// sc-6970 — widgets have no HTML path: they render through React Native
|
|
5567
5602
|
// primitives, which have no `dangerouslySetInnerHTML` on either host. Content
|
package/dist/index.js
CHANGED
|
@@ -268,6 +268,8 @@ export {
|
|
|
268
268
|
gradientProps,
|
|
269
269
|
cardGradientProps,
|
|
270
270
|
} from "./card-style.js";
|
|
271
|
+
// The five style fields every generated widget declares, in one call.
|
|
272
|
+
export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
|
|
271
273
|
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
272
274
|
export { Button, IconButton, BUTTON_VARIANTS } from "./button.js";
|
|
273
275
|
export {
|
package/dist/index.native.js
CHANGED
|
@@ -261,6 +261,8 @@ export {
|
|
|
261
261
|
gradientProps,
|
|
262
262
|
cardGradientProps,
|
|
263
263
|
} from "./card-style.js";
|
|
264
|
+
// The five style fields every generated widget declares, in one call.
|
|
265
|
+
export { BASIC_STYLE_SCHEMA, basicStyle } from "./basic-style.js";
|
|
264
266
|
// The themed button; follows the theme's button, destructive and ring scopes.
|
|
265
267
|
export { Button, IconButton, BUTTON_VARIANTS } from "./button.native.js";
|
|
266
268
|
export {
|
package/dist/linter.cjs
CHANGED
|
@@ -1946,11 +1946,15 @@ function lintStyleWiring(manifest, files) {
|
|
|
1946
1946
|
const code = sources
|
|
1947
1947
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
1948
1948
|
.join("\n");
|
|
1949
|
+
const appliedByHelper = callsBasicStyle(sources)
|
|
1950
|
+
? _WHOLE_WIDGET_STYLE_FIELDS
|
|
1951
|
+
: new Set();
|
|
1949
1952
|
const findings = [];
|
|
1950
1953
|
for (const field of Object.keys(schema)) {
|
|
1951
1954
|
// A key that is not a bare identifier cannot be scanned by name; the
|
|
1952
1955
|
// manifest validator rejects those anyway.
|
|
1953
1956
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
1957
|
+
if (appliedByHelper.has(field)) continue;
|
|
1954
1958
|
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
1955
1959
|
findings.push({
|
|
1956
1960
|
rule: "style-field-unread",
|
|
@@ -1961,7 +1965,8 @@ function lintStyleWiring(manifest, files) {
|
|
|
1961
1965
|
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
1962
1966
|
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
1963
1967
|
`set, so the theme still shows through while the author has chosen ` +
|
|
1964
|
-
`none. If it is not styleable, drop it from styleSchema instead
|
|
1968
|
+
`none. If it is not styleable, drop it from styleSchema instead.` +
|
|
1969
|
+
(_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
|
|
1965
1970
|
line: 0,
|
|
1966
1971
|
snippet: field,
|
|
1967
1972
|
});
|
|
@@ -1970,6 +1975,26 @@ function lintStyleWiring(manifest, files) {
|
|
|
1970
1975
|
return { ok: !hasErrors, findings };
|
|
1971
1976
|
}
|
|
1972
1977
|
|
|
1978
|
+
const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
|
|
1979
|
+
|
|
1980
|
+
const _BASIC_STYLE_FIX =
|
|
1981
|
+
` The one-line fix for every basic field: basicStyle(props.style) from ` +
|
|
1982
|
+
`@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
|
|
1983
|
+
`its .text on each Text in a theme text colour.`;
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Whether the bundle calls `basicStyle()`, which applies every basic style
|
|
1987
|
+
* field (`CONTRACT.basicStyleSchema`) in one call.
|
|
1988
|
+
*
|
|
1989
|
+
* @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
|
|
1990
|
+
* @returns {boolean}
|
|
1991
|
+
*/
|
|
1992
|
+
function callsBasicStyle(files) {
|
|
1993
|
+
return _bundleSources(files).some((s) =>
|
|
1994
|
+
_BASIC_STYLE_CALL.test(_stripNonCode(s)),
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1973
1998
|
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
1974
1999
|
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
1975
2000
|
const _GENERIC_STYLE_GROUPS = new Set([
|
|
@@ -1982,16 +2007,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
1982
2007
|
]);
|
|
1983
2008
|
|
|
1984
2009
|
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1989
|
-
"background",
|
|
1990
|
-
"textColor",
|
|
1991
|
-
"align",
|
|
1992
|
-
"padding",
|
|
1993
|
-
"margin",
|
|
1994
|
-
]);
|
|
2010
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
|
|
2011
|
+
Object.keys(CONTRACT.basicStyleSchema),
|
|
2012
|
+
);
|
|
1995
2013
|
|
|
1996
2014
|
// A second element an author would style apart from the text. Pressable and
|
|
1997
2015
|
// Button are deliberately absent: a touch target wrapping the only label is
|
|
@@ -2385,6 +2403,7 @@ function lintSource(source, options) {
|
|
|
2385
2403
|
module.exports = {
|
|
2386
2404
|
lintSource,
|
|
2387
2405
|
lintStyleWiring,
|
|
2406
|
+
callsBasicStyle,
|
|
2388
2407
|
lintEditability,
|
|
2389
2408
|
bannedIdentifiers,
|
|
2390
2409
|
narrowManifestForFile,
|
package/dist/linter.js
CHANGED
|
@@ -2009,11 +2009,15 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2009
2009
|
const code = sources
|
|
2010
2010
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
2011
2011
|
.join("\n");
|
|
2012
|
+
const appliedByHelper = callsBasicStyle(sources)
|
|
2013
|
+
? _WHOLE_WIDGET_STYLE_FIELDS
|
|
2014
|
+
: new Set();
|
|
2012
2015
|
const findings = [];
|
|
2013
2016
|
for (const field of Object.keys(schema)) {
|
|
2014
2017
|
// A key that is not a bare identifier cannot be scanned by name; the
|
|
2015
2018
|
// manifest validator rejects those anyway.
|
|
2016
2019
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
2020
|
+
if (appliedByHelper.has(field)) continue;
|
|
2017
2021
|
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
2018
2022
|
findings.push({
|
|
2019
2023
|
rule: "style-field-unread",
|
|
@@ -2024,7 +2028,8 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2024
2028
|
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
2025
2029
|
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
2026
2030
|
`set, so the theme still shows through while the author has chosen ` +
|
|
2027
|
-
`none. If it is not styleable, drop it from styleSchema instead
|
|
2031
|
+
`none. If it is not styleable, drop it from styleSchema instead.` +
|
|
2032
|
+
(_WHOLE_WIDGET_STYLE_FIELDS.has(field) ? _BASIC_STYLE_FIX : ""),
|
|
2028
2033
|
line: 0,
|
|
2029
2034
|
snippet: field,
|
|
2030
2035
|
});
|
|
@@ -2033,6 +2038,26 @@ export function lintStyleWiring(manifest, files) {
|
|
|
2033
2038
|
return { ok: !hasErrors, findings };
|
|
2034
2039
|
}
|
|
2035
2040
|
|
|
2041
|
+
const _BASIC_STYLE_CALL = /\bbasicStyle\s*\(/;
|
|
2042
|
+
|
|
2043
|
+
const _BASIC_STYLE_FIX =
|
|
2044
|
+
` The one-line fix for every basic field: basicStyle(props.style) from ` +
|
|
2045
|
+
`@colixsystems/widget-sdk — spread its .root on the outermost View and ` +
|
|
2046
|
+
`its .text on each Text in a theme text colour.`;
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Whether the bundle calls `basicStyle()`, which applies every basic style
|
|
2050
|
+
* field (`CONTRACT.basicStyleSchema`) in one call.
|
|
2051
|
+
*
|
|
2052
|
+
* @param {object|string[]|string} files bundle sources, as `lintStyleWiring` takes them
|
|
2053
|
+
* @returns {boolean}
|
|
2054
|
+
*/
|
|
2055
|
+
export function callsBasicStyle(files) {
|
|
2056
|
+
return _bundleSources(files).some((s) =>
|
|
2057
|
+
_BASIC_STYLE_CALL.test(_stripNonCode(s)),
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2036
2061
|
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
2037
2062
|
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
2038
2063
|
const _GENERIC_STYLE_GROUPS = new Set([
|
|
@@ -2045,16 +2070,9 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
2045
2070
|
]);
|
|
2046
2071
|
|
|
2047
2072
|
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
2052
|
-
"background",
|
|
2053
|
-
"textColor",
|
|
2054
|
-
"align",
|
|
2055
|
-
"padding",
|
|
2056
|
-
"margin",
|
|
2057
|
-
]);
|
|
2073
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set(
|
|
2074
|
+
Object.keys(CONTRACT.basicStyleSchema),
|
|
2075
|
+
);
|
|
2058
2076
|
|
|
2059
2077
|
// A second element an author would style apart from the text. Pressable and
|
|
2060
2078
|
// Button are deliberately absent: a touch target wrapping the only label is
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// sc-14423 — what keeps a `live: true` read current. No React, so the
|
|
2
|
+
// backend's end-to-end test drives this same code.
|
|
3
|
+
|
|
4
|
+
/** How often a live read re-reads while no realtime stream reaches it. */
|
|
5
|
+
export const LIVE_POLL_MS = 30000;
|
|
6
|
+
|
|
7
|
+
const GRANTED_SCOPE = Object.freeze({ kind: "granted" });
|
|
8
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calls `onChange()` whenever someone may have changed the table behind `ns`
|
|
12
|
+
* (`records(tableId)`); it carries no row, the caller re-reads.
|
|
13
|
+
*
|
|
14
|
+
* Streams the whole table through the client's shared subscription. Until
|
|
15
|
+
* that goes live, a signed-in viewer also streams their granted rows (the
|
|
16
|
+
* server refuses the whole table to a viewer who cannot read every row).
|
|
17
|
+
* While nothing streams it polls; after any gap it reports one change.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} ns
|
|
20
|
+
* @param {{ tableId: string, signedIn?: boolean, onChange: () => void }} options
|
|
21
|
+
* @returns {() => void} stop
|
|
22
|
+
*/
|
|
23
|
+
export function watchTableChanges(ns, { tableId, signedIn = false, onChange }) {
|
|
24
|
+
const streamable = Boolean(ns && typeof ns.subscribe === "function" && UUID_RE.test(tableId || ""));
|
|
25
|
+
const statuses = new Map();
|
|
26
|
+
const releases = new Map();
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let pollTimer = null;
|
|
29
|
+
let live = false;
|
|
30
|
+
let missed = false;
|
|
31
|
+
let tableWasLive = false;
|
|
32
|
+
|
|
33
|
+
const changed = () => {
|
|
34
|
+
if (!stopped) onChange();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function settle() {
|
|
38
|
+
const all = [...statuses.values()];
|
|
39
|
+
const nowLive = all.includes("live");
|
|
40
|
+
if (nowLive && !live && missed) changed();
|
|
41
|
+
if (!nowLive && (live || all.includes("fallback"))) missed = true;
|
|
42
|
+
if (nowLive) missed = false;
|
|
43
|
+
live = nowLive;
|
|
44
|
+
if (!live && all.every((s) => s === "fallback")) {
|
|
45
|
+
if (!pollTimer) pollTimer = setInterval(changed, LIVE_POLL_MS);
|
|
46
|
+
} else if (pollTimer) {
|
|
47
|
+
clearInterval(pollTimer);
|
|
48
|
+
pollTimer = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function close(name) {
|
|
53
|
+
releases.get(name)?.();
|
|
54
|
+
releases.delete(name);
|
|
55
|
+
statuses.delete(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function update(name, status) {
|
|
59
|
+
if (stopped || !statuses.has(name)) return;
|
|
60
|
+
statuses.set(name, status);
|
|
61
|
+
if (name === "table" && status === "live") {
|
|
62
|
+
tableWasLive = true;
|
|
63
|
+
if (statuses.has("granted")) close("granted");
|
|
64
|
+
}
|
|
65
|
+
if (name === "table" && status === "fallback" && !tableWasLive && streamable && signedIn && !statuses.has("granted")) {
|
|
66
|
+
open("granted", GRANTED_SCOPE);
|
|
67
|
+
}
|
|
68
|
+
settle();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function open(name, scope) {
|
|
72
|
+
statuses.set(name, "connecting");
|
|
73
|
+
try {
|
|
74
|
+
const release = ns.subscribe(
|
|
75
|
+
{
|
|
76
|
+
onCreated: changed,
|
|
77
|
+
onUpdated: changed,
|
|
78
|
+
onDeleted: changed,
|
|
79
|
+
onAccessGained: changed,
|
|
80
|
+
onAccessLost: changed,
|
|
81
|
+
onStatus: (s) => update(name, s),
|
|
82
|
+
},
|
|
83
|
+
scope ? { scope } : undefined,
|
|
84
|
+
);
|
|
85
|
+
if (typeof release === "function" && statuses.has(name)) releases.set(name, release);
|
|
86
|
+
else if (typeof release === "function") release();
|
|
87
|
+
} catch (_e) {
|
|
88
|
+
update(name, "fallback");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (streamable) open("table", null);
|
|
93
|
+
else {
|
|
94
|
+
statuses.set("table", "fallback");
|
|
95
|
+
settle();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return function stop() {
|
|
99
|
+
stopped = true;
|
|
100
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
101
|
+
pollTimer = null;
|
|
102
|
+
for (const release of releases.values()) release();
|
|
103
|
+
releases.clear();
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/stats-hooks.js
CHANGED
|
@@ -117,7 +117,7 @@ export function useDatastoreAggregate(table, spec) {
|
|
|
117
117
|
toDatastoreError,
|
|
118
118
|
fetchStartRef,
|
|
119
119
|
);
|
|
120
|
-
useRereadOnWrite(table, result.refetch, fetchStartRef);
|
|
120
|
+
useRereadOnWrite(table, result.refetch, fetchStartRef, Boolean(spec && spec.live === true));
|
|
121
121
|
|
|
122
122
|
const { data, ...state } = result;
|
|
123
123
|
const rows = data || [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.271.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -386,6 +386,38 @@ const THEME_SPACING_SCALE = Object.freeze({
|
|
|
386
386
|
default: 1,
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
// The style fields every generated widget declares, with the controls the
|
|
390
|
+
// Studio renders for them. One source for `BASIC_STYLE_SCHEMA` /
|
|
391
|
+
// `basicStyle()`, the linter, the publish gate and the Mason plan gate.
|
|
392
|
+
const BASIC_STYLE_GROUP = Object.freeze({ group: "Basics" });
|
|
393
|
+
const BASIC_SPACING_RANGE = Object.freeze({ min: 0, max: 64 });
|
|
394
|
+
const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
395
|
+
background: Object.freeze({ type: "color", label: "Background", ui: BASIC_STYLE_GROUP }),
|
|
396
|
+
textColor: Object.freeze({ type: "color", label: "Text color", ui: BASIC_STYLE_GROUP }),
|
|
397
|
+
align: Object.freeze({
|
|
398
|
+
type: "select",
|
|
399
|
+
label: "Align",
|
|
400
|
+
enum: Object.freeze([
|
|
401
|
+
Object.freeze({ value: "left", label: "Left" }),
|
|
402
|
+
Object.freeze({ value: "center", label: "Center" }),
|
|
403
|
+
Object.freeze({ value: "right", label: "Right" }),
|
|
404
|
+
]),
|
|
405
|
+
ui: BASIC_STYLE_GROUP,
|
|
406
|
+
}),
|
|
407
|
+
padding: Object.freeze({
|
|
408
|
+
type: "spacing",
|
|
409
|
+
label: "Padding",
|
|
410
|
+
validation: BASIC_SPACING_RANGE,
|
|
411
|
+
ui: BASIC_STYLE_GROUP,
|
|
412
|
+
}),
|
|
413
|
+
margin: Object.freeze({
|
|
414
|
+
type: "spacing",
|
|
415
|
+
label: "Margin",
|
|
416
|
+
validation: BASIC_SPACING_RANGE,
|
|
417
|
+
ui: BASIC_STYLE_GROUP,
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
|
|
389
421
|
// The card-surface field names shared by every widget that paints its own card
|
|
390
422
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
391
423
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1351,7 +1383,8 @@ const HOOKS = [
|
|
|
1351
1383
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1352
1384
|
"different table or record id resets to loading. " +
|
|
1353
1385
|
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1354
|
-
"for useDatastoreQuery."
|
|
1386
|
+
"for useDatastoreQuery. `{ live: true }` re-reads it when anyone else " +
|
|
1387
|
+
"changes the table.",
|
|
1355
1388
|
returnShape: {
|
|
1356
1389
|
data: "Record | null",
|
|
1357
1390
|
loading: "boolean",
|
|
@@ -1938,7 +1971,12 @@ const HOOKS = [
|
|
|
1938
1971
|
"meta.total), not just the page in `data`; null until known. A " +
|
|
1939
1972
|
"successful useDatastoreMutation write to the table, from this widget " +
|
|
1940
1973
|
"or any other on the screen, re-reads it on its own: never refetch or " +
|
|
1941
|
-
"subscribe for your own writes."
|
|
1974
|
+
"subscribe for your own writes. Add `live: true` to the options to also " +
|
|
1975
|
+
"re-read when anyone else changes the table (a pushed change, paced like " +
|
|
1976
|
+
"a write; the re-read is the same permission-checked read, and keeps the " +
|
|
1977
|
+
"rows on screen with loading false). Use it for lists other people " +
|
|
1978
|
+
"change: queues, bookings, chat, dashboards. Never poll with setInterval " +
|
|
1979
|
+
"or wire useDatastoreSubscription for that.",
|
|
1942
1980
|
returnShape: {
|
|
1943
1981
|
data: "Record[]",
|
|
1944
1982
|
total: "number | null",
|
|
@@ -1952,8 +1990,8 @@ const HOOKS = [
|
|
|
1952
1990
|
},
|
|
1953
1991
|
{
|
|
1954
1992
|
name: "useDatastoreFigure",
|
|
1955
|
-
signature: "useDatastoreFigure(tableId, { aggregate, column, filter })",
|
|
1956
|
-
optionalArgs: ["aggregate", "column", "filter"],
|
|
1993
|
+
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
1994
|
+
optionalArgs: ["aggregate", "column", "filter", "live"],
|
|
1957
1995
|
description:
|
|
1958
1996
|
"ONE live figure over a table, for a `<Stat>`: `aggregate` is " +
|
|
1959
1997
|
"\"count\" (the default; the rows, or the rows `filter` matches), \"sum\" " +
|
|
@@ -1968,7 +2006,8 @@ const HOOKS = [
|
|
|
1968
2006
|
"or average reads up to 1000 rows, skips blank cells rather than counting " +
|
|
1969
2007
|
"them as zero, and past that reads \"≈ n\" (`truncated: true`). The stock " +
|
|
1970
2008
|
"Stat widget computes its figure through this hook, so use it rather than " +
|
|
1971
|
-
"reducing useDatastoreQuery rows by hand. Call it once per figure."
|
|
2009
|
+
"reducing useDatastoreQuery rows by hand. Call it once per figure. " +
|
|
2010
|
+
"`live: true` re-reads it when anyone else changes the table.",
|
|
1972
2011
|
returnShape: {
|
|
1973
2012
|
value: "number | null",
|
|
1974
2013
|
text: "string | null // the formatted figure for <Stat value>",
|
|
@@ -1986,8 +2025,8 @@ const HOOKS = [
|
|
|
1986
2025
|
{
|
|
1987
2026
|
name: "useDatastoreAggregate",
|
|
1988
2027
|
signature:
|
|
1989
|
-
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit })",
|
|
1990
|
-
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit"],
|
|
2028
|
+
"useDatastoreAggregate(tableId, { groupBy, bucket, from, to, sumColumn, filter, order, limit, live })",
|
|
2029
|
+
optionalArgs: ["groupBy", "bucket", "from", "to", "sumColumn", "filter", "order", "limit", "live"],
|
|
1991
2030
|
description:
|
|
1992
2031
|
"Counts rows on the server over EVERY row the viewer can read, so it is " +
|
|
1993
2032
|
"exact past the 1000-row page a list read returns — never group " +
|
|
@@ -2006,7 +2045,8 @@ const HOOKS = [
|
|
|
2006
2045
|
"them out with a `nempty:` filter. Encrypted, array, file and relation " +
|
|
2007
2046
|
"columns cannot be grouped (VALIDATION). `filter` takes the same shapes " +
|
|
2008
2047
|
"as useDatastoreQuery's, created_at ranges included. Pass null as the " +
|
|
2009
|
-
"table until a bound table is known."
|
|
2048
|
+
"table until a bound table is known. `live: true` re-reads it when " +
|
|
2049
|
+
"anyone else changes the table.",
|
|
2010
2050
|
returnShape: {
|
|
2011
2051
|
buckets:
|
|
2012
2052
|
"Array<{ date, count, sum }> // date = the bucket's start, YYYY-MM-DD; sum is 0 without sumColumn",
|
|
@@ -2871,6 +2911,9 @@ const HOOKS = [
|
|
|
2871
2911
|
signature: "useDatastoreSubscription(tableId, handlers, options)",
|
|
2872
2912
|
optionalArgs: ["options"],
|
|
2873
2913
|
description:
|
|
2914
|
+
"To keep a list, record, figure or aggregate current, pass `live: true` " +
|
|
2915
|
+
"to its read hook instead; this hook is for acting on each change " +
|
|
2916
|
+
"(a toast, a sound, merging rows yourself). " +
|
|
2874
2917
|
"Subscribe to a table's realtime change stream via the injected " +
|
|
2875
2918
|
"datastore-client at ctx.datastore.records(tableId).subscribe(...). " +
|
|
2876
2919
|
"handlers is { onCreated?, onUpdated?, onDeleted? }; each receives the " +
|
|
@@ -6492,7 +6535,11 @@ const CONTRACT = deepFreeze({
|
|
|
6492
6535
|
// not reach the script.
|
|
6493
6536
|
// 1.219.0: behaviour (sc-14407) — a useDatastoreMutation write re-reads
|
|
6494
6537
|
// every query, record, figure and aggregate on that table, app-wide.
|
|
6495
|
-
|
|
6538
|
+
// 1.220.0: additive (sc-14423) — `live: true` on useDatastoreQuery,
|
|
6539
|
+
// useDatastoreRecord, useDatastoreFigure and useDatastoreAggregate.
|
|
6540
|
+
// 1.221.0: additive (sc-14460) — `basicStyleSchema`, exported as
|
|
6541
|
+
// `BASIC_STYLE_SCHEMA`, and `basicStyle(style)`, which applies it.
|
|
6542
|
+
version: "1.221.0",
|
|
6496
6543
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6497
6544
|
hooks: HOOKS,
|
|
6498
6545
|
primitives: PRIMITIVES,
|
|
@@ -6517,6 +6564,7 @@ const CONTRACT = deepFreeze({
|
|
|
6517
6564
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
6518
6565
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6519
6566
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6567
|
+
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6520
6568
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6521
6569
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6522
6570
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|