@colixsystems/widget-sdk 0.280.0 → 0.283.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 +21 -17
- package/dist/contract.cjs +61 -5
- package/dist/contract.js +61 -5
- package/dist/hooks.js +15 -12
- package/dist/index.d.ts +46 -2
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/dist/paged-query.js +193 -0
- package/dist/source-mask.cjs +18 -4
- package/dist/source-mask.js +18 -4
- package/package.json +1 -1
- package/src/contract.js +61 -5
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
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
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
|
+
| **DATASTORE** | `usePagedDatastoreQuery(table, { pageSize, ...query })` | `{ data, total, hasMore, loadingMore, error, loadMore, retry, … }` | A growing list, a page at a time: see *Querying records*. |
|
|
54
55
|
| **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
56
|
| **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
57
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
@@ -88,7 +89,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
88
89
|
|
|
89
90
|
## Status
|
|
90
91
|
|
|
91
|
-
`v0.
|
|
92
|
+
`v0.283.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**.
|
|
93
|
+
|
|
94
|
+
### What's new in 0.283.0 (contract 1.232.0)
|
|
95
|
+
|
|
96
|
+
**`usePagedDatastoreQuery(tableId, { pageSize, sort, filter, … })` — a list of records a page at a time.** A custom list read `useDatastoreQuery` with no `limit`, so it showed one server page and nothing said the rest existed. The new hook reads the first `pageSize` rows (default 25, 1-500), and `loadMore()` appends the next page by offset, so a tap reads one page instead of the whole list again. It returns `{ data, total, hasMore, loading, fetching, loadingMore, error, loadMore, retry, refetch }`: show a Load more `<Button busy={loadingMore}>` while `hasMore`. A failed page sets `error` and keeps the rows shown, and `retry()` fetches it again. A changed sort, filter, search or page size starts from the first page. A write, `live`, `refetch()` or pull-to-refresh re-reads every page shown. `PAGE_SIZE_PROPERTY` is the standard "Rows per page" property, the one the stock Data List shows: declare `pageSize: PAGE_SIZE_PROPERTY` in your `propertySchema` and pass the prop to the hook. The stock Data List now pages through this hook.
|
|
97
|
+
|
|
98
|
+
### What's new in 0.281.0 (contract 1.230.0)
|
|
99
|
+
|
|
100
|
+
**Paid page access in the store builds.** A workspace can sell a `useAccessPlans()` plan in its iOS and Android apps by naming a RevenueCat entitlement for it. In those builds `purchase(planId)` then opens the store's own in-app purchase sheet, and access arrives when RevenueCat confirms the purchase — call `refresh()` a moment later. Each plan now carries `purchase_available`; `purchaseAvailable` is `false` only when no plan can be bought here, so check the plan's own flag before showing its button. A plan or paid-app access bought on the web also grants its RevenueCat entitlement, so `usePurchases().entitlements` in the app includes it. `usePurchases()` itself stays native-only. The payments client's `purchaseAccessPlan()` may answer with `store_product_id` (`@colixsystems/payments-client` 0.4.0).
|
|
92
101
|
|
|
93
102
|
### What's new in 0.280.0 (contract 1.229.0)
|
|
94
103
|
|
|
@@ -3695,44 +3704,39 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
|
|
|
3695
3704
|
|
|
3696
3705
|
The filter operators are `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains` (a case-insensitive substring), `empty` and `nempty` (no value). On a list column (`STRING_ARRAY`, `INT_ARRAY`), `eq` keeps the rows whose list holds the value exactly, `empty` / `nempty` test whether it holds any, and every other operator is refused. In the array form a condition may carry `valueMode`: `"currentUser"` or `"currentUserGroup"` with `eq` or `neq` matches the signed-in user (or their groups) on a USER or USER_GROUP column, and `"relativeDate"` with an ordering operator reads `value` as "that many days ago".
|
|
3697
3706
|
|
|
3698
|
-
The result's `total` is how many rows match in all, not just this page
|
|
3707
|
+
The result's `total` is how many rows match in all, not just this page. **A list that grows reads its rows with `usePagedDatastoreQuery(tableId, { pageSize, ...query })`**, which takes the same options bar `limit` / `offset`, sets those itself, and appends the next page on `loadMore()`. Declare `pageSize: PAGE_SIZE_PROPERTY` in the `propertySchema` so the maker sets the rows per page. For a count, a sum or an average, use `useDatastoreFigure(tableId, { aggregate, column, filter })` instead of reading rows.
|
|
3699
3708
|
|
|
3700
3709
|
**Narrow on the server, never over a fetched batch.** A widget that fetches `limit: 1000` and then filters or searches the rows in JavaScript only ever sees the first 1000; once the table grows past that, it misses rows without saying so. Send the search box's text as `q`, a chip's condition as `filter`, and page with `offset`. The checker warns about the batch pattern.
|
|
3701
3710
|
|
|
3702
3711
|
```js
|
|
3703
3712
|
import { useEffect, useState } from "react";
|
|
3704
|
-
import { Pressable, Text, TextInput, View,
|
|
3705
|
-
|
|
3706
|
-
const PAGE = 50;
|
|
3713
|
+
import { Button, Pressable, Text, TextInput, View, usePagedDatastoreQuery, useStableQuery } from "@colixsystems/widget-sdk";
|
|
3707
3714
|
|
|
3708
|
-
|
|
3715
|
+
// manifest.propertySchema: { tableId: { type: "tableRef", … }, pageSize: PAGE_SIZE_PROPERTY }
|
|
3716
|
+
export default function ContactList({ tableId, pageSize }) {
|
|
3709
3717
|
const [draft, setDraft] = useState("");
|
|
3710
3718
|
const [search, setSearch] = useState("");
|
|
3711
3719
|
const [status, setStatus] = useState("");
|
|
3712
|
-
const [page, setPage] = useState(0);
|
|
3713
3720
|
useEffect(() => {
|
|
3714
|
-
const wait = setTimeout(() =>
|
|
3721
|
+
const wait = setTimeout(() => setSearch(draft.trim()), 250);
|
|
3715
3722
|
return () => clearTimeout(wait);
|
|
3716
3723
|
}, [draft]);
|
|
3724
|
+
// A new search or status starts again from the first page.
|
|
3717
3725
|
const query = useStableQuery(() => ({
|
|
3718
3726
|
q: search,
|
|
3719
3727
|
filter: status ? { status: `eq:${status}` } : undefined,
|
|
3720
3728
|
sort: { field: "last_contacted", dir: "desc" },
|
|
3721
|
-
|
|
3722
|
-
offset: page * PAGE,
|
|
3729
|
+
pageSize,
|
|
3723
3730
|
}));
|
|
3724
|
-
const { data, total, loading, error } =
|
|
3725
|
-
const pick = (next) => { setStatus(next); setPage(0); };
|
|
3731
|
+
const { data, total, loading, error, hasMore, loadingMore, loadMore, retry } = usePagedDatastoreQuery(tableId, query);
|
|
3726
3732
|
return (
|
|
3727
3733
|
<View>
|
|
3728
3734
|
<TextInput value={draft} onChangeText={setDraft} placeholder="Search" />
|
|
3729
|
-
<Pressable onPress={() =>
|
|
3730
|
-
{error ? <Text>{error.message}</Text> : null}
|
|
3735
|
+
<Pressable onPress={() => setStatus(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
|
|
3731
3736
|
{loading ? <Text>Loading…</Text> : data.map((row) => <Text key={row.id}>{row.name}</Text>)}
|
|
3737
|
+
{error ? <Button variant="ghost" title="Retry" onPress={retry} /> : null}
|
|
3732
3738
|
<Text>{total ?? 0} contacts</Text>
|
|
3733
|
-
{
|
|
3734
|
-
<Pressable onPress={() => setPage(page + 1)}><Text>Next page</Text></Pressable>
|
|
3735
|
-
) : null}
|
|
3739
|
+
{hasMore ? <Button variant="ghost" title="Load more" busy={loadingMore} onPress={loadMore} /> : null}
|
|
3736
3740
|
</View>
|
|
3737
3741
|
);
|
|
3738
3742
|
}
|
package/dist/contract.cjs
CHANGED
|
@@ -422,6 +422,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
|
422
422
|
}),
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
// sc-14120 — the "Rows per page" property a paged list declares, the same one
|
|
426
|
+
// the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
|
|
427
|
+
const PAGE_SIZE_PROPERTY = Object.freeze({
|
|
428
|
+
type: "number",
|
|
429
|
+
label: "Rows per page",
|
|
430
|
+
description: "How many records to load at first; a Load more control fetches the next batch.",
|
|
431
|
+
default: 25,
|
|
432
|
+
validation: Object.freeze({ min: 1, max: 500 }),
|
|
433
|
+
});
|
|
434
|
+
|
|
425
435
|
// The card-surface field names shared by every widget that paints its own card
|
|
426
436
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
427
437
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1992,6 +2002,42 @@ const HOOKS = [
|
|
|
1992
2002
|
requiredContextSlice: ["datastore.records"],
|
|
1993
2003
|
scopes: ["datastore.read:*"],
|
|
1994
2004
|
},
|
|
2005
|
+
{
|
|
2006
|
+
name: "usePagedDatastoreQuery",
|
|
2007
|
+
signature: "usePagedDatastoreQuery(tableId, options)",
|
|
2008
|
+
optionalArgs: ["options"],
|
|
2009
|
+
description:
|
|
2010
|
+
"Reads a table's rows a page at a time, for any list of records that " +
|
|
2011
|
+
"can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
|
|
2012
|
+
"`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
|
|
2013
|
+
"`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
|
|
2014
|
+
"`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
|
|
2015
|
+
"the next page after the rows in `data`, so each tap reads one page, not " +
|
|
2016
|
+
"the whole list again. `hasMore` is false once every row the query " +
|
|
2017
|
+
"matches (`total`) is loaded. `loadingMore` is true while a page is " +
|
|
2018
|
+
"fetched: show the Load more button busy on it, and hide the button when " +
|
|
2019
|
+
"`hasMore` is false. A failed page sets `error` and keeps the rows " +
|
|
2020
|
+
"already shown; `retry()` fetches it again. A changed sort, filter, " +
|
|
2021
|
+
"search or pageSize starts again from the first page. A write to the " +
|
|
2022
|
+
"table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
|
|
2023
|
+
"Take the size from the maker's \"Rows per page\" property: declare " +
|
|
2024
|
+
"the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
|
|
2025
|
+
"PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
|
|
2026
|
+
returnShape: {
|
|
2027
|
+
data: "Record[]",
|
|
2028
|
+
total: "number | null",
|
|
2029
|
+
hasMore: "boolean",
|
|
2030
|
+
loading: "boolean",
|
|
2031
|
+
fetching: "boolean",
|
|
2032
|
+
loadingMore: "boolean",
|
|
2033
|
+
error: "DatastoreError | null",
|
|
2034
|
+
loadMore: "() => void",
|
|
2035
|
+
retry: "() => void",
|
|
2036
|
+
refetch: "() => Promise<void>",
|
|
2037
|
+
},
|
|
2038
|
+
requiredContextSlice: ["datastore.records"],
|
|
2039
|
+
scopes: ["datastore.read:*"],
|
|
2040
|
+
},
|
|
1995
2041
|
{
|
|
1996
2042
|
name: "useDatastoreFigure",
|
|
1997
2043
|
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
@@ -2343,13 +2389,13 @@ const HOOKS = [
|
|
|
2343
2389
|
signature: "useAccessPlans()",
|
|
2344
2390
|
returnShape: {
|
|
2345
2391
|
plans:
|
|
2346
|
-
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
|
|
2392
|
+
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
|
|
2347
2393
|
loading: "boolean",
|
|
2348
2394
|
error: "PaymentError | null",
|
|
2349
2395
|
purchaseAvailable:
|
|
2350
|
-
"boolean // false
|
|
2396
|
+
"boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
|
|
2351
2397
|
purchase:
|
|
2352
|
-
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
|
|
2398
|
+
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
|
|
2353
2399
|
cancel:
|
|
2354
2400
|
"(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
|
|
2355
2401
|
refresh: "() => Promise<void> // re-read after returning from Checkout",
|
|
@@ -3171,7 +3217,9 @@ const HOOKS = [
|
|
|
3171
3217
|
"the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
|
|
3172
3218
|
"browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
|
|
3173
3219
|
"— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
|
|
3174
|
-
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app."
|
|
3220
|
+
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
|
|
3221
|
+
"page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
|
|
3222
|
+
"RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
|
|
3175
3223
|
returnShape: {
|
|
3176
3224
|
supported: "boolean // false on the web Player and in an app built without RevenueCat",
|
|
3177
3225
|
loading: "boolean",
|
|
@@ -6662,7 +6710,14 @@ const CONTRACT = deepFreeze({
|
|
|
6662
6710
|
// `kind` (physical | service | digital); three new PaymentError codes.
|
|
6663
6711
|
// 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
|
|
6664
6712
|
// gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
|
|
6665
|
-
|
|
6713
|
+
// 1.230.0: additive (sc-15164) — each access plan carries
|
|
6714
|
+
// `purchase_available`; a store build buys a plan the workspace sells
|
|
6715
|
+
// in-app through the store, and web purchases grant RevenueCat entitlements.
|
|
6716
|
+
// 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
|
|
6717
|
+
// page at a time, with loadMore / hasMore / retry) and
|
|
6718
|
+
// `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
|
|
6719
|
+
// "Rows per page" property.
|
|
6720
|
+
version: "1.232.0",
|
|
6666
6721
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6667
6722
|
hooks: HOOKS,
|
|
6668
6723
|
primitives: PRIMITIVES,
|
|
@@ -6688,6 +6743,7 @@ const CONTRACT = deepFreeze({
|
|
|
6688
6743
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6689
6744
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6690
6745
|
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6746
|
+
pageSizeProperty: PAGE_SIZE_PROPERTY,
|
|
6691
6747
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6692
6748
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6693
6749
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/contract.js
CHANGED
|
@@ -418,6 +418,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
|
418
418
|
}),
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
+
// sc-14120 — the "Rows per page" property a paged list declares, the same one
|
|
422
|
+
// the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
|
|
423
|
+
const PAGE_SIZE_PROPERTY = Object.freeze({
|
|
424
|
+
type: "number",
|
|
425
|
+
label: "Rows per page",
|
|
426
|
+
description: "How many records to load at first; a Load more control fetches the next batch.",
|
|
427
|
+
default: 25,
|
|
428
|
+
validation: Object.freeze({ min: 1, max: 500 }),
|
|
429
|
+
});
|
|
430
|
+
|
|
421
431
|
// The card-surface field names shared by every widget that paints its own card
|
|
422
432
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
423
433
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1988,6 +1998,42 @@ const HOOKS = [
|
|
|
1988
1998
|
requiredContextSlice: ["datastore.records"],
|
|
1989
1999
|
scopes: ["datastore.read:*"],
|
|
1990
2000
|
},
|
|
2001
|
+
{
|
|
2002
|
+
name: "usePagedDatastoreQuery",
|
|
2003
|
+
signature: "usePagedDatastoreQuery(tableId, options)",
|
|
2004
|
+
optionalArgs: ["options"],
|
|
2005
|
+
description:
|
|
2006
|
+
"Reads a table's rows a page at a time, for any list of records that " +
|
|
2007
|
+
"can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
|
|
2008
|
+
"`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
|
|
2009
|
+
"`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
|
|
2010
|
+
"`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
|
|
2011
|
+
"the next page after the rows in `data`, so each tap reads one page, not " +
|
|
2012
|
+
"the whole list again. `hasMore` is false once every row the query " +
|
|
2013
|
+
"matches (`total`) is loaded. `loadingMore` is true while a page is " +
|
|
2014
|
+
"fetched: show the Load more button busy on it, and hide the button when " +
|
|
2015
|
+
"`hasMore` is false. A failed page sets `error` and keeps the rows " +
|
|
2016
|
+
"already shown; `retry()` fetches it again. A changed sort, filter, " +
|
|
2017
|
+
"search or pageSize starts again from the first page. A write to the " +
|
|
2018
|
+
"table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
|
|
2019
|
+
"Take the size from the maker's \"Rows per page\" property: declare " +
|
|
2020
|
+
"the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
|
|
2021
|
+
"PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
|
|
2022
|
+
returnShape: {
|
|
2023
|
+
data: "Record[]",
|
|
2024
|
+
total: "number | null",
|
|
2025
|
+
hasMore: "boolean",
|
|
2026
|
+
loading: "boolean",
|
|
2027
|
+
fetching: "boolean",
|
|
2028
|
+
loadingMore: "boolean",
|
|
2029
|
+
error: "DatastoreError | null",
|
|
2030
|
+
loadMore: "() => void",
|
|
2031
|
+
retry: "() => void",
|
|
2032
|
+
refetch: "() => Promise<void>",
|
|
2033
|
+
},
|
|
2034
|
+
requiredContextSlice: ["datastore.records"],
|
|
2035
|
+
scopes: ["datastore.read:*"],
|
|
2036
|
+
},
|
|
1991
2037
|
{
|
|
1992
2038
|
name: "useDatastoreFigure",
|
|
1993
2039
|
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
@@ -2339,13 +2385,13 @@ const HOOKS = [
|
|
|
2339
2385
|
signature: "useAccessPlans()",
|
|
2340
2386
|
returnShape: {
|
|
2341
2387
|
plans:
|
|
2342
|
-
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
|
|
2388
|
+
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
|
|
2343
2389
|
loading: "boolean",
|
|
2344
2390
|
error: "PaymentError | null",
|
|
2345
2391
|
purchaseAvailable:
|
|
2346
|
-
"boolean // false
|
|
2392
|
+
"boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
|
|
2347
2393
|
purchase:
|
|
2348
|
-
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
|
|
2394
|
+
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
|
|
2349
2395
|
cancel:
|
|
2350
2396
|
"(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
|
|
2351
2397
|
refresh: "() => Promise<void> // re-read after returning from Checkout",
|
|
@@ -3167,7 +3213,9 @@ const HOOKS = [
|
|
|
3167
3213
|
"the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
|
|
3168
3214
|
"browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
|
|
3169
3215
|
"— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
|
|
3170
|
-
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app."
|
|
3216
|
+
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
|
|
3217
|
+
"page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
|
|
3218
|
+
"RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
|
|
3171
3219
|
returnShape: {
|
|
3172
3220
|
supported: "boolean // false on the web Player and in an app built without RevenueCat",
|
|
3173
3221
|
loading: "boolean",
|
|
@@ -6658,7 +6706,14 @@ const CONTRACT = deepFreeze({
|
|
|
6658
6706
|
// `kind` (physical | service | digital); three new PaymentError codes.
|
|
6659
6707
|
// 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
|
|
6660
6708
|
// gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
|
|
6661
|
-
|
|
6709
|
+
// 1.230.0: additive (sc-15164) — each access plan carries
|
|
6710
|
+
// `purchase_available`; a store build buys a plan the workspace sells
|
|
6711
|
+
// in-app through the store, and web purchases grant RevenueCat entitlements.
|
|
6712
|
+
// 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
|
|
6713
|
+
// page at a time, with loadMore / hasMore / retry) and
|
|
6714
|
+
// `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
|
|
6715
|
+
// "Rows per page" property.
|
|
6716
|
+
version: "1.232.0",
|
|
6662
6717
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6663
6718
|
hooks: HOOKS,
|
|
6664
6719
|
primitives: PRIMITIVES,
|
|
@@ -6684,6 +6739,7 @@ const CONTRACT = deepFreeze({
|
|
|
6684
6739
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6685
6740
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6686
6741
|
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6742
|
+
pageSizeProperty: PAGE_SIZE_PROPERTY,
|
|
6687
6743
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6688
6744
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6689
6745
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|
package/dist/hooks.js
CHANGED
|
@@ -7075,21 +7075,24 @@ export function usePayments() {
|
|
|
7075
7075
|
* `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }`:
|
|
7076
7076
|
*
|
|
7077
7077
|
* plans → [{ id, name, price_cents, currency, billing, owned, renews,
|
|
7078
|
-
* access_ends_at }] — `billing` is "one_time" or
|
|
7079
|
-
* true once the user holds the plan; `renews` while
|
|
7080
|
-
* will charge again; `access_ends_at` is when held
|
|
7081
|
-
* renewal (null for one-time access)
|
|
7078
|
+
* access_ends_at, purchase_available }] — `billing` is "one_time" or
|
|
7079
|
+
* "monthly"; `owned` is true once the user holds the plan; `renews` while
|
|
7080
|
+
* a held monthly plan will charge again; `access_ends_at` is when held
|
|
7081
|
+
* access ends without a renewal (null for one-time access);
|
|
7082
|
+
* `purchase_available` is whether this plan can be bought here.
|
|
7082
7083
|
* Render the price with `useWorkspaceCurrency().formatMoney(price_cents)`.
|
|
7083
|
-
* purchaseAvailable → false where
|
|
7084
|
-
* digital good, and an iOS / Android build
|
|
7085
|
-
*
|
|
7086
|
-
*
|
|
7084
|
+
* purchaseAvailable → false where no plan can be bought here: page access is
|
|
7085
|
+
* a digital good, and an iOS / Android build sells only the plans the
|
|
7086
|
+
* workspace sells in-app (through store billing). Render a visible "not
|
|
7087
|
+
* available in this app" state for a plan whose `purchase_available` is
|
|
7088
|
+
* false — never a dead button and never a blank render.
|
|
7087
7089
|
* purchase(planId, { return_path? }?) → Promise<{ plan_id, status }>. Pass
|
|
7088
7090
|
* ONLY the plan id: the server sets the price and the group, so a widget can
|
|
7089
|
-
* never name an amount. The HOST
|
|
7090
|
-
* in-app
|
|
7091
|
-
* once (the mock provider) and
|
|
7092
|
-
*
|
|
7091
|
+
* never name an amount. The HOST takes the payment: hosted Checkout on the
|
|
7092
|
+
* web, the store's in-app purchase sheet in an iOS / Android build. `status`
|
|
7093
|
+
* is "ACTIVE" when access was granted at once (the mock provider) and
|
|
7094
|
+
* "PENDING" while payment is confirmed; access arrives with the
|
|
7095
|
+
* confirmation, never from the client.
|
|
7093
7096
|
* cancel(planId) → Promise<{ plan_id, renews: false, access_ends_at }> —
|
|
7094
7097
|
* stop a held monthly plan renewing (`renews` true). Access continues
|
|
7095
7098
|
* until `access_ends_at`; offer it beside every renewing plan.
|
package/dist/index.d.ts
CHANGED
|
@@ -1160,6 +1160,48 @@ export function useDatastoreQuery<T = unknown>(
|
|
|
1160
1160
|
query?: Query,
|
|
1161
1161
|
): QueryResult<T>;
|
|
1162
1162
|
|
|
1163
|
+
export interface PagedQueryOptions extends Omit<Query, "limit" | "offset"> {
|
|
1164
|
+
/** Rows per page: default 25, clamped to 1-500. Pass the `pageSize` prop. */
|
|
1165
|
+
pageSize?: number;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export interface PagedQueryResult<T> {
|
|
1169
|
+
/** Every row loaded so far, first page first. */
|
|
1170
|
+
data: T[];
|
|
1171
|
+
/** How many rows the query matches in all; null until known. */
|
|
1172
|
+
total: number | null;
|
|
1173
|
+
/** False once every matching row is loaded. Hide Load more on it. */
|
|
1174
|
+
hasMore: boolean;
|
|
1175
|
+
/** True only until the first page lands. */
|
|
1176
|
+
loading: boolean;
|
|
1177
|
+
/** True while any page, or a re-read of them, is in flight. */
|
|
1178
|
+
fetching: boolean;
|
|
1179
|
+
/** True while `loadMore()` fetches the next page. */
|
|
1180
|
+
loadingMore: boolean;
|
|
1181
|
+
/** A failed page; the rows already loaded stay in `data`. */
|
|
1182
|
+
error: Error | null;
|
|
1183
|
+
/** Appends the next page. Does nothing when `hasMore` is false. */
|
|
1184
|
+
loadMore(): void;
|
|
1185
|
+
/** Fetches the failed page again (or re-reads the list). */
|
|
1186
|
+
retry(): void;
|
|
1187
|
+
refetch(): Promise<void>;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* A table's rows a page at a time: the first `pageSize` at once, the next on
|
|
1192
|
+
* each `loadMore()`. A changed sort, filter or page size starts again.
|
|
1193
|
+
*/
|
|
1194
|
+
export function usePagedDatastoreQuery<T = unknown>(
|
|
1195
|
+
table: string | null | undefined,
|
|
1196
|
+
options?: PagedQueryOptions,
|
|
1197
|
+
): PagedQueryResult<T>;
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The standard "Rows per page" property (number, default 25, 1-500), the one
|
|
1201
|
+
* the stock Data List shows. Declare it as `pageSize` in a list's propertySchema.
|
|
1202
|
+
*/
|
|
1203
|
+
export const PAGE_SIZE_PROPERTY: Readonly<WidgetPropertyDef>;
|
|
1204
|
+
|
|
1163
1205
|
export interface FigureOptions {
|
|
1164
1206
|
/** Default `"count"`. `"sum"` and `"average"` read the number `column`. */
|
|
1165
1207
|
aggregate?: "count" | "sum" | "average";
|
|
@@ -1527,6 +1569,8 @@ export interface AccessPlan {
|
|
|
1527
1569
|
renews: boolean;
|
|
1528
1570
|
/** ISO time held access ends without a renewal; null for one-time access. */
|
|
1529
1571
|
access_ends_at: string | null;
|
|
1572
|
+
/** Whether this plan can be bought here (false in a store build that does not sell it in-app). */
|
|
1573
|
+
purchase_available: boolean;
|
|
1530
1574
|
}
|
|
1531
1575
|
|
|
1532
1576
|
export interface AccessPlanPurchase {
|
|
@@ -1540,8 +1584,8 @@ export interface AccessPlansApi {
|
|
|
1540
1584
|
loading: boolean;
|
|
1541
1585
|
error: PaymentError | null;
|
|
1542
1586
|
/**
|
|
1543
|
-
* False where
|
|
1544
|
-
*
|
|
1587
|
+
* False where no plan can be bought here (an iOS / Android build sells only
|
|
1588
|
+
* the plans the workspace sells in-app): render a visible not-available state.
|
|
1545
1589
|
*/
|
|
1546
1590
|
purchaseAvailable: boolean;
|
|
1547
1591
|
/** Pass only the plan id; the server sets price and group. */
|
package/dist/index.js
CHANGED
|
@@ -136,6 +136,7 @@ export {
|
|
|
136
136
|
// sc-15163 — native-only in-app purchase; the web Player brokers none.
|
|
137
137
|
export { usePurchases, PurchasesError } from "./purchases.js";
|
|
138
138
|
export { useDatastoreFigure } from "./datastore-figure.js";
|
|
139
|
+
export { usePagedDatastoreQuery, PAGE_SIZE_PROPERTY } from "./paged-query.js";
|
|
139
140
|
export {
|
|
140
141
|
useDatastoreAggregate,
|
|
141
142
|
useUserStats,
|
package/dist/index.native.js
CHANGED
|
@@ -134,6 +134,7 @@ export {
|
|
|
134
134
|
// sc-15163 — native-only in-app purchase; the web Player brokers none.
|
|
135
135
|
export { usePurchases, PurchasesError } from "./purchases.js";
|
|
136
136
|
export { useDatastoreFigure } from "./datastore-figure.js";
|
|
137
|
+
export { usePagedDatastoreQuery, PAGE_SIZE_PROPERTY } from "./paged-query.js";
|
|
137
138
|
export {
|
|
138
139
|
useDatastoreAggregate,
|
|
139
140
|
useUserStats,
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// sc-14120 — a list of records read a page at a time, shared by the stock Data
|
|
2
|
+
// List and custom widgets on both hosts. The first page is useDatastoreQuery's,
|
|
3
|
+
// so it keeps that hook's stale-while-revalidate, write re-reads, `live` and
|
|
4
|
+
// pull-to-refresh; the pages after it are appended by offset. Platform-free,
|
|
5
|
+
// so the web and native entries export this one module.
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { CONTRACT } from "./contract.js";
|
|
8
|
+
import { toDatastoreError, useDatastoreQuery, useWidgetContextOrThrow } from "./hooks.js";
|
|
9
|
+
|
|
10
|
+
export const PAGE_SIZE_PROPERTY = CONTRACT.pageSizeProperty;
|
|
11
|
+
|
|
12
|
+
// REQ-QUERY-02: the server returns at most this many rows per request.
|
|
13
|
+
const MAX_ROWS_PER_READ = 1000;
|
|
14
|
+
|
|
15
|
+
const NO_PAGES = Object.freeze({ key: null, rows: Object.freeze([]), pages: 0, total: null });
|
|
16
|
+
|
|
17
|
+
/** A `pageSize` prop as the page size read: whole, within the property's bounds. */
|
|
18
|
+
export function normalisePageSize(value) {
|
|
19
|
+
const { min, max } = PAGE_SIZE_PROPERTY.validation;
|
|
20
|
+
const n = Math.floor(Number(value));
|
|
21
|
+
if (!Number.isFinite(n) || n < min) return PAGE_SIZE_PROPERTY.default;
|
|
22
|
+
return Math.min(n, max);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function windowKey(table, pageSize, query) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify([table, pageSize, query]);
|
|
28
|
+
} catch (_e) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Rows `[offset, offset + count)`, in requests the server's row cap allows. */
|
|
34
|
+
async function readRange(ns, query, offset, count) {
|
|
35
|
+
const reads = [];
|
|
36
|
+
for (let start = 0; start < count; start += MAX_ROWS_PER_READ) {
|
|
37
|
+
const limit = Math.min(MAX_ROWS_PER_READ, count - start);
|
|
38
|
+
reads.push(ns.list({ ...query, limit, offset: offset + start }));
|
|
39
|
+
}
|
|
40
|
+
const answers = await Promise.all(reads);
|
|
41
|
+
const rows = [];
|
|
42
|
+
let total = null;
|
|
43
|
+
for (const res of answers) {
|
|
44
|
+
if (res && Array.isArray(res.data)) rows.push(...res.data);
|
|
45
|
+
if (res && res.meta && Number.isFinite(res.meta.total)) total = res.meta.total;
|
|
46
|
+
}
|
|
47
|
+
return { rows, total };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A row that moved into the next page between two reads shows once.
|
|
51
|
+
function withoutRepeats(first, more) {
|
|
52
|
+
if (more.length === 0) return first;
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const row of [...first, ...more]) {
|
|
56
|
+
const id = row && row.id;
|
|
57
|
+
if (id !== undefined && id !== null) {
|
|
58
|
+
if (seen.has(id)) continue;
|
|
59
|
+
seen.add(id);
|
|
60
|
+
}
|
|
61
|
+
out.push(row);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A table's rows, `pageSize` at a time, with `loadMore()` appending the next
|
|
68
|
+
* page. See the usePagedDatastoreQuery CONTRACT entry.
|
|
69
|
+
*/
|
|
70
|
+
export function usePagedDatastoreQuery(table, options) {
|
|
71
|
+
const ctx = useWidgetContextOrThrow("usePagedDatastoreQuery");
|
|
72
|
+
// `limit` and `offset` are the hook's to set, so a caller's are dropped.
|
|
73
|
+
// eslint-disable-next-line no-unused-vars
|
|
74
|
+
const { pageSize: requestedPageSize, limit, offset, ...query } = options || {};
|
|
75
|
+
const pageSize = normalisePageSize(requestedPageSize);
|
|
76
|
+
const boundTable = table || null;
|
|
77
|
+
const first = useDatastoreQuery(boundTable, { ...query, limit: pageSize });
|
|
78
|
+
|
|
79
|
+
const key = boundTable ? windowKey(boundTable, pageSize, query) : null;
|
|
80
|
+
const [more, setMore] = useState(NO_PAGES);
|
|
81
|
+
// The page read in flight or failed: { key, seq, pages, appending, error }.
|
|
82
|
+
const [op, setOp] = useState(null);
|
|
83
|
+
// A new query drops the old one's pages and reads before they can return.
|
|
84
|
+
const [windowOf, setWindowOf] = useState(key);
|
|
85
|
+
if (windowOf !== key) {
|
|
86
|
+
setWindowOf(key);
|
|
87
|
+
setMore(NO_PAGES);
|
|
88
|
+
setOp(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Live inputs held in refs so loadMore / retry stay stable callbacks.
|
|
92
|
+
const live = useRef({});
|
|
93
|
+
live.current = {
|
|
94
|
+
key,
|
|
95
|
+
table: boundTable,
|
|
96
|
+
pageSize,
|
|
97
|
+
query,
|
|
98
|
+
records: ctx.datastore && ctx.datastore.records,
|
|
99
|
+
firstFetching: first.fetching,
|
|
100
|
+
more: more.key === key ? more : NO_PAGES,
|
|
101
|
+
op: op && op.key === key ? op : null,
|
|
102
|
+
};
|
|
103
|
+
const seqRef = useRef(0);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
seqRef.current += 1;
|
|
106
|
+
}, [key]);
|
|
107
|
+
|
|
108
|
+
// Reads pages 1..`pages` after the first one; `appending` reads only the last.
|
|
109
|
+
const readPages = useCallback(async (pages, appending) => {
|
|
110
|
+
const { key: k, table: t, pageSize: size, query: q, records, more: m } = live.current;
|
|
111
|
+
if (!k || !records) return;
|
|
112
|
+
const seq = ++seqRef.current;
|
|
113
|
+
setOp({ key: k, seq, pages, appending, error: null });
|
|
114
|
+
const from = appending ? pages : 1;
|
|
115
|
+
try {
|
|
116
|
+
const { rows, total } = await readRange(records(t), q, from * size, (pages - from + 1) * size);
|
|
117
|
+
if (seqRef.current !== seq || live.current.key !== k) return;
|
|
118
|
+
setMore({ key: k, rows: appending ? [...m.rows, ...rows] : rows, pages, total });
|
|
119
|
+
setOp(null);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (seqRef.current !== seq || live.current.key !== k) return;
|
|
122
|
+
setOp({ key: k, seq, pages, appending, error: toDatastoreError(err) });
|
|
123
|
+
}
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const current = live.current.more;
|
|
127
|
+
const pending = live.current.op;
|
|
128
|
+
const total = first.total !== null ? first.total : current.total;
|
|
129
|
+
const loaded = current.pages === 0 ? first.data.length : pageSize + current.rows.length;
|
|
130
|
+
const lastPageFull = current.pages === 0 ? first.data.length >= pageSize : current.rows.length >= current.pages * pageSize;
|
|
131
|
+
const hasMore = Boolean(key) && !first.loading && !first.error && (total !== null ? loaded < total : lastPageFull);
|
|
132
|
+
live.current.hasMore = hasMore;
|
|
133
|
+
|
|
134
|
+
const loadMore = useCallback(() => {
|
|
135
|
+
const { key: k, more: m, op: o, hasMore: canLoad, firstFetching } = live.current;
|
|
136
|
+
if (!canLoad) return;
|
|
137
|
+
if (m.pages === 0 && firstFetching && !o) {
|
|
138
|
+
// The first page may still be the previous query's: wait for it.
|
|
139
|
+
setOp({ key: k, seq: ++seqRef.current, pages: 1, appending: true, queued: true, error: null });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (o && !o.error) {
|
|
143
|
+
// A page is already on its way; a re-read in flight takes one page more.
|
|
144
|
+
if (!o.appending) readPages(o.pages + 1, false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
readPages(m.pages + 1, true);
|
|
148
|
+
}, [readPages]);
|
|
149
|
+
|
|
150
|
+
// A re-read of the first page (a write, `live`, refetch, pull-to-refresh)
|
|
151
|
+
// re-reads the pages after it, so the whole list shown stays current.
|
|
152
|
+
const firstRows = useRef(first.data);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (firstRows.current === first.data) return;
|
|
155
|
+
firstRows.current = first.data;
|
|
156
|
+
const { more: m, op: o } = live.current;
|
|
157
|
+
if (o && o.queued && m.pages === 0) {
|
|
158
|
+
readPages(1, true);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const pages = Math.max(m.pages, o && !o.error ? o.pages : 0);
|
|
162
|
+
if (pages > 0) readPages(pages, false);
|
|
163
|
+
}, [first.data, readPages]);
|
|
164
|
+
|
|
165
|
+
const refetch = first.refetch;
|
|
166
|
+
const firstFailed = Boolean(first.error);
|
|
167
|
+
const firstFailedRef = useRef(firstFailed);
|
|
168
|
+
firstFailedRef.current = firstFailed;
|
|
169
|
+
const retry = useCallback(() => {
|
|
170
|
+
const o = live.current.op;
|
|
171
|
+
if (o && o.error) readPages(o.pages, o.appending);
|
|
172
|
+
if (firstFailedRef.current || !(o && o.error)) refetch();
|
|
173
|
+
}, [readPages, refetch]);
|
|
174
|
+
|
|
175
|
+
const data = useMemo(
|
|
176
|
+
() => withoutRepeats(first.data, current.rows),
|
|
177
|
+
[first.data, current.rows],
|
|
178
|
+
);
|
|
179
|
+
const waiting = Boolean(pending && !pending.error && !(pending.queued && firstFailed));
|
|
180
|
+
const loadingMore = waiting && pending.appending;
|
|
181
|
+
return {
|
|
182
|
+
data,
|
|
183
|
+
total,
|
|
184
|
+
hasMore,
|
|
185
|
+
loading: first.loading,
|
|
186
|
+
fetching: first.fetching || waiting,
|
|
187
|
+
loadingMore,
|
|
188
|
+
error: first.error || (pending && pending.error) || null,
|
|
189
|
+
loadMore,
|
|
190
|
+
retry,
|
|
191
|
+
refetch,
|
|
192
|
+
};
|
|
193
|
+
}
|
package/dist/source-mask.cjs
CHANGED
|
@@ -28,8 +28,10 @@ function stripNonCode(
|
|
|
28
28
|
source,
|
|
29
29
|
{ keepStrings = false, keepTemplates = keepStrings } = {},
|
|
30
30
|
) {
|
|
31
|
-
let out = "";
|
|
32
31
|
const n = source.length;
|
|
32
|
+
// Code units, not `out += ch`: a per-character rope costs superlinear GC.
|
|
33
|
+
const out = new Uint16Array(n);
|
|
34
|
+
let written = 0;
|
|
33
35
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
34
36
|
// Brace depth, plus a stack of the depths at which an enclosing template
|
|
35
37
|
// literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
|
|
@@ -37,10 +39,10 @@ function stripNonCode(
|
|
|
37
39
|
let braceDepth = 0;
|
|
38
40
|
const tmplStack = [];
|
|
39
41
|
const keep = (ch) => {
|
|
40
|
-
out
|
|
42
|
+
out[written++] = ch.charCodeAt(0);
|
|
41
43
|
};
|
|
42
44
|
const blank = (ch) => {
|
|
43
|
-
out
|
|
45
|
+
out[written++] = ch === "\n" || ch === "\r" ? ch.charCodeAt(0) : SPACE;
|
|
44
46
|
};
|
|
45
47
|
// String / template CONTENT: blanked for the banned-identifier scan
|
|
46
48
|
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
@@ -162,7 +164,19 @@ function stripNonCode(
|
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
|
-
return out;
|
|
167
|
+
return decodeCodeUnits(out.subarray(0, written));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const SPACE = 0x20;
|
|
171
|
+
// fromCharCode spreads its arguments, so decode in stack-safe chunks.
|
|
172
|
+
const DECODE_CHUNK = 8192;
|
|
173
|
+
|
|
174
|
+
function decodeCodeUnits(units) {
|
|
175
|
+
const parts = [];
|
|
176
|
+
for (let at = 0; at < units.length; at += DECODE_CHUNK) {
|
|
177
|
+
parts.push(String.fromCharCode.apply(null, units.subarray(at, at + DECODE_CHUNK)));
|
|
178
|
+
}
|
|
179
|
+
return parts.join("");
|
|
166
180
|
}
|
|
167
181
|
module.exports = {
|
|
168
182
|
stripNonCode,
|
package/dist/source-mask.js
CHANGED
|
@@ -24,8 +24,10 @@ export function stripNonCode(
|
|
|
24
24
|
source,
|
|
25
25
|
{ keepStrings = false, keepTemplates = keepStrings } = {},
|
|
26
26
|
) {
|
|
27
|
-
let out = "";
|
|
28
27
|
const n = source.length;
|
|
28
|
+
// Code units, not `out += ch`: a per-character rope costs superlinear GC.
|
|
29
|
+
const out = new Uint16Array(n);
|
|
30
|
+
let written = 0;
|
|
29
31
|
let mode = "code"; // code | line | block | sq | dq | tmpl
|
|
30
32
|
// Brace depth, plus a stack of the depths at which an enclosing template
|
|
31
33
|
// literal resumes — lets a `${ … }` hole (which may itself contain `{}`,
|
|
@@ -33,10 +35,10 @@ export function stripNonCode(
|
|
|
33
35
|
let braceDepth = 0;
|
|
34
36
|
const tmplStack = [];
|
|
35
37
|
const keep = (ch) => {
|
|
36
|
-
out
|
|
38
|
+
out[written++] = ch.charCodeAt(0);
|
|
37
39
|
};
|
|
38
40
|
const blank = (ch) => {
|
|
39
|
-
out
|
|
41
|
+
out[written++] = ch === "\n" || ch === "\r" ? ch.charCodeAt(0) : SPACE;
|
|
40
42
|
};
|
|
41
43
|
// String / template CONTENT: blanked for the banned-identifier scan
|
|
42
44
|
// (prose must not trip `no-window`), kept for the host-API-URL scan,
|
|
@@ -158,5 +160,17 @@ export function stripNonCode(
|
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
|
-
return out;
|
|
163
|
+
return decodeCodeUnits(out.subarray(0, written));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const SPACE = 0x20;
|
|
167
|
+
// fromCharCode spreads its arguments, so decode in stack-safe chunks.
|
|
168
|
+
const DECODE_CHUNK = 8192;
|
|
169
|
+
|
|
170
|
+
function decodeCodeUnits(units) {
|
|
171
|
+
const parts = [];
|
|
172
|
+
for (let at = 0; at < units.length; at += DECODE_CHUNK) {
|
|
173
|
+
parts.push(String.fromCharCode.apply(null, units.subarray(at, at + DECODE_CHUNK)));
|
|
174
|
+
}
|
|
175
|
+
return parts.join("");
|
|
162
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.283.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
|
@@ -418,6 +418,16 @@ const BASIC_STYLE_SCHEMA = Object.freeze({
|
|
|
418
418
|
}),
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
+
// sc-14120 — the "Rows per page" property a paged list declares, the same one
|
|
422
|
+
// the stock Data List shows. usePagedDatastoreQuery clamps to its bounds.
|
|
423
|
+
const PAGE_SIZE_PROPERTY = Object.freeze({
|
|
424
|
+
type: "number",
|
|
425
|
+
label: "Rows per page",
|
|
426
|
+
description: "How many records to load at first; a Load more control fetches the next batch.",
|
|
427
|
+
default: 25,
|
|
428
|
+
validation: Object.freeze({ min: 1, max: 500 }),
|
|
429
|
+
});
|
|
430
|
+
|
|
421
431
|
// The card-surface field names shared by every widget that paints its own card
|
|
422
432
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
423
433
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -1988,6 +1998,42 @@ const HOOKS = [
|
|
|
1988
1998
|
requiredContextSlice: ["datastore.records"],
|
|
1989
1999
|
scopes: ["datastore.read:*"],
|
|
1990
2000
|
},
|
|
2001
|
+
{
|
|
2002
|
+
name: "usePagedDatastoreQuery",
|
|
2003
|
+
signature: "usePagedDatastoreQuery(tableId, options)",
|
|
2004
|
+
optionalArgs: ["options"],
|
|
2005
|
+
description:
|
|
2006
|
+
"Reads a table's rows a page at a time, for any list of records that " +
|
|
2007
|
+
"can grow. Built on useDatastoreQuery: `options` takes the same `sort`, " +
|
|
2008
|
+
"`filter`, `filterMode`, `q`, `includeCapabilities` and `live`, plus " +
|
|
2009
|
+
"`pageSize` (default 25, clamped to 1-500; never pass `limit` or " +
|
|
2010
|
+
"`offset`). The first `pageSize` rows load at once; `loadMore()` appends " +
|
|
2011
|
+
"the next page after the rows in `data`, so each tap reads one page, not " +
|
|
2012
|
+
"the whole list again. `hasMore` is false once every row the query " +
|
|
2013
|
+
"matches (`total`) is loaded. `loadingMore` is true while a page is " +
|
|
2014
|
+
"fetched: show the Load more button busy on it, and hide the button when " +
|
|
2015
|
+
"`hasMore` is false. A failed page sets `error` and keeps the rows " +
|
|
2016
|
+
"already shown; `retry()` fetches it again. A changed sort, filter, " +
|
|
2017
|
+
"search or pageSize starts again from the first page. A write to the " +
|
|
2018
|
+
"table, `live`, refetch() and pull-to-refresh re-read every page shown. " +
|
|
2019
|
+
"Take the size from the maker's \"Rows per page\" property: declare " +
|
|
2020
|
+
"the standard `pageSize` entry (CONTRACT.pageSizeProperty, exported as " +
|
|
2021
|
+
"PAGE_SIZE_PROPERTY) in the propertySchema and pass the `pageSize` prop.",
|
|
2022
|
+
returnShape: {
|
|
2023
|
+
data: "Record[]",
|
|
2024
|
+
total: "number | null",
|
|
2025
|
+
hasMore: "boolean",
|
|
2026
|
+
loading: "boolean",
|
|
2027
|
+
fetching: "boolean",
|
|
2028
|
+
loadingMore: "boolean",
|
|
2029
|
+
error: "DatastoreError | null",
|
|
2030
|
+
loadMore: "() => void",
|
|
2031
|
+
retry: "() => void",
|
|
2032
|
+
refetch: "() => Promise<void>",
|
|
2033
|
+
},
|
|
2034
|
+
requiredContextSlice: ["datastore.records"],
|
|
2035
|
+
scopes: ["datastore.read:*"],
|
|
2036
|
+
},
|
|
1991
2037
|
{
|
|
1992
2038
|
name: "useDatastoreFigure",
|
|
1993
2039
|
signature: "useDatastoreFigure(tableId, { aggregate, column, filter, live })",
|
|
@@ -2339,13 +2385,13 @@ const HOOKS = [
|
|
|
2339
2385
|
signature: "useAccessPlans()",
|
|
2340
2386
|
returnShape: {
|
|
2341
2387
|
plans:
|
|
2342
|
-
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time)",
|
|
2388
|
+
"Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
|
|
2343
2389
|
loading: "boolean",
|
|
2344
2390
|
error: "PaymentError | null",
|
|
2345
2391
|
purchaseAvailable:
|
|
2346
|
-
"boolean // false
|
|
2392
|
+
"boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
|
|
2347
2393
|
purchase:
|
|
2348
|
-
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; host opens hosted Checkout; access arrives with the payment confirmation",
|
|
2394
|
+
"(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
|
|
2349
2395
|
cancel:
|
|
2350
2396
|
"(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
|
|
2351
2397
|
refresh: "() => Promise<void> // re-read after returning from Checkout",
|
|
@@ -3167,7 +3213,9 @@ const HOOKS = [
|
|
|
3167
3213
|
"the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
|
|
3168
3214
|
"browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
|
|
3169
3215
|
"— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
|
|
3170
|
-
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app."
|
|
3216
|
+
"reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
|
|
3217
|
+
"page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
|
|
3218
|
+
"RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
|
|
3171
3219
|
returnShape: {
|
|
3172
3220
|
supported: "boolean // false on the web Player and in an app built without RevenueCat",
|
|
3173
3221
|
loading: "boolean",
|
|
@@ -6658,7 +6706,14 @@ const CONTRACT = deepFreeze({
|
|
|
6658
6706
|
// `kind` (physical | service | digital); three new PaymentError codes.
|
|
6659
6707
|
// 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
|
|
6660
6708
|
// gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
|
|
6661
|
-
|
|
6709
|
+
// 1.230.0: additive (sc-15164) — each access plan carries
|
|
6710
|
+
// `purchase_available`; a store build buys a plan the workspace sells
|
|
6711
|
+
// in-app through the store, and web purchases grant RevenueCat entitlements.
|
|
6712
|
+
// 1.232.0: additive (sc-14120) — `usePagedDatastoreQuery` (a list read a
|
|
6713
|
+
// page at a time, with loadMore / hasMore / retry) and
|
|
6714
|
+
// `pageSizeProperty`, exported as `PAGE_SIZE_PROPERTY`: the standard
|
|
6715
|
+
// "Rows per page" property.
|
|
6716
|
+
version: "1.232.0",
|
|
6662
6717
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6663
6718
|
hooks: HOOKS,
|
|
6664
6719
|
primitives: PRIMITIVES,
|
|
@@ -6684,6 +6739,7 @@ const CONTRACT = deepFreeze({
|
|
|
6684
6739
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
6685
6740
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
6686
6741
|
basicStyleSchema: BASIC_STYLE_SCHEMA,
|
|
6742
|
+
pageSizeProperty: PAGE_SIZE_PROPERTY,
|
|
6687
6743
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
6688
6744
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
6689
6745
|
// sc-10829 — the hook a `badgeCounter: true` entry exports by name.
|