@colixsystems/widget-sdk 0.173.1 → 0.175.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 +27 -4
- package/dist/contract.cjs +57 -12
- package/dist/contract.js +57 -12
- package/dist/hooks.js +102 -10
- package/dist/index.d.ts +18 -0
- package/dist/linter.cjs +40 -0
- package/dist/linter.js +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
44
44
|
| **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. |
|
|
45
45
|
| **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". |
|
|
46
46
|
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
47
|
-
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, fetching, error, refetch }` — a refetch of the same record keeps `data` while `fetching` | `records(table).get` — `datastore.read:<table>` |
|
|
47
|
+
| **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>` |
|
|
48
48
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
49
49
|
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
50
50
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
@@ -60,7 +60,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
60
60
|
| **DIRECTORY** | `useGroups(query)` (optional: query) | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
61
61
|
| **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| all` (endpoint default `all`). |
|
|
62
62
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
63
|
-
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: folderId) | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `
|
|
63
|
+
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
|
|
64
64
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
|
|
65
65
|
| **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
|
|
66
66
|
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider, purpose, pollIntervalMs })` (optional: every key, and the whole argument) | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
|
|
@@ -73,7 +73,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.175.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**.
|
|
77
|
+
|
|
78
|
+
### What's new in 0.175.0 (contract 1.136.0)
|
|
79
|
+
|
|
80
|
+
**Open a PDF for the user to view, print or share, without saving it (sc-9135).** `usePdfExport()` gains `openPdf(html, { fileName? })`, `opening` and `canOpenPdf`. The HTML is rendered server-side by the same headless-Chromium pipeline as `exportToPdf`, and the host opens the result: a new browser tab with the PDF viewer on the web Player, and the OS print and share sheet in the native export (`expo-print`). Nothing is stored, so a widget that only opens PDFs calls `usePdfExport()` with no arguments. Call `openPdf` directly from the press handler, because the web host opens its tab inside that press and a tab opened after an `await` is refused by the popup blocker. `canOpenPdf` is false on a host with no PDF viewer, where `openPdf` rejects with `err.code` `UNSUPPORTED`. The wire errors arrive on `err.status`: 401 not signed in, 413 over the size cap, 429 too many renders, 503 renderer busy. The HTML runs no scripts and loads only `https:` / `data:` images and `https:` fonts. On native, closing the print sheet resolves `openPdf` like closing the web tab does. Needs `@colixsystems/filestore-client@0.11.0` (`files.renderPdf`). The host slice is the new `device.pdfViewer`.
|
|
81
|
+
|
|
82
|
+
**Breaking for authors: `createObjectURL` is now a banned API** (lint rule `no-createobjecturl`). Widgets used it to open a hand-built plain-text blob as a stand-in "print version": it only works in a browser, and on the web it opens widget-built bytes on the host's origin. A widget version that calls it fails lint at submission; versions already published keep running. Open a generated document with `openPdf`, upload a file with `useFilestoreUpload`, and preview a picked photo from the `uri` `useCamera` returns.
|
|
83
|
+
|
|
84
|
+
### What's new in 0.174.0 (contract 1.135.0)
|
|
85
|
+
|
|
86
|
+
**Show a row's Edit / Delete only to someone allowed to use it: `includeCapabilities`.** A list gated on `useCanWrite(tableId)` offered Edit and Delete on every row, because the table-level answer only says whether the user may *create*. On a table where each user owns the rows they created, that is true for every signed-in user, so the server refused every edit and delete on someone else's row. Ask the read for each row's own answer instead:
|
|
87
|
+
|
|
88
|
+
```jsx
|
|
89
|
+
const { data: rows, refetch } = useDatastoreQuery(tableId, { limit: 50, includeCapabilities: true });
|
|
90
|
+
const { delete: remove } = useDatastoreMutation(tableId);
|
|
91
|
+
// inside rows.map((row) => …)
|
|
92
|
+
{row._capabilities?.can_delete && (
|
|
93
|
+
<Pressable onPress={() => remove(row.id).then(refetch)}><Text>Delete</Text></Pressable>
|
|
94
|
+
)}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Every row arrives with `_capabilities: { can_read, can_write, can_delete, can_grant }`, the same answer `update` / `delete` enforce, computed for the whole page in one lookup. `useDatastoreRecord(tableId, recordId, { includeCapabilities: true })` does the same for one record. Without the option, rows are unchanged. The new linter warning `row-action-not-gated` flags a widget that calls `update` or `delete` with no per-row check (`_capabilities`, or `useCanWrite(tableId, { recordId })` on a single-record page).
|
|
77
98
|
|
|
78
99
|
### What's new in 0.173.1 (contract 1.134.1)
|
|
79
100
|
|
|
@@ -2168,7 +2189,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
|
|
|
2168
2189
|
}
|
|
2169
2190
|
```
|
|
2170
2191
|
|
|
2171
|
-
`useCanWrite` answers "
|
|
2192
|
+
`useCanWrite(tableId)` answers "may I create a row here". It says nothing about changing an EXISTING row: gate a list's Edit / Delete on each row's `_capabilities` (read with `includeCapabilities: true`, see *What's new in 0.174.0*), and a single-record page on `useCanWrite(tableId, { recordId })` or `useDatastoreRecord(…, { includeCapabilities: true })`. Never call `useCanWrite` with a `recordId` per list row — that is one request per row. Either answer is a FLOOR, not the whole rule: a widget whose own logic is more specific ("only the assignee may edit this row") still hand-checks that in addition (`user.id === record[assigneeField]`).
|
|
2172
2193
|
|
|
2173
2194
|
## Cross-platform widgets (single-file vs split)
|
|
2174
2195
|
|
|
@@ -2239,6 +2260,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
|
|
|
2239
2260
|
|
|
2240
2261
|
`flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
|
|
2241
2262
|
|
|
2263
|
+
`row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check (sc-9132): no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
|
|
2264
|
+
|
|
2242
2265
|
`no-html-in-content` flags an HTML tag inside a **string** (sc-6970) — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
|
|
2243
2266
|
|
|
2244
2267
|
## Local dev loop (`appstudio-widget dev`)
|
package/dist/contract.cjs
CHANGED
|
@@ -1018,11 +1018,14 @@ const HOOKS = [
|
|
|
1018
1018
|
},
|
|
1019
1019
|
{
|
|
1020
1020
|
name: "useDatastoreRecord",
|
|
1021
|
-
signature: "useDatastoreRecord(tableId, recordId)",
|
|
1021
|
+
signature: "useDatastoreRecord(tableId, recordId, options)",
|
|
1022
|
+
optionalArgs: ["options"],
|
|
1022
1023
|
description:
|
|
1023
1024
|
"Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
|
|
1024
1025
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1025
|
-
"different table or record id resets to loading."
|
|
1026
|
+
"different table or record id resets to loading. " +
|
|
1027
|
+
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1028
|
+
"for useDatastoreQuery.",
|
|
1026
1029
|
returnShape: {
|
|
1027
1030
|
data: "Record | null",
|
|
1028
1031
|
loading: "boolean",
|
|
@@ -1176,9 +1179,22 @@ const HOOKS = [
|
|
|
1176
1179
|
{
|
|
1177
1180
|
name: "usePdfExport",
|
|
1178
1181
|
signature: "usePdfExport({ spaceType, folderId })",
|
|
1179
|
-
optionalArgs: ["folderId"],
|
|
1180
|
-
description:
|
|
1181
|
-
"Render an HTML string to a PDF server-side
|
|
1182
|
+
optionalArgs: ["spaceType", "folderId"],
|
|
1183
|
+
description:
|
|
1184
|
+
"Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
|
|
1185
|
+
"it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
|
|
1186
|
+
"to view, print, download or share: it renders the HTML and opens the " +
|
|
1187
|
+
"result — the browser's PDF viewer in a new tab on the web Player, the " +
|
|
1188
|
+
"OS print / share sheet in the native export — and SAVES NOTHING, so " +
|
|
1189
|
+
"a widget that only opens PDFs calls `usePdfExport()` with no " +
|
|
1190
|
+
"arguments (`spaceType` names where exportToPdf saves). Call " +
|
|
1191
|
+
"it straight from the press handler (the web tab opens before the " +
|
|
1192
|
+
"render so popup blockers allow it). It resolves once the document is " +
|
|
1193
|
+
"handed to the viewer and rejects with `err.code` UNSUPPORTED when the " +
|
|
1194
|
+
"host has no PDF viewer (check `canOpenPdf`), or the wire error " +
|
|
1195
|
+
"(`err.status` 401 not signed in, 413 over the size cap, 429 too many " +
|
|
1196
|
+
"renders, 503 renderer busy — try again). The rest of this entry is " +
|
|
1197
|
+
"the SAVE path. `exportToPdf` renders and SAVES it as a file in " +
|
|
1182
1198
|
"the end-user's Filestore space. The widget passes the SPACE " +
|
|
1183
1199
|
"(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
|
|
1184
1200
|
"context and POSTs JSON through ctx.filestore.files.exportPdf. " +
|
|
@@ -1191,6 +1207,9 @@ const HOOKS = [
|
|
|
1191
1207
|
"service (headless Chromium) — the SAME server-side pipeline on the web " +
|
|
1192
1208
|
"Player and the native export, so no browser-only PDF library is used.",
|
|
1193
1209
|
returnShape: {
|
|
1210
|
+
openPdf: "(html, { fileName? }) => Promise<void>",
|
|
1211
|
+
opening: "boolean",
|
|
1212
|
+
canOpenPdf: "boolean",
|
|
1194
1213
|
exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
|
|
1195
1214
|
exporting: "boolean",
|
|
1196
1215
|
error: "Error | null",
|
|
@@ -1371,7 +1390,12 @@ const HOOKS = [
|
|
|
1371
1390
|
"until the first result for the bound table lands; a changed query on " +
|
|
1372
1391
|
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1373
1392
|
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1374
|
-
"different table resets to loading. Show a spinner on `loading` only."
|
|
1393
|
+
"different table resets to loading. Show a spinner on `loading` only. " +
|
|
1394
|
+
"Pass `includeCapabilities: true` in options to stamp each row with " +
|
|
1395
|
+
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1396
|
+
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1397
|
+
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1398
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
|
|
1375
1399
|
returnShape: {
|
|
1376
1400
|
data: "Record[]",
|
|
1377
1401
|
loading: "boolean",
|
|
@@ -1886,7 +1910,8 @@ const HOOKS = [
|
|
|
1886
1910
|
"domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
|
|
1887
1911
|
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1888
1912
|
"NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
|
|
1889
|
-
"tell those apart.
|
|
1913
|
+
"tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
|
|
1914
|
+
"{ recordId } check per row is one request per row, and the table-level answer only says whether they may CREATE. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
|
|
1890
1915
|
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1891
1916
|
returnShape: {
|
|
1892
1917
|
canWrite: "boolean",
|
|
@@ -2949,10 +2974,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2949
2974
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2950
2975
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
|
|
2951
2976
|
"imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
|
|
2952
|
-
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }
|
|
2953
|
-
"
|
|
2954
|
-
"
|
|
2955
|
-
"
|
|
2977
|
+
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
|
|
2978
|
+
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
|
|
2979
|
+
"Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
|
|
2980
|
+
"navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
|
|
2981
|
+
"expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
|
|
2982
|
+
"pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
|
|
2983
|
+
"(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
|
|
2984
|
+
"cache and hands them to the OS print sheet. " +
|
|
2956
2985
|
"imageEditor.edit applies resize / crop / rotate / flip in order and rejects with an ImageEditorError " +
|
|
2957
2986
|
"(.code UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | INTERNAL). " +
|
|
2958
2987
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
@@ -2980,6 +3009,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2980
3009
|
camera: "object",
|
|
2981
3010
|
imageEditor: "object",
|
|
2982
3011
|
barcode: "object",
|
|
3012
|
+
pdfViewer: "object",
|
|
2983
3013
|
},
|
|
2984
3014
|
},
|
|
2985
3015
|
};
|
|
@@ -3044,6 +3074,11 @@ const BANNED_APIS = [
|
|
|
3044
3074
|
reason: "Dynamic import bypasses the loader's allowlist.",
|
|
3045
3075
|
},
|
|
3046
3076
|
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
3077
|
+
{
|
|
3078
|
+
identifier: "createObjectURL",
|
|
3079
|
+
reason:
|
|
3080
|
+
"Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
|
|
3081
|
+
},
|
|
3047
3082
|
];
|
|
3048
3083
|
|
|
3049
3084
|
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
@@ -4671,7 +4706,17 @@ const CONTRACT = deepFreeze({
|
|
|
4671
4706
|
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4672
4707
|
// no longer shifts its content. Other rows render as before.
|
|
4673
4708
|
// 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
|
|
4674
|
-
|
|
4709
|
+
// 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
|
|
4710
|
+
// options and a new `options` arg on useDatastoreRecord stamp each row
|
|
4711
|
+
// with `_capabilities`, so a widget shows Edit/Delete only on rows the
|
|
4712
|
+
// user may change. New linter warning `row-action-not-gated`.
|
|
4713
|
+
// 1.136.0: additive (sc-9135) — `usePdfExport()` gains `openPdf(html,
|
|
4714
|
+
// { fileName? })`, `opening` and `canOpenPdf`: render server-side and
|
|
4715
|
+
// OPEN the PDF (web: browser viewer tab; native: expo-print sheet)
|
|
4716
|
+
// without saving it, through the new `device.pdfViewer` host slice. And
|
|
4717
|
+
// `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
|
|
4718
|
+
// "print version" as a plain-text blob that never worked on native.
|
|
4719
|
+
version: "1.136.0",
|
|
4675
4720
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4676
4721
|
hooks: HOOKS,
|
|
4677
4722
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1018,11 +1018,14 @@ const HOOKS = [
|
|
|
1018
1018
|
},
|
|
1019
1019
|
{
|
|
1020
1020
|
name: "useDatastoreRecord",
|
|
1021
|
-
signature: "useDatastoreRecord(tableId, recordId)",
|
|
1021
|
+
signature: "useDatastoreRecord(tableId, recordId, options)",
|
|
1022
|
+
optionalArgs: ["options"],
|
|
1022
1023
|
description:
|
|
1023
1024
|
"Reads one row. Re-reading the same record (refetch, pull-to-refresh) " +
|
|
1024
1025
|
"keeps it in `data` with loading false while `fetching` is true; a " +
|
|
1025
|
-
"different table or record id resets to loading."
|
|
1026
|
+
"different table or record id resets to loading. " +
|
|
1027
|
+
"`{ includeCapabilities: true }` adds `_capabilities` to the row, as " +
|
|
1028
|
+
"for useDatastoreQuery.",
|
|
1026
1029
|
returnShape: {
|
|
1027
1030
|
data: "Record | null",
|
|
1028
1031
|
loading: "boolean",
|
|
@@ -1176,9 +1179,22 @@ const HOOKS = [
|
|
|
1176
1179
|
{
|
|
1177
1180
|
name: "usePdfExport",
|
|
1178
1181
|
signature: "usePdfExport({ spaceType, folderId })",
|
|
1179
|
-
optionalArgs: ["folderId"],
|
|
1180
|
-
description:
|
|
1181
|
-
"Render an HTML string to a PDF server-side
|
|
1182
|
+
optionalArgs: ["spaceType", "folderId"],
|
|
1183
|
+
description:
|
|
1184
|
+
"Render an HTML string to a PDF server-side, then either OPEN it or SAVE " +
|
|
1185
|
+
"it. `openPdf(html, { fileName? })` is THE way to give the user a PDF " +
|
|
1186
|
+
"to view, print, download or share: it renders the HTML and opens the " +
|
|
1187
|
+
"result — the browser's PDF viewer in a new tab on the web Player, the " +
|
|
1188
|
+
"OS print / share sheet in the native export — and SAVES NOTHING, so " +
|
|
1189
|
+
"a widget that only opens PDFs calls `usePdfExport()` with no " +
|
|
1190
|
+
"arguments (`spaceType` names where exportToPdf saves). Call " +
|
|
1191
|
+
"it straight from the press handler (the web tab opens before the " +
|
|
1192
|
+
"render so popup blockers allow it). It resolves once the document is " +
|
|
1193
|
+
"handed to the viewer and rejects with `err.code` UNSUPPORTED when the " +
|
|
1194
|
+
"host has no PDF viewer (check `canOpenPdf`), or the wire error " +
|
|
1195
|
+
"(`err.status` 401 not signed in, 413 over the size cap, 429 too many " +
|
|
1196
|
+
"renders, 503 renderer busy — try again). The rest of this entry is " +
|
|
1197
|
+
"the SAVE path. `exportToPdf` renders and SAVES it as a file in " +
|
|
1182
1198
|
"the end-user's Filestore space. The widget passes the SPACE " +
|
|
1183
1199
|
"(`{ spaceType, folderId? }`); the hook resolves owner_id from the host " +
|
|
1184
1200
|
"context and POSTs JSON through ctx.filestore.files.exportPdf. " +
|
|
@@ -1191,6 +1207,9 @@ const HOOKS = [
|
|
|
1191
1207
|
"service (headless Chromium) — the SAME server-side pipeline on the web " +
|
|
1192
1208
|
"Player and the native export, so no browser-only PDF library is used.",
|
|
1193
1209
|
returnShape: {
|
|
1210
|
+
openPdf: "(html, { fileName? }) => Promise<void>",
|
|
1211
|
+
opening: "boolean",
|
|
1212
|
+
canOpenPdf: "boolean",
|
|
1194
1213
|
exportToPdf: "(html, { fileName?, folderId? }) => Promise<FilestoreFile>",
|
|
1195
1214
|
exporting: "boolean",
|
|
1196
1215
|
error: "Error | null",
|
|
@@ -1371,7 +1390,12 @@ const HOOKS = [
|
|
|
1371
1390
|
"until the first result for the bound table lands; a changed query on " +
|
|
1372
1391
|
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1373
1392
|
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1374
|
-
"different table resets to loading. Show a spinner on `loading` only."
|
|
1393
|
+
"different table resets to loading. Show a spinner on `loading` only. " +
|
|
1394
|
+
"Pass `includeCapabilities: true` in options to stamp each row with " +
|
|
1395
|
+
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1396
|
+
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1397
|
+
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1398
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
|
|
1375
1399
|
returnShape: {
|
|
1376
1400
|
data: "Record[]",
|
|
1377
1401
|
loading: "boolean",
|
|
@@ -1886,7 +1910,8 @@ const HOOKS = [
|
|
|
1886
1910
|
"domain-specific write rules: a widget whose rule is more specific than the table ACL (e.g. 'only the assigned user may edit this row') must " +
|
|
1887
1911
|
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1888
1912
|
"NOT distinguish 'not signed in' from 'signed in but forbidden' (both resolve canWrite:false); pair it with useUser() when the UI needs to " +
|
|
1889
|
-
"tell those apart.
|
|
1913
|
+
"tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
|
|
1914
|
+
"{ recordId } check per row is one request per row, and the table-level answer only says whether they may CREATE. myPermissions is a newer client method: a host that has not injected it is NOT a hard error — this hook collapses to " +
|
|
1890
1915
|
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1891
1916
|
returnShape: {
|
|
1892
1917
|
canWrite: "boolean",
|
|
@@ -2945,10 +2970,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2945
2970
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2946
2971
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
|
|
2947
2972
|
"imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
|
|
2948
|
-
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }
|
|
2949
|
-
"
|
|
2950
|
-
"
|
|
2951
|
-
"
|
|
2973
|
+
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
|
|
2974
|
+
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
|
|
2975
|
+
"Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
|
|
2976
|
+
"navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
|
|
2977
|
+
"expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
|
|
2978
|
+
"pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
|
|
2979
|
+
"(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
|
|
2980
|
+
"cache and hands them to the OS print sheet. " +
|
|
2952
2981
|
"imageEditor.edit applies resize / crop / rotate / flip in order and rejects with an ImageEditorError " +
|
|
2953
2982
|
"(.code UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | INTERNAL). " +
|
|
2954
2983
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
@@ -2976,6 +3005,7 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2976
3005
|
camera: "object",
|
|
2977
3006
|
imageEditor: "object",
|
|
2978
3007
|
barcode: "object",
|
|
3008
|
+
pdfViewer: "object",
|
|
2979
3009
|
},
|
|
2980
3010
|
},
|
|
2981
3011
|
};
|
|
@@ -3040,6 +3070,11 @@ const BANNED_APIS = [
|
|
|
3040
3070
|
reason: "Dynamic import bypasses the loader's allowlist.",
|
|
3041
3071
|
},
|
|
3042
3072
|
{ identifier: "globalThis", reason: "Host environment escape." },
|
|
3073
|
+
{
|
|
3074
|
+
identifier: "createObjectURL",
|
|
3075
|
+
reason:
|
|
3076
|
+
"Web-only and opens widget-built bytes on the host origin; open a PDF with usePdfExport().openPdf.",
|
|
3077
|
+
},
|
|
3043
3078
|
];
|
|
3044
3079
|
|
|
3045
3080
|
// REQ-WSDK-PLATFORM §3.4, §5: vetted package allowlist. Each entry is a
|
|
@@ -4667,7 +4702,17 @@ const CONTRACT = deepFreeze({
|
|
|
4667
4702
|
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4668
4703
|
// no longer shifts its content. Other rows render as before.
|
|
4669
4704
|
// 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
|
|
4670
|
-
|
|
4705
|
+
// 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
|
|
4706
|
+
// options and a new `options` arg on useDatastoreRecord stamp each row
|
|
4707
|
+
// with `_capabilities`, so a widget shows Edit/Delete only on rows the
|
|
4708
|
+
// user may change. New linter warning `row-action-not-gated`.
|
|
4709
|
+
// 1.136.0: additive (sc-9135) — `usePdfExport()` gains `openPdf(html,
|
|
4710
|
+
// { fileName? })`, `opening` and `canOpenPdf`: render server-side and
|
|
4711
|
+
// OPEN the PDF (web: browser viewer tab; native: expo-print sheet)
|
|
4712
|
+
// without saving it, through the new `device.pdfViewer` host slice. And
|
|
4713
|
+
// `createObjectURL` joins `bannedApis`: widgets reached for it to fake a
|
|
4714
|
+
// "print version" as a plain-text blob that never worked on native.
|
|
4715
|
+
version: "1.136.0",
|
|
4671
4716
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4672
4717
|
hooks: HOOKS,
|
|
4673
4718
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -2185,7 +2185,8 @@ function serializeQuery(query) {
|
|
|
2185
2185
|
}
|
|
2186
2186
|
|
|
2187
2187
|
const queryResultKey = (table, queryKey) => JSON.stringify([table, queryKey]);
|
|
2188
|
-
const recordResultKey = (table, recordId
|
|
2188
|
+
const recordResultKey = (table, recordId, includeCapabilities = false) =>
|
|
2189
|
+
JSON.stringify([table, recordId, includeCapabilities]);
|
|
2189
2190
|
|
|
2190
2191
|
/**
|
|
2191
2192
|
* Stateful datastore query hook. Returns { data, loading, fetching, error,
|
|
@@ -2202,6 +2203,10 @@ const recordResultKey = (table, recordId) => JSON.stringify([table, recordId]);
|
|
|
2202
2203
|
* re-fetch when [table, JSON.stringify(query)] changes. `refetch` re-runs the
|
|
2203
2204
|
* same call on demand.
|
|
2204
2205
|
*
|
|
2206
|
+
* sc-9132 — `query.includeCapabilities: true` stamps each row with
|
|
2207
|
+
* `_capabilities` ({ can_read, can_write, can_delete, can_grant }), computed
|
|
2208
|
+
* for the whole page in one server lookup. Gate a row's Edit / Delete on it.
|
|
2209
|
+
*
|
|
2205
2210
|
* sc-8595 — stale-while-revalidate. `loading` is true only until the first
|
|
2206
2211
|
* result for the bound TABLE lands. A changed query on that table, `refetch()`
|
|
2207
2212
|
* and a page pull-to-refresh keep the rows already in `data` on screen with
|
|
@@ -2362,8 +2367,12 @@ export function useDatastoreQuery(table, query) {
|
|
|
2362
2367
|
* fetching: false, error: null, refetch } so the widget can render its empty state
|
|
2363
2368
|
* without throwing. A 404 surfaces as a DatastoreError with
|
|
2364
2369
|
* `code: "NOT_FOUND"`.
|
|
2370
|
+
*
|
|
2371
|
+
* sc-9132 — pass `{ includeCapabilities: true }` to have the row carry
|
|
2372
|
+
* `_capabilities` ({ can_read, can_write, can_delete, can_grant }): what the
|
|
2373
|
+
* signed-in user may do to THIS record, the same answer update/delete enforce.
|
|
2365
2374
|
*/
|
|
2366
|
-
export function useDatastoreRecord(table, recordId) {
|
|
2375
|
+
export function useDatastoreRecord(table, recordId, options) {
|
|
2367
2376
|
const ctx = useWidgetContextOrThrow("useDatastoreRecord");
|
|
2368
2377
|
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
2369
2378
|
throw new Error(
|
|
@@ -2378,11 +2387,14 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2378
2387
|
// Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
|
|
2379
2388
|
// identity on every host render, so we hold the live inputs in refs
|
|
2380
2389
|
// to keep `refetch` a stable callback.
|
|
2390
|
+
const includeCapabilities = options?.includeCapabilities === true;
|
|
2381
2391
|
const tableRef = useRef(table);
|
|
2382
2392
|
const recordIdRef = useRef(recordId);
|
|
2393
|
+
const includeCapabilitiesRef = useRef(includeCapabilities);
|
|
2383
2394
|
const recordsRef = useRef(ctx.datastore.records);
|
|
2384
2395
|
tableRef.current = table;
|
|
2385
2396
|
recordIdRef.current = recordId;
|
|
2397
|
+
includeCapabilitiesRef.current = includeCapabilities;
|
|
2386
2398
|
recordsRef.current = ctx.datastore.records;
|
|
2387
2399
|
|
|
2388
2400
|
const runRef = useRef(0);
|
|
@@ -2396,11 +2408,13 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2396
2408
|
setSettled(NO_RECORD_RESULT);
|
|
2397
2409
|
return;
|
|
2398
2410
|
}
|
|
2399
|
-
const key = recordResultKey(t, id);
|
|
2411
|
+
const key = recordResultKey(t, id, includeCapabilitiesRef.current);
|
|
2400
2412
|
setInFlight(true);
|
|
2401
2413
|
try {
|
|
2402
2414
|
const ns = recordsRef.current(t);
|
|
2403
|
-
const row =
|
|
2415
|
+
const row = includeCapabilitiesRef.current
|
|
2416
|
+
? await ns.get(id, { includeCapabilities: true })
|
|
2417
|
+
: await ns.get(id);
|
|
2404
2418
|
if (runRef.current !== myRun) return;
|
|
2405
2419
|
setSettled({ key, data: row || null, error: null, loaded: true });
|
|
2406
2420
|
setInFlight(false);
|
|
@@ -2419,7 +2433,7 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2419
2433
|
useEffect(() => {
|
|
2420
2434
|
doFetch();
|
|
2421
2435
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2422
|
-
}, [table, recordId]);
|
|
2436
|
+
}, [table, recordId, includeCapabilities]);
|
|
2423
2437
|
|
|
2424
2438
|
const refetch = useCallback(async () => {
|
|
2425
2439
|
await doFetch();
|
|
@@ -2428,7 +2442,8 @@ export function useDatastoreRecord(table, recordId) {
|
|
|
2428
2442
|
// sc-1179 — auto-subscribe to the page-level refresh tick.
|
|
2429
2443
|
useRefresh(refetch);
|
|
2430
2444
|
|
|
2431
|
-
const currentKey =
|
|
2445
|
+
const currentKey =
|
|
2446
|
+
table && recordId ? recordResultKey(table, recordId, includeCapabilities) : null;
|
|
2432
2447
|
const busy = currentKey !== null && (settled.key !== currentKey || inFlight);
|
|
2433
2448
|
const hasRecord = currentKey !== null && settled.key === currentKey && settled.loaded;
|
|
2434
2449
|
return {
|
|
@@ -4134,9 +4149,18 @@ export function useFilestoreUpload(options) {
|
|
|
4134
4149
|
}
|
|
4135
4150
|
|
|
4136
4151
|
/**
|
|
4137
|
-
* sc-2314 — render an HTML string to a PDF server-side
|
|
4138
|
-
*
|
|
4139
|
-
* lastExported }`.
|
|
4152
|
+
* sc-2314 / sc-9135 — render an HTML string to a PDF server-side, then OPEN it
|
|
4153
|
+
* or SAVE it. Returns `{ openPdf, opening, canOpenPdf, exportToPdf, exporting,
|
|
4154
|
+
* error, lastExported }`.
|
|
4155
|
+
*
|
|
4156
|
+
* `openPdf(html, { fileName? })` renders without saving and hands the bytes to
|
|
4157
|
+
* the host's `device.pdfViewer`: a browser PDF tab on web, the OS print sheet
|
|
4158
|
+
* on native. It is how a widget offers "print / view / download as PDF".
|
|
4159
|
+
* `canOpenPdf` is false on a host with no viewer, where openPdf rejects with
|
|
4160
|
+
* `err.code` UNSUPPORTED.
|
|
4161
|
+
*
|
|
4162
|
+
* `exportToPdf` SAVES into the end-user's Filestore space. The widget passes
|
|
4163
|
+
* the SPACE (`{ spaceType, folderId? }`);
|
|
4140
4164
|
* the hook resolves `owner_id` the same way the upload hook does (tenant for
|
|
4141
4165
|
* PROJECT, app user for PERSONAL) and posts JSON to
|
|
4142
4166
|
* `ctx.filestore.files.exportPdf`.
|
|
@@ -4174,6 +4198,45 @@ export function usePdfExport(options) {
|
|
|
4174
4198
|
|
|
4175
4199
|
const filesRef = useRef(ctx.filestore.files);
|
|
4176
4200
|
filesRef.current = ctx.filestore.files;
|
|
4201
|
+
const viewerRef = useRef(ctx.device && ctx.device.pdfViewer);
|
|
4202
|
+
viewerRef.current = ctx.device && ctx.device.pdfViewer;
|
|
4203
|
+
const [opening, setOpening] = useState(false);
|
|
4204
|
+
const openingCountRef = useRef(0);
|
|
4205
|
+
|
|
4206
|
+
const canOpenPdf =
|
|
4207
|
+
_canOpenPdf(viewerRef.current) &&
|
|
4208
|
+
typeof ctx.filestore.files.renderPdf === "function";
|
|
4209
|
+
|
|
4210
|
+
// Deliberately no `await` before viewer.open: the web broker must open its
|
|
4211
|
+
// tab inside the press that called us, or a popup blocker refuses it.
|
|
4212
|
+
const openPdf = useCallback(async (html, overrides) => {
|
|
4213
|
+
if (typeof html !== "string" || html.trim().length === 0) {
|
|
4214
|
+
throw new Error("usePdfExport: html is required");
|
|
4215
|
+
}
|
|
4216
|
+
const viewer = viewerRef.current;
|
|
4217
|
+
const files = filesRef.current;
|
|
4218
|
+
if (!_canOpenPdf(viewer) || typeof files.renderPdf !== "function") {
|
|
4219
|
+
const err = new Error("This app cannot open PDFs here.");
|
|
4220
|
+
err.code = "UNSUPPORTED";
|
|
4221
|
+
setError(err);
|
|
4222
|
+
throw err;
|
|
4223
|
+
}
|
|
4224
|
+
setError(null);
|
|
4225
|
+
openingCountRef.current += 1;
|
|
4226
|
+
setOpening(true);
|
|
4227
|
+
try {
|
|
4228
|
+
await viewer.open({
|
|
4229
|
+
fileName: _pdfFileName(overrides && overrides.fileName),
|
|
4230
|
+
load: () => files.renderPdf({ html }),
|
|
4231
|
+
});
|
|
4232
|
+
} catch (err) {
|
|
4233
|
+
setError(err);
|
|
4234
|
+
throw err;
|
|
4235
|
+
} finally {
|
|
4236
|
+
openingCountRef.current -= 1;
|
|
4237
|
+
if (openingCountRef.current === 0) setOpening(false);
|
|
4238
|
+
}
|
|
4239
|
+
}, []);
|
|
4177
4240
|
|
|
4178
4241
|
const exportToPdf = useCallback(
|
|
4179
4242
|
async (html, overrides) => {
|
|
@@ -4213,7 +4276,36 @@ export function usePdfExport(options) {
|
|
|
4213
4276
|
[ownerId, defaultFolderId, spaceType],
|
|
4214
4277
|
);
|
|
4215
4278
|
|
|
4216
|
-
return {
|
|
4279
|
+
return {
|
|
4280
|
+
openPdf,
|
|
4281
|
+
opening,
|
|
4282
|
+
canOpenPdf,
|
|
4283
|
+
exportToPdf,
|
|
4284
|
+
exporting,
|
|
4285
|
+
error,
|
|
4286
|
+
lastExported,
|
|
4287
|
+
};
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
function _canOpenPdf(viewer) {
|
|
4291
|
+
return Boolean(
|
|
4292
|
+
viewer &&
|
|
4293
|
+
typeof viewer.open === "function" &&
|
|
4294
|
+
(typeof viewer.isSupported !== "function" || viewer.isSupported()),
|
|
4295
|
+
);
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
// The name the viewer offers when the user saves or shares the document.
|
|
4299
|
+
function _pdfFileName(fileName) {
|
|
4300
|
+
const base =
|
|
4301
|
+
typeof fileName === "string" && fileName.trim()
|
|
4302
|
+
? Array.from(fileName.trim(), (ch) =>
|
|
4303
|
+
ch < " " || ch === "/" || ch === "\\" ? "_" : ch,
|
|
4304
|
+
)
|
|
4305
|
+
.join("")
|
|
4306
|
+
.slice(0, 120)
|
|
4307
|
+
: "document";
|
|
4308
|
+
return /\.pdf$/i.test(base) ? base : `${base}.pdf`;
|
|
4217
4309
|
}
|
|
4218
4310
|
|
|
4219
4311
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -822,6 +822,23 @@ export interface Query {
|
|
|
822
822
|
sort?: Array<{ field: string; dir: "asc" | "desc" }>;
|
|
823
823
|
limit?: number;
|
|
824
824
|
cursor?: string;
|
|
825
|
+
/**
|
|
826
|
+
* Stamp each row with `_capabilities` — what the signed-in user may do to
|
|
827
|
+
* that row. Gate a row's Edit / Delete on it.
|
|
828
|
+
*/
|
|
829
|
+
includeCapabilities?: boolean;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* What the signed-in user may do to one record: the same answer the update /
|
|
834
|
+
* delete endpoints enforce. Present as `row._capabilities` only when the read
|
|
835
|
+
* passed `includeCapabilities: true`.
|
|
836
|
+
*/
|
|
837
|
+
export interface RecordCapabilities {
|
|
838
|
+
can_read: boolean;
|
|
839
|
+
can_write: boolean;
|
|
840
|
+
can_delete: boolean;
|
|
841
|
+
can_grant: boolean;
|
|
825
842
|
}
|
|
826
843
|
|
|
827
844
|
export interface QueryResult<T> {
|
|
@@ -1065,6 +1082,7 @@ export function useWidgetStyle(): Record<string, unknown>;
|
|
|
1065
1082
|
export function useDatastoreRecord(
|
|
1066
1083
|
tableId: string | null | undefined,
|
|
1067
1084
|
recordId: string | null | undefined,
|
|
1085
|
+
options?: { includeCapabilities?: boolean },
|
|
1068
1086
|
): {
|
|
1069
1087
|
data: unknown | null;
|
|
1070
1088
|
loading: boolean;
|
package/dist/linter.cjs
CHANGED
|
@@ -1189,6 +1189,45 @@ function _handRolledWriteGateRules(source) {
|
|
|
1189
1189
|
];
|
|
1190
1190
|
}
|
|
1191
1191
|
|
|
1192
|
+
// sc-9132 — soft warning: a row's Edit / Delete shown whatever that row's
|
|
1193
|
+
// permissions say. Table-level `useCanWrite(tableId)` only answers "may
|
|
1194
|
+
// create", which on a creator-owned table is true for every signed-in user,
|
|
1195
|
+
// so every row offered actions the server then refused.
|
|
1196
|
+
function _rowActionGatingRules(source) {
|
|
1197
|
+
const code = _stripNonCode(source);
|
|
1198
|
+
const call = _rowMutationCall(code);
|
|
1199
|
+
if (!call) return [];
|
|
1200
|
+
if (/\b_capabilities\b/.test(code)) return [];
|
|
1201
|
+
if (/\buseCanWrite\s*\([^)]*\brecordId\b/.test(code)) return [];
|
|
1202
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
1203
|
+
return [
|
|
1204
|
+
{
|
|
1205
|
+
rule: "row-action-not-gated",
|
|
1206
|
+
severity: "warning",
|
|
1207
|
+
label:
|
|
1208
|
+
`edits or deletes rows without checking that row's permission - read ` +
|
|
1209
|
+
`with { includeCapabilities: true } and show Edit/Delete only when ` +
|
|
1210
|
+
`row._capabilities.can_write / can_delete is true.`,
|
|
1211
|
+
line,
|
|
1212
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
1213
|
+
},
|
|
1214
|
+
];
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// The useDatastoreMutation() call whose update or delete the widget uses,
|
|
1218
|
+
// either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
|
|
1219
|
+
function _rowMutationCall(code) {
|
|
1220
|
+
const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
|
|
1221
|
+
for (const match of code.matchAll(hookCall)) {
|
|
1222
|
+
const [, destructured, name] = match;
|
|
1223
|
+
const usesRowVerb = destructured
|
|
1224
|
+
? /\b(update|delete)\b/.test(destructured)
|
|
1225
|
+
: new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
|
|
1226
|
+
if (usesRowVerb) return match;
|
|
1227
|
+
}
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1192
1231
|
function _imagePercentHeightRules(source) {
|
|
1193
1232
|
const findings = [];
|
|
1194
1233
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
@@ -1839,6 +1878,7 @@ function lintSource(source, options) {
|
|
|
1839
1878
|
// sc-5206 — soft warnings steering a widget toward the two new hooks.
|
|
1840
1879
|
findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
|
|
1841
1880
|
findings.push(..._handRolledWriteGateRules(source));
|
|
1881
|
+
findings.push(..._rowActionGatingRules(source));
|
|
1842
1882
|
findings.push(
|
|
1843
1883
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
|
1844
1884
|
...f,
|
package/dist/linter.js
CHANGED
|
@@ -1210,6 +1210,45 @@ function _handRolledWriteGateRules(source) {
|
|
|
1210
1210
|
];
|
|
1211
1211
|
}
|
|
1212
1212
|
|
|
1213
|
+
// sc-9132 — soft warning: a row's Edit / Delete shown whatever that row's
|
|
1214
|
+
// permissions say. Table-level `useCanWrite(tableId)` only answers "may
|
|
1215
|
+
// create", which on a creator-owned table is true for every signed-in user,
|
|
1216
|
+
// so every row offered actions the server then refused.
|
|
1217
|
+
function _rowActionGatingRules(source) {
|
|
1218
|
+
const code = _stripNonCode(source);
|
|
1219
|
+
const call = _rowMutationCall(code);
|
|
1220
|
+
if (!call) return [];
|
|
1221
|
+
if (/\b_capabilities\b/.test(code)) return [];
|
|
1222
|
+
if (/\buseCanWrite\s*\([^)]*\brecordId\b/.test(code)) return [];
|
|
1223
|
+
const line = code.slice(0, call.index).split(/\r?\n/).length;
|
|
1224
|
+
return [
|
|
1225
|
+
{
|
|
1226
|
+
rule: "row-action-not-gated",
|
|
1227
|
+
severity: "warning",
|
|
1228
|
+
label:
|
|
1229
|
+
`edits or deletes rows without checking that row's permission - read ` +
|
|
1230
|
+
`with { includeCapabilities: true } and show Edit/Delete only when ` +
|
|
1231
|
+
`row._capabilities.can_write / can_delete is true.`,
|
|
1232
|
+
line,
|
|
1233
|
+
snippet: (source.split(/\r?\n/)[line - 1] || "").trim().slice(0, 200),
|
|
1234
|
+
},
|
|
1235
|
+
];
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// The useDatastoreMutation() call whose update or delete the widget uses,
|
|
1239
|
+
// either as `m.update(` / `m.delete(` or destructured `{ update, delete }`.
|
|
1240
|
+
function _rowMutationCall(code) {
|
|
1241
|
+
const hookCall = /(?:\{([^}]*)\}|\b(\w+))\s*=\s*useDatastoreMutation\s*\(/g;
|
|
1242
|
+
for (const match of code.matchAll(hookCall)) {
|
|
1243
|
+
const [, destructured, name] = match;
|
|
1244
|
+
const usesRowVerb = destructured
|
|
1245
|
+
? /\b(update|delete)\b/.test(destructured)
|
|
1246
|
+
: new RegExp(`\\b${name}\\s*\\.\\s*(update|delete)\\s*\\(`).test(code);
|
|
1247
|
+
if (usesRowVerb) return match;
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1213
1252
|
function _imagePercentHeightRules(source) {
|
|
1214
1253
|
const findings = [];
|
|
1215
1254
|
// Comments are blanked (string contents kept) so a commented-out example —
|
|
@@ -1915,6 +1954,7 @@ export function lintSource(source, options) {
|
|
|
1915
1954
|
// sc-5206 — soft warnings steering a widget toward the two new hooks.
|
|
1916
1955
|
findings.push(..._rawUseMemoIntoDatastoreQueryRules(source));
|
|
1917
1956
|
findings.push(..._handRolledWriteGateRules(source));
|
|
1957
|
+
findings.push(..._rowActionGatingRules(source));
|
|
1918
1958
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
1919
1959
|
// line-by-line scan so banned-identifier findings stay first in the
|
|
1920
1960
|
// output.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.175.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",
|