@colixsystems/widget-sdk 0.173.1 → 0.174.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 +20 -3
- package/dist/contract.cjs +18 -5
- package/dist/contract.js +18 -5
- package/dist/hooks.js +21 -6
- 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. |
|
|
@@ -73,7 +73,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
73
73
|
|
|
74
74
|
## Status
|
|
75
75
|
|
|
76
|
-
`v0.
|
|
76
|
+
`v0.174.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.174.0 (contract 1.135.0)
|
|
79
|
+
|
|
80
|
+
**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:
|
|
81
|
+
|
|
82
|
+
```jsx
|
|
83
|
+
const { data: rows, refetch } = useDatastoreQuery(tableId, { limit: 50, includeCapabilities: true });
|
|
84
|
+
const { delete: remove } = useDatastoreMutation(tableId);
|
|
85
|
+
// inside rows.map((row) => …)
|
|
86
|
+
{row._capabilities?.can_delete && (
|
|
87
|
+
<Pressable onPress={() => remove(row.id).then(refetch)}><Text>Delete</Text></Pressable>
|
|
88
|
+
)}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
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
92
|
|
|
78
93
|
### What's new in 0.173.1 (contract 1.134.1)
|
|
79
94
|
|
|
@@ -2168,7 +2183,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
|
|
|
2168
2183
|
}
|
|
2169
2184
|
```
|
|
2170
2185
|
|
|
2171
|
-
`useCanWrite` answers "
|
|
2186
|
+
`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
2187
|
|
|
2173
2188
|
## Cross-platform widgets (single-file vs split)
|
|
2174
2189
|
|
|
@@ -2239,6 +2254,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
|
|
|
2239
2254
|
|
|
2240
2255
|
`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
2256
|
|
|
2257
|
+
`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.
|
|
2258
|
+
|
|
2242
2259
|
`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
2260
|
|
|
2244
2261
|
## 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",
|
|
@@ -1371,7 +1374,12 @@ const HOOKS = [
|
|
|
1371
1374
|
"until the first result for the bound table lands; a changed query on " +
|
|
1372
1375
|
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1373
1376
|
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1374
|
-
"different table resets to loading. Show a spinner on `loading` only."
|
|
1377
|
+
"different table resets to loading. Show a spinner on `loading` only. " +
|
|
1378
|
+
"Pass `includeCapabilities: true` in options to stamp each row with " +
|
|
1379
|
+
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1380
|
+
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1381
|
+
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1382
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
|
|
1375
1383
|
returnShape: {
|
|
1376
1384
|
data: "Record[]",
|
|
1377
1385
|
loading: "boolean",
|
|
@@ -1886,7 +1894,8 @@ const HOOKS = [
|
|
|
1886
1894
|
"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
1895
|
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1888
1896
|
"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.
|
|
1897
|
+
"tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
|
|
1898
|
+
"{ 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
1899
|
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1891
1900
|
returnShape: {
|
|
1892
1901
|
canWrite: "boolean",
|
|
@@ -4671,7 +4680,11 @@ const CONTRACT = deepFreeze({
|
|
|
4671
4680
|
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4672
4681
|
// no longer shifts its content. Other rows render as before.
|
|
4673
4682
|
// 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
|
|
4674
|
-
|
|
4683
|
+
// 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
|
|
4684
|
+
// options and a new `options` arg on useDatastoreRecord stamp each row
|
|
4685
|
+
// with `_capabilities`, so a widget shows Edit/Delete only on rows the
|
|
4686
|
+
// user may change. New linter warning `row-action-not-gated`.
|
|
4687
|
+
version: "1.135.0",
|
|
4675
4688
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4676
4689
|
hooks: HOOKS,
|
|
4677
4690
|
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",
|
|
@@ -1371,7 +1374,12 @@ const HOOKS = [
|
|
|
1371
1374
|
"until the first result for the bound table lands; a changed query on " +
|
|
1372
1375
|
"that table, refetch() and pull-to-refresh keep the rows already in " +
|
|
1373
1376
|
"`data` with loading false, while `fetching` is true. Binding a " +
|
|
1374
|
-
"different table resets to loading. Show a spinner on `loading` only."
|
|
1377
|
+
"different table resets to loading. Show a spinner on `loading` only. " +
|
|
1378
|
+
"Pass `includeCapabilities: true` in options to stamp each row with " +
|
|
1379
|
+
"`_capabilities` { can_read, can_write, can_delete, can_grant } — what " +
|
|
1380
|
+
"the signed-in user may do to THAT row, the answer update/delete " +
|
|
1381
|
+
"enforce, computed for the whole page in one lookup. Gate a row's " +
|
|
1382
|
+
"Edit on `row._capabilities?.can_write` and Delete on `can_delete`.",
|
|
1375
1383
|
returnShape: {
|
|
1376
1384
|
data: "Record[]",
|
|
1377
1385
|
loading: "boolean",
|
|
@@ -1886,7 +1894,8 @@ const HOOKS = [
|
|
|
1886
1894
|
"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
1895
|
"still hand-check that in addition to useCanWrite (user.id === record[assigneeField]). It only answers 'signed in AND permitted' — it does " +
|
|
1888
1896
|
"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.
|
|
1897
|
+
"tell those apart. For a LIST, gate each row's edit/delete on useDatastoreQuery's `includeCapabilities` answer instead — a " +
|
|
1898
|
+
"{ 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
1899
|
"{ canWrite: false, loading: false, error: null, refetch: async () => undefined } rather than throwing. Same collapse when tableId is falsy.",
|
|
1891
1900
|
returnShape: {
|
|
1892
1901
|
canWrite: "boolean",
|
|
@@ -4667,7 +4676,11 @@ const CONTRACT = deepFreeze({
|
|
|
4667
4676
|
// a `ListRow`'s lone `View` child spans the row; a selected row's bar
|
|
4668
4677
|
// no longer shifts its content. Other rows render as before.
|
|
4669
4678
|
// 1.134.1: docs (sc-9122) — an emitted event can run a bound action.
|
|
4670
|
-
|
|
4679
|
+
// 1.135.0: additive (sc-9132) — `includeCapabilities` on useDatastoreQuery
|
|
4680
|
+
// options and a new `options` arg on useDatastoreRecord stamp each row
|
|
4681
|
+
// with `_capabilities`, so a widget shows Edit/Delete only on rows the
|
|
4682
|
+
// user may change. New linter warning `row-action-not-gated`.
|
|
4683
|
+
version: "1.135.0",
|
|
4671
4684
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4672
4685
|
hooks: HOOKS,
|
|
4673
4686
|
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 {
|
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.174.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",
|