@colixsystems/widget-sdk 0.45.1 → 0.48.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 +22 -3
- package/dist/contract.cjs +98 -1
- package/dist/contract.js +98 -1
- package/dist/filepicker.js +93 -0
- package/dist/filepicker.native.js +100 -0
- package/dist/hooks.js +197 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +4 -0
- package/dist/index.native.js +4 -0
- package/dist/linter.cjs +36 -2
- package/dist/linter.js +41 -2
- package/dist/primitives.js +6 -0
- package/dist/primitives.native.js +5 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
13
13
|
|
|
14
14
|
**Wire / casing: snake_case end to end.** The clients send and return snake_case **verbatim** (`created_at`, `group_ids`, `can_read`, `amount_cents`, `data_type`, `is_active`, …). There is **no case transform anywhere** — not on the client and not in the backend; the only casing boundary is Prisma `@map` (snake_case field → camelCase column). Author-defined record column values pass through verbatim. Every `list(...)` returns the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you.
|
|
15
15
|
|
|
16
|
-
**Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (
|
|
16
|
+
**Hooks read the injected clients** — they do not hold their own HTTP. This is the **complete** hook surface (20 hooks), grouped by the domain client each one reads. **CORE** hooks read host state directly off `WidgetContext` (no data client); the rest delegate to one of the four injected clients. The grouping mirrors the banner sections in [`src/hooks.js`](src/hooks.js).
|
|
17
17
|
|
|
18
18
|
| Group | Hook (signature) | Returns | Reads / scope |
|
|
19
19
|
| ----- | ---------------- | ------- | ------------- |
|
|
@@ -40,6 +40,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
40
40
|
| **DIRECTORY** | `useGroups(query?)` | `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` | `directory.groups.*` — `groups.read:*` (mutations also `groups.write:*`) |
|
|
41
41
|
| **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
|
|
42
42
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
43
|
+
| **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`. |
|
|
43
44
|
|
|
44
45
|
All list calls return the `{ data, meta }` envelope; the read hooks unwrap `res.data` for you. There is no `useWorkspace()` or `useLogger()` hook — read the theme via `useTheme()` and the locale via `useI18n()`; the host logger lives on `ctx.logger` (`{ debug, info, warn, error }`).
|
|
45
46
|
|
|
@@ -49,7 +50,24 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
49
50
|
|
|
50
51
|
## Status
|
|
51
52
|
|
|
52
|
-
`v0.
|
|
53
|
+
`v0.48.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**.
|
|
54
|
+
|
|
55
|
+
### What's new in 0.48.0
|
|
56
|
+
|
|
57
|
+
**New `useSendNotification()` hook — send an in-app notification to an app user (sc-890).** A new NOTIFICATIONS hook reading a newly-injected `ctx.notifications` slice (the `@colixsystems/notifications-client`, now constructed by both the web Player and the native Expo export). Returns `{ send, sending, error }`. `send({ recipient_user_id, title, body, link?, payload? })` posts a notification to one app user **in the same workspace** and resolves to the created row; `recipient_user_id` must be a member of the tenant or the call is rejected (cross-workspace targets never resolve). Call it from an **event handler** (a `Pressable.onPress`, a submit, a mutation callback) — never in render, where the abuse/rate-limit guard would fire on every paint. Gated by the new `notifications.send:appUser` scope, which the widget declares in its manifest `requestedScopes`. Rejections surface as a structured `NotificationError` (new named export) with a stable `.code` (`INVALID_TITLE` / `INVALID_BODY` / `INVALID_RECIPIENT` / `INVALID_PAYLOAD` / `VALIDATION` / `AUTH_REQUIRED` / `FORBIDDEN` / `RECIPIENT_NOT_FOUND` / `RATE_LIMITED` / `INTERNAL`). `CONTRACT.version` → `1.35.0`. Additive — one new hook, one new context slice, one new scope, one new error class; no existing export changed signature.
|
|
58
|
+
|
|
59
|
+
### What's new in 0.47.0
|
|
60
|
+
|
|
61
|
+
**`<FilePicker>` native variant is real (sc-1378 follow-up).** The Expo-export shell of `<FilePicker>` now wraps the vetted `expo-document-picker` and reports `FilePicker.isSupported = true`. Result: the Files widget's `allowUpload` toggle works on **both** the web Player and the native Expo export — no more disabled stub on mobile. `expo-document-picker` is added to `CONTRACT.vettedImports` (`platforms: ["native"]`); the compiler's `generatePackageJson` was already pinning it for the export build, so no new export-pin work was needed. `useFilestoreUpload` accepts whatever the host's FormData reads as a binary part — a browser `File` on web, the React Native `{ uri, name, type }` shape from the picker on native — so the same hook code path feeds the multipart on both platforms. `CONTRACT.version` → `1.34.0`, additive.
|
|
62
|
+
|
|
63
|
+
### What's new in 0.46.0
|
|
64
|
+
|
|
65
|
+
**End-user upload primitive + hook (sc-1378).** Widgets can now let an end user upload a file to the Filestore from the published app. Two pieces ship together:
|
|
66
|
+
|
|
67
|
+
- **New SDK hook `useFilestoreUpload({ spaceType, folderId? })`.** Resolves `owner_id` the same way the read filestore hooks do, builds a multipart `FormData` with the snake_case fields the backend reads verbatim (`space_type`, `owner_id`, `folder_id`, plus the binary `file`), and POSTs through `ctx.filestore.files.upload`. Returns `{ upload(file, { folderId? }), uploading, error, lastUploaded }`. A 404 from the backend means the destination folder denied a write (REQ-FSH `canWrite` gate).
|
|
68
|
+
- **New SDK primitive `<FilePicker accept onPick>`.** Web wraps a hidden DOM `<input type="file">` (children render as the visible click target inside a `<label>` so the click reaches the file dialog without ref plumbing). Native renders a disabled trigger and exposes `FilePicker.isSupported = false` until a vetted Expo picker pin lands.
|
|
69
|
+
- **Files widget `allowUpload`.** The built-in Files widget grew an `Allow uploading files` toggle (manifest v2.2.0). When enabled, the toolbar renders an Upload trigger that pipes the picked file through the new hook into the currently-open folder; the `typeFilter` setting narrows the `accept` MIME so an "Images" filter shows only images in the OS dialog.
|
|
70
|
+
- **`CONTRACT.version` → `1.33.0`.** Additive — new hook + new primitive; no existing hook, primitive, manifest field, or token changed shape.
|
|
53
71
|
|
|
54
72
|
### What's new in 0.45.1
|
|
55
73
|
|
|
@@ -151,6 +169,7 @@ Also: `useFileSignatures(fileIds)` is now **self-scoped** (the caller's own sign
|
|
|
151
169
|
**Filestore browsing + BankID file signing for widgets (REQ-FS / REQ-SIGN).** Three new hooks read a newly-injected `ctx.filestore` (the `@colixsystems/filestore-client`, now constructed by both the web and native hosts):
|
|
152
170
|
- `useFilestoreFiles({ spaceType, folderId?, q?, type? })` → `{ files, loading, error, refetch }` — browses the end-user's Filestore space. The hook resolves `owner_id` from the host context (tenant for a project space, the app user for a personal space), so the widget only picks the space.
|
|
153
171
|
- `useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })` → `{ folders, loading, error, refetch }` — the folder-navigation companion to `useFilestoreFiles`; pass `enabled:false` to suspend fetching.
|
|
172
|
+
- `useFilestoreUpload({ spaceType, folderId? })` → `{ upload, uploading, error, lastUploaded }` — POSTs a multipart upload to `ctx.filestore.files.upload`. Resolves `owner_id` from the host context (like the read hooks) so the widget only picks the space + destination folder. Pair with the `<FilePicker>` primitive for the visible trigger. Requires the `files.write:*` scope.
|
|
154
173
|
- `useFileSignature(fileId)` → `{ status, qr, signerName, verdict, initiate, refresh, cancel, verify, … }` — drives a BankID signing flow for a file (the backend hashes the bytes server-side, binds the digest into the signature, and verifies the proof offline).
|
|
155
174
|
|
|
156
175
|
`CONTRACT.version` → `1.20.0` (additive — no existing hook changed).
|
|
@@ -382,7 +401,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
382
401
|
|
|
383
402
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
384
403
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
385
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, display_name, roles, group_ids }` (snake_case verbatim; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
404
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, display_name, roles, group_ids }` (snake_case verbatim; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case).
|
|
386
405
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
387
406
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
388
407
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
package/dist/contract.cjs
CHANGED
|
@@ -214,6 +214,27 @@ const HOOKS = [
|
|
|
214
214
|
requiredContextSlice: ["filestore.files"],
|
|
215
215
|
scopes: ["files.read:*"],
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
name: "useFilestoreUpload",
|
|
219
|
+
signature: "useFilestoreUpload({ spaceType, folderId? })",
|
|
220
|
+
description:
|
|
221
|
+
"Upload a file into the end-user's Filestore space. The widget passes " +
|
|
222
|
+
"the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
|
|
223
|
+
"from the host context, builds a multipart FormData with the " +
|
|
224
|
+
"snake_case fields the backend reads verbatim (`space_type`, " +
|
|
225
|
+
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
226
|
+
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
227
|
+
"to the created file row or throws the wire error; a 404 means the " +
|
|
228
|
+
"destination folder denied a write (REQ-FSH canWrite gate).",
|
|
229
|
+
returnShape: {
|
|
230
|
+
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
231
|
+
uploading: "boolean",
|
|
232
|
+
error: "Error | null",
|
|
233
|
+
lastUploaded: "FilestoreFile | null",
|
|
234
|
+
},
|
|
235
|
+
requiredContextSlice: ["filestore.files"],
|
|
236
|
+
scopes: ["files.write:*"],
|
|
237
|
+
},
|
|
217
238
|
{
|
|
218
239
|
name: "useFilestoreFolders",
|
|
219
240
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
@@ -399,6 +420,22 @@ const HOOKS = [
|
|
|
399
420
|
requiredContextSlice: ["payments.requestPayment"],
|
|
400
421
|
scopes: ["payments.charge:appUser"],
|
|
401
422
|
},
|
|
423
|
+
// sc-890 — send an in-app notification to one app user in the tenant.
|
|
424
|
+
// IMPERATIVE: send() never fires on mount; the widget calls it from an
|
|
425
|
+
// event handler. Reads ctx.notifications.send (the injected
|
|
426
|
+
// @colixsystems/notifications-client). Requires the
|
|
427
|
+
// notifications.send:appUser scope.
|
|
428
|
+
{
|
|
429
|
+
name: "useSendNotification",
|
|
430
|
+
signature: "useSendNotification()",
|
|
431
|
+
returnShape: {
|
|
432
|
+
send: "({ recipient_user_id, title, body, link?, payload? }) => Promise<notification> // rejects with NotificationError",
|
|
433
|
+
sending: "boolean",
|
|
434
|
+
error: "NotificationError | null",
|
|
435
|
+
},
|
|
436
|
+
requiredContextSlice: ["notifications.send"],
|
|
437
|
+
scopes: ["notifications.send:appUser"],
|
|
438
|
+
},
|
|
402
439
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
403
440
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
404
441
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
@@ -710,6 +747,19 @@ const PRIMITIVES = [
|
|
|
710
747
|
rnComponent: "@react-native-community/datetimepicker",
|
|
711
748
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
712
749
|
},
|
|
750
|
+
// sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
|
|
751
|
+
// file-picker on the web Player so end users can upload files to the
|
|
752
|
+
// backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
|
|
753
|
+
// a hidden DOM `<input type="file">`; native renders a disabled trigger
|
|
754
|
+
// and exposes `FilePicker.isSupported = false` until a vetted Expo
|
|
755
|
+
// picker pin lands.
|
|
756
|
+
{
|
|
757
|
+
name: "FilePicker",
|
|
758
|
+
description:
|
|
759
|
+
'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
|
|
760
|
+
rnComponent: null,
|
|
761
|
+
docsUrl: null,
|
|
762
|
+
},
|
|
713
763
|
];
|
|
714
764
|
|
|
715
765
|
const CATEGORIES = [
|
|
@@ -992,6 +1042,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
992
1042
|
required: true,
|
|
993
1043
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
994
1044
|
},
|
|
1045
|
+
// sc-890 — backs useSendNotification(). Mirror of contract.js.
|
|
1046
|
+
notifications: {
|
|
1047
|
+
description:
|
|
1048
|
+
"Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification> }. Backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount).",
|
|
1049
|
+
required: true,
|
|
1050
|
+
fields: { send: "function" },
|
|
1051
|
+
},
|
|
995
1052
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
996
1053
|
// administration, and per-record VirtualPermission facades that used to
|
|
997
1054
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -1169,6 +1226,13 @@ const VETTED_IMPORTS = [
|
|
|
1169
1226
|
description:
|
|
1170
1227
|
"Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
|
|
1171
1228
|
},
|
|
1229
|
+
{
|
|
1230
|
+
specifier: "expo-document-picker",
|
|
1231
|
+
platforms: ["native"],
|
|
1232
|
+
category: "files",
|
|
1233
|
+
description:
|
|
1234
|
+
"Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
|
|
1235
|
+
},
|
|
1172
1236
|
{
|
|
1173
1237
|
specifier: "lucide-react-native",
|
|
1174
1238
|
platforms: ["web", "native"],
|
|
@@ -1681,7 +1745,40 @@ const CONTRACT = deepFreeze({
|
|
|
1681
1745
|
// instance — one StyleSheet/context, no double-instance conflicts. The
|
|
1682
1746
|
// vetted-import SET is unchanged (react-native was already vetted); only
|
|
1683
1747
|
// its web resolution + description changed — minor bump.
|
|
1684
|
-
|
|
1748
|
+
//
|
|
1749
|
+
// 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
|
|
1750
|
+
// posts a multipart upload to `ctx.filestore.files.upload` after the
|
|
1751
|
+
// same owner_id resolution the read filestore hooks use (tenant for
|
|
1752
|
+
// PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
|
|
1753
|
+
// primitive — web wraps a hidden DOM `<input type="file">`, native
|
|
1754
|
+
// renders a disabled trigger and exposes `FilePicker.isSupported =
|
|
1755
|
+
// false` until a vetted Expo picker pin lands. Backs the Files widget's
|
|
1756
|
+
// new `allowUpload` toggle. No existing hook, primitive, manifest
|
|
1757
|
+
// field, or token changed shape — minor bump.
|
|
1758
|
+
//
|
|
1759
|
+
// 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
|
|
1760
|
+
// now wraps the vetted `expo-document-picker` and reports
|
|
1761
|
+
// `FilePicker.isSupported = true` on the Expo export (the build was
|
|
1762
|
+
// already pinning the package in `generatePackageJson`). Closes the
|
|
1763
|
+
// REQ-FW-03 native gap; the Files widget's Upload button now works on
|
|
1764
|
+
// both the web Player and the Expo export. New vetted entry
|
|
1765
|
+
// `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
|
|
1766
|
+
// accepts both a browser `File` and the React Native `{ uri, name,
|
|
1767
|
+
// type }` shape the native picker returns, so the same hook code path
|
|
1768
|
+
// feeds the multipart in both hosts. No existing hook, primitive, or
|
|
1769
|
+
// manifest field changed shape — minor bump.
|
|
1770
|
+
//
|
|
1771
|
+
// 1.35.0: additive (sc-890) — new `useSendNotification()` hook reading the
|
|
1772
|
+
// newly-injected `ctx.notifications` (@colixsystems/notifications-client).
|
|
1773
|
+
// Returns `{ send, sending, error }`; `send({ recipient_user_id, title,
|
|
1774
|
+
// body, link?, payload? })` POSTs to `/notifications/send` and resolves to
|
|
1775
|
+
// the created notification row, rejecting with a structured
|
|
1776
|
+
// NotificationError. Imperative — never fires on mount. New required field
|
|
1777
|
+
// on the new `notifications` context slice (`send: "function"`) + the new
|
|
1778
|
+
// `notifications.send:appUser` scope it gates on. No existing hook,
|
|
1779
|
+
// primitive, manifest field, or token changed shape — minor bump on the
|
|
1780
|
+
// pre-1.0 channel.
|
|
1781
|
+
version: "1.35.0",
|
|
1685
1782
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1686
1783
|
hooks: HOOKS,
|
|
1687
1784
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -214,6 +214,27 @@ const HOOKS = [
|
|
|
214
214
|
requiredContextSlice: ["filestore.files"],
|
|
215
215
|
scopes: ["files.read:*"],
|
|
216
216
|
},
|
|
217
|
+
{
|
|
218
|
+
name: "useFilestoreUpload",
|
|
219
|
+
signature: "useFilestoreUpload({ spaceType, folderId? })",
|
|
220
|
+
description:
|
|
221
|
+
"Upload a file into the end-user's Filestore space. The widget passes " +
|
|
222
|
+
"the SPACE (`{ spaceType, folderId? }`); the hook resolves owner_id " +
|
|
223
|
+
"from the host context, builds a multipart FormData with the " +
|
|
224
|
+
"snake_case fields the backend reads verbatim (`space_type`, " +
|
|
225
|
+
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
226
|
+
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
227
|
+
"to the created file row or throws the wire error; a 404 means the " +
|
|
228
|
+
"destination folder denied a write (REQ-FSH canWrite gate).",
|
|
229
|
+
returnShape: {
|
|
230
|
+
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
231
|
+
uploading: "boolean",
|
|
232
|
+
error: "Error | null",
|
|
233
|
+
lastUploaded: "FilestoreFile | null",
|
|
234
|
+
},
|
|
235
|
+
requiredContextSlice: ["filestore.files"],
|
|
236
|
+
scopes: ["files.write:*"],
|
|
237
|
+
},
|
|
217
238
|
{
|
|
218
239
|
name: "useFilestoreFolders",
|
|
219
240
|
signature: "useFilestoreFolders({ spaceType, parentFolderId?, q?, enabled? })",
|
|
@@ -399,6 +420,22 @@ const HOOKS = [
|
|
|
399
420
|
requiredContextSlice: ["payments.requestPayment"],
|
|
400
421
|
scopes: ["payments.charge:appUser"],
|
|
401
422
|
},
|
|
423
|
+
// sc-890 — send an in-app notification to one app user in the tenant.
|
|
424
|
+
// IMPERATIVE: send() never fires on mount; the widget calls it from an
|
|
425
|
+
// event handler. Reads ctx.notifications.send (the injected
|
|
426
|
+
// @colixsystems/notifications-client). Requires the
|
|
427
|
+
// notifications.send:appUser scope.
|
|
428
|
+
{
|
|
429
|
+
name: "useSendNotification",
|
|
430
|
+
signature: "useSendNotification()",
|
|
431
|
+
returnShape: {
|
|
432
|
+
send: "({ recipient_user_id, title, body, link?, payload? }) => Promise<notification> // rejects with NotificationError",
|
|
433
|
+
sending: "boolean",
|
|
434
|
+
error: "NotificationError | null",
|
|
435
|
+
},
|
|
436
|
+
requiredContextSlice: ["notifications.send"],
|
|
437
|
+
scopes: ["notifications.send:appUser"],
|
|
438
|
+
},
|
|
402
439
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
403
440
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
404
441
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
@@ -710,6 +747,19 @@ const PRIMITIVES = [
|
|
|
710
747
|
rnComponent: "@react-native-community/datetimepicker",
|
|
711
748
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
712
749
|
},
|
|
750
|
+
// sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
|
|
751
|
+
// file-picker on the web Player so end users can upload files to the
|
|
752
|
+
// backend (paired with useFilestoreUpload or useAssets.upload). Web wraps
|
|
753
|
+
// a hidden DOM `<input type="file">`; native renders a disabled trigger
|
|
754
|
+
// and exposes `FilePicker.isSupported = false` until a vetted Expo
|
|
755
|
+
// picker pin lands.
|
|
756
|
+
{
|
|
757
|
+
name: "FilePicker",
|
|
758
|
+
description:
|
|
759
|
+
'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
|
|
760
|
+
rnComponent: null,
|
|
761
|
+
docsUrl: null,
|
|
762
|
+
},
|
|
713
763
|
];
|
|
714
764
|
|
|
715
765
|
const CATEGORIES = [
|
|
@@ -992,6 +1042,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
992
1042
|
required: true,
|
|
993
1043
|
fields: { requestPayment: "function", getPayment: "function" },
|
|
994
1044
|
},
|
|
1045
|
+
// sc-890 — backs useSendNotification(). Mirror of contract.cjs.
|
|
1046
|
+
notifications: {
|
|
1047
|
+
description:
|
|
1048
|
+
"Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification> }. Backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount).",
|
|
1049
|
+
required: true,
|
|
1050
|
+
fields: { send: "function" },
|
|
1051
|
+
},
|
|
995
1052
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
996
1053
|
// administration, and per-record VirtualPermission facades that used to
|
|
997
1054
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -1169,6 +1226,13 @@ const VETTED_IMPORTS = [
|
|
|
1169
1226
|
description:
|
|
1170
1227
|
"Canvas-style 2D/GPU drawing & animation (games, custom graphics) on native. Native-only — author it in widget.native.jsx and pair it with a web variant in widget.web.jsx (a <canvas> or react-native-svg). There is no Skia web build wired into the Player.",
|
|
1171
1228
|
},
|
|
1229
|
+
{
|
|
1230
|
+
specifier: "expo-document-picker",
|
|
1231
|
+
platforms: ["native"],
|
|
1232
|
+
category: "files",
|
|
1233
|
+
description:
|
|
1234
|
+
"Native file picker used by the SDK's <FilePicker> primitive on the Expo export (web uses a hidden DOM <input type=\"file\">, mapped to the same { accept, onPick } contract). Most widgets reach <FilePicker> through the SDK rather than importing this directly; a widget that does import it goes in widget.native.jsx and is pinned by the compiler's generatePackageJson — Expo SDK 56 ships 56.0.x.",
|
|
1235
|
+
},
|
|
1172
1236
|
{
|
|
1173
1237
|
specifier: "lucide-react-native",
|
|
1174
1238
|
platforms: ["web", "native"],
|
|
@@ -1681,7 +1745,40 @@ const CONTRACT = deepFreeze({
|
|
|
1681
1745
|
// instance — one StyleSheet/context, no double-instance conflicts. The
|
|
1682
1746
|
// vetted-import SET is unchanged (react-native was already vetted); only
|
|
1683
1747
|
// its web resolution + description changed — minor bump.
|
|
1684
|
-
|
|
1748
|
+
//
|
|
1749
|
+
// 1.33.0: additive (sc-1378) — `useFilestoreUpload({ spaceType, folderId? })`
|
|
1750
|
+
// posts a multipart upload to `ctx.filestore.files.upload` after the
|
|
1751
|
+
// same owner_id resolution the read filestore hooks use (tenant for
|
|
1752
|
+
// PROJECT, app user for PERSONAL). Paired with the new `<FilePicker>`
|
|
1753
|
+
// primitive — web wraps a hidden DOM `<input type="file">`, native
|
|
1754
|
+
// renders a disabled trigger and exposes `FilePicker.isSupported =
|
|
1755
|
+
// false` until a vetted Expo picker pin lands. Backs the Files widget's
|
|
1756
|
+
// new `allowUpload` toggle. No existing hook, primitive, manifest
|
|
1757
|
+
// field, or token changed shape — minor bump.
|
|
1758
|
+
//
|
|
1759
|
+
// 1.34.0: additive (sc-1378 follow-up) — `<FilePicker>` native variant
|
|
1760
|
+
// now wraps the vetted `expo-document-picker` and reports
|
|
1761
|
+
// `FilePicker.isSupported = true` on the Expo export (the build was
|
|
1762
|
+
// already pinning the package in `generatePackageJson`). Closes the
|
|
1763
|
+
// REQ-FW-03 native gap; the Files widget's Upload button now works on
|
|
1764
|
+
// both the web Player and the Expo export. New vetted entry
|
|
1765
|
+
// `expo-document-picker` (`platforms: ["native"]`). `useFilestoreUpload`
|
|
1766
|
+
// accepts both a browser `File` and the React Native `{ uri, name,
|
|
1767
|
+
// type }` shape the native picker returns, so the same hook code path
|
|
1768
|
+
// feeds the multipart in both hosts. No existing hook, primitive, or
|
|
1769
|
+
// manifest field changed shape — minor bump.
|
|
1770
|
+
//
|
|
1771
|
+
// 1.35.0: additive (sc-890) — new `useSendNotification()` hook reading the
|
|
1772
|
+
// newly-injected `ctx.notifications` (@colixsystems/notifications-client).
|
|
1773
|
+
// Returns `{ send, sending, error }`; `send({ recipient_user_id, title,
|
|
1774
|
+
// body, link?, payload? })` POSTs to `/notifications/send` and resolves to
|
|
1775
|
+
// the created notification row, rejecting with a structured
|
|
1776
|
+
// NotificationError. Imperative — never fires on mount. New required field
|
|
1777
|
+
// on the new `notifications` context slice (`send: "function"`) + the new
|
|
1778
|
+
// `notifications.send:appUser` scope it gates on. No existing hook,
|
|
1779
|
+
// primitive, manifest field, or token changed shape — minor bump on the
|
|
1780
|
+
// pre-1.0 channel.
|
|
1781
|
+
version: "1.35.0",
|
|
1685
1782
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
1686
1783
|
hooks: HOOKS,
|
|
1687
1784
|
primitives: PRIMITIVES,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// sc-1378 — `<FilePicker>` SDK primitive (web implementation).
|
|
2
|
+
//
|
|
3
|
+
// React Native (and react-native-web) has no native equivalent of the
|
|
4
|
+
// browser's `<input type="file">`, so the web build renders the DOM input
|
|
5
|
+
// directly (the same trick the web DateTimePicker uses: `React.createElement
|
|
6
|
+
// ("input", ...)`). The input is visually hidden so authors style the
|
|
7
|
+
// trigger themselves — children are rendered inside a label so any
|
|
8
|
+
// Pressable / View / Text the widget passes in becomes the click target.
|
|
9
|
+
//
|
|
10
|
+
// Public contract (mirror in filepicker.native.js — keep them in lockstep):
|
|
11
|
+
// accept: string | undefined — same value as the DOM `accept`
|
|
12
|
+
// attribute. "image/*" / "image/png,image/jpeg" narrow the
|
|
13
|
+
// picker; omit / "" lets the OS picker show every file type.
|
|
14
|
+
// multiple: boolean — default false. When true, `onPick`
|
|
15
|
+
// fires once with an array of File objects.
|
|
16
|
+
// disabled: boolean — gates the click; renders a muted
|
|
17
|
+
// label.
|
|
18
|
+
// onPick: (file | File[]) => void
|
|
19
|
+
// children: ReactNode — the visible trigger (a Pressable +
|
|
20
|
+
// an icon + a label is the common shape). Wrapped in a
|
|
21
|
+
// `<label>` so the click reaches the hidden input
|
|
22
|
+
// everywhere — no manual ref / `.click()` plumbing needed.
|
|
23
|
+
// accessibilityLabel: string? — written to the wrapper label.
|
|
24
|
+
|
|
25
|
+
import React, { useRef, useCallback } from "react";
|
|
26
|
+
|
|
27
|
+
const HIDDEN_INPUT_STYLE = {
|
|
28
|
+
position: "absolute",
|
|
29
|
+
width: 1,
|
|
30
|
+
height: 1,
|
|
31
|
+
padding: 0,
|
|
32
|
+
margin: -1,
|
|
33
|
+
overflow: "hidden",
|
|
34
|
+
clip: "rect(0, 0, 0, 0)",
|
|
35
|
+
whiteSpace: "nowrap",
|
|
36
|
+
border: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LABEL_STYLE = {
|
|
40
|
+
display: "inline-flex",
|
|
41
|
+
cursor: "pointer",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const LABEL_STYLE_DISABLED = {
|
|
45
|
+
...LABEL_STYLE,
|
|
46
|
+
cursor: "not-allowed",
|
|
47
|
+
opacity: 0.5,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function FilePicker({
|
|
51
|
+
accept,
|
|
52
|
+
multiple = false,
|
|
53
|
+
disabled = false,
|
|
54
|
+
onPick,
|
|
55
|
+
children,
|
|
56
|
+
accessibilityLabel,
|
|
57
|
+
}) {
|
|
58
|
+
const inputRef = useRef(null);
|
|
59
|
+
|
|
60
|
+
const handleChange = useCallback(
|
|
61
|
+
(event) => {
|
|
62
|
+
const list = event && event.target && event.target.files;
|
|
63
|
+
if (!list || list.length === 0) return;
|
|
64
|
+
if (typeof onPick === "function") {
|
|
65
|
+
onPick(multiple ? Array.from(list) : list[0]);
|
|
66
|
+
}
|
|
67
|
+
// Reset the input so picking the SAME file twice in a row still fires
|
|
68
|
+
// onChange — DOM dedupes by value, the SDK contract does not.
|
|
69
|
+
event.target.value = "";
|
|
70
|
+
},
|
|
71
|
+
[onPick, multiple],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return React.createElement(
|
|
75
|
+
"label",
|
|
76
|
+
{
|
|
77
|
+
style: disabled ? LABEL_STYLE_DISABLED : LABEL_STYLE,
|
|
78
|
+
"aria-label": accessibilityLabel,
|
|
79
|
+
},
|
|
80
|
+
React.createElement("input", {
|
|
81
|
+
ref: inputRef,
|
|
82
|
+
type: "file",
|
|
83
|
+
accept: accept || undefined,
|
|
84
|
+
multiple,
|
|
85
|
+
disabled,
|
|
86
|
+
onChange: handleChange,
|
|
87
|
+
style: HIDDEN_INPUT_STYLE,
|
|
88
|
+
}),
|
|
89
|
+
children,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
FilePicker.isSupported = true;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// sc-1378 — `<FilePicker>` SDK primitive (native — Expo export).
|
|
2
|
+
//
|
|
3
|
+
// Wraps `expo-document-picker` (a vetted import, native-only; pinned by
|
|
4
|
+
// the compiler's generatePackageJson at expo-document-picker ~56.0.x).
|
|
5
|
+
// The web variant in ./filepicker.js wraps a hidden DOM
|
|
6
|
+
// `<input type="file">` because react-native-web has no equivalent of the
|
|
7
|
+
// system file picker; this native variant calls the OS document picker
|
|
8
|
+
// directly. Both honour the same public contract — `accept` / `multiple`
|
|
9
|
+
// / `disabled` / `onPick` / `children` / `accessibilityLabel`.
|
|
10
|
+
//
|
|
11
|
+
// `onPick` receives the same shape on both platforms:
|
|
12
|
+
// - web → the browser's `File` object (a `Blob` with name + type).
|
|
13
|
+
// - native → `{ uri, name, type, size }` — React Native's
|
|
14
|
+
// FormData-friendly file part. `useFilestoreUpload` appends whichever
|
|
15
|
+
// verbatim into the multipart body; the React Native fetch
|
|
16
|
+
// implementation reads `uri` and streams the bytes.
|
|
17
|
+
|
|
18
|
+
import React, { useCallback, useMemo } from "react";
|
|
19
|
+
import { Pressable, View } from "react-native";
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
21
|
+
import { getDocumentAsync } from "expo-document-picker";
|
|
22
|
+
|
|
23
|
+
// Map an HTML `accept` string (the web contract — "image/*", "audio/*",
|
|
24
|
+
// "image/png,image/jpeg", …) to expo-document-picker's `type` arg, which
|
|
25
|
+
// accepts either a single MIME string or an array of MIME strings. We
|
|
26
|
+
// pass the array form unchanged so a comma-separated list narrows the
|
|
27
|
+
// same way it does on web. An empty / falsy accept maps to "*/*"
|
|
28
|
+
// (DocumentPicker's wide-open default — every file type is offered).
|
|
29
|
+
function _mapAccept(accept) {
|
|
30
|
+
if (!accept || typeof accept !== "string") return "*/*";
|
|
31
|
+
const parts = accept
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => s.trim())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
if (parts.length === 0) return "*/*";
|
|
36
|
+
if (parts.length === 1) return parts[0];
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// expo-document-picker returns `{ canceled, assets: [{ uri, name,
|
|
41
|
+
// mimeType, size }] }`. Normalise each asset to the FormData-friendly
|
|
42
|
+
// `{ uri, name, type, size }` shape useFilestoreUpload appends into the
|
|
43
|
+
// multipart body — `type` is the field React Native's FormData reads
|
|
44
|
+
// for the part's content-type, mirroring the browser File's `.type`.
|
|
45
|
+
function _asset(picked) {
|
|
46
|
+
return {
|
|
47
|
+
uri: picked.uri,
|
|
48
|
+
name: picked.name || "upload",
|
|
49
|
+
type: picked.mimeType || "application/octet-stream",
|
|
50
|
+
size: typeof picked.size === "number" ? picked.size : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function FilePicker({
|
|
55
|
+
accept,
|
|
56
|
+
multiple = false,
|
|
57
|
+
disabled = false,
|
|
58
|
+
onPick,
|
|
59
|
+
children,
|
|
60
|
+
accessibilityLabel,
|
|
61
|
+
}) {
|
|
62
|
+
const type = useMemo(() => _mapAccept(accept), [accept]);
|
|
63
|
+
|
|
64
|
+
const handlePress = useCallback(async () => {
|
|
65
|
+
if (disabled) return;
|
|
66
|
+
let result;
|
|
67
|
+
try {
|
|
68
|
+
result = await getDocumentAsync({
|
|
69
|
+
type,
|
|
70
|
+
multiple,
|
|
71
|
+
copyToCacheDirectory: true,
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!result || result.canceled) return;
|
|
77
|
+
const assets = Array.isArray(result.assets) ? result.assets : [];
|
|
78
|
+
if (assets.length === 0) return;
|
|
79
|
+
if (typeof onPick !== "function") return;
|
|
80
|
+
if (multiple) {
|
|
81
|
+
onPick(assets.map(_asset));
|
|
82
|
+
} else {
|
|
83
|
+
onPick(_asset(assets[0]));
|
|
84
|
+
}
|
|
85
|
+
}, [disabled, type, multiple, onPick]);
|
|
86
|
+
|
|
87
|
+
return React.createElement(
|
|
88
|
+
Pressable,
|
|
89
|
+
{
|
|
90
|
+
onPress: handlePress,
|
|
91
|
+
disabled,
|
|
92
|
+
accessibilityRole: "button",
|
|
93
|
+
accessibilityLabel,
|
|
94
|
+
accessibilityState: { disabled },
|
|
95
|
+
},
|
|
96
|
+
React.createElement(View, { style: disabled ? { opacity: 0.5 } : undefined }, children),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
FilePicker.isSupported = true;
|
package/dist/hooks.js
CHANGED
|
@@ -1364,6 +1364,82 @@ export function useFilestoreFiles(options) {
|
|
|
1364
1364
|
return { files, loading, error, refetch };
|
|
1365
1365
|
}
|
|
1366
1366
|
|
|
1367
|
+
/**
|
|
1368
|
+
* sc-1378 — upload a file into the end-user's Filestore space. Returns
|
|
1369
|
+
* `{ upload, uploading, error, lastUploaded }`. The widget passes the
|
|
1370
|
+
* SPACE (`{ spaceType, folderId? }`); the hook resolves `owner_id` the
|
|
1371
|
+
* same way the read hooks do (tenant for PROJECT, app user for PERSONAL)
|
|
1372
|
+
* and posts a multipart FormData to `ctx.filestore.files.upload`.
|
|
1373
|
+
*
|
|
1374
|
+
* `upload(file, { folderId? })` accepts whatever the host's FormData
|
|
1375
|
+
* implementation reads as a binary file part — on the web Player a
|
|
1376
|
+
* browser `File`, on the Expo export the React Native shape
|
|
1377
|
+
* `{ uri, name, type }` returned by `<FilePicker>` (which wraps
|
|
1378
|
+
* `expo-document-picker` natively and a hidden DOM `<input type="file">`
|
|
1379
|
+
* on web). FormData serialises both verbatim, so the same hook code path
|
|
1380
|
+
* feeds the multipart on both platforms.
|
|
1381
|
+
*
|
|
1382
|
+
* A 404 from the backend means the destination folder denied a write —
|
|
1383
|
+
* the widget should surface a friendly "you can't upload here" message
|
|
1384
|
+
* rather than the raw error.
|
|
1385
|
+
*/
|
|
1386
|
+
export function useFilestoreUpload(options) {
|
|
1387
|
+
const ctx = useWidgetContextOrThrow("useFilestoreUpload");
|
|
1388
|
+
if (
|
|
1389
|
+
!ctx.filestore ||
|
|
1390
|
+
!ctx.filestore.files ||
|
|
1391
|
+
typeof ctx.filestore.files.upload !== "function"
|
|
1392
|
+
) {
|
|
1393
|
+
throw new Error(
|
|
1394
|
+
"useFilestoreUpload: host did not inject a filestore client (ctx.filestore.files.upload)",
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
const { spaceType = "project", folderId: defaultFolderId = null } = options || {};
|
|
1398
|
+
const ownerId = _filestoreOwnerId(ctx, spaceType);
|
|
1399
|
+
|
|
1400
|
+
const [uploading, setUploading] = useState(false);
|
|
1401
|
+
const [error, setError] = useState(null);
|
|
1402
|
+
const [lastUploaded, setLastUploaded] = useState(null);
|
|
1403
|
+
|
|
1404
|
+
const filesRef = useRef(ctx.filestore.files);
|
|
1405
|
+
filesRef.current = ctx.filestore.files;
|
|
1406
|
+
|
|
1407
|
+
const upload = useCallback(
|
|
1408
|
+
async (file, overrides) => {
|
|
1409
|
+
if (!file) throw new Error("useFilestoreUpload: file is required");
|
|
1410
|
+
if (!ownerId) {
|
|
1411
|
+
const err = new Error("Sign in to upload");
|
|
1412
|
+
setError(err);
|
|
1413
|
+
throw err;
|
|
1414
|
+
}
|
|
1415
|
+
const folderId =
|
|
1416
|
+
overrides && Object.prototype.hasOwnProperty.call(overrides, "folderId")
|
|
1417
|
+
? overrides.folderId
|
|
1418
|
+
: defaultFolderId;
|
|
1419
|
+
const form = new FormData();
|
|
1420
|
+
form.append("space_type", String(spaceType || "project").toUpperCase());
|
|
1421
|
+
form.append("owner_id", ownerId);
|
|
1422
|
+
if (folderId) form.append("folder_id", folderId);
|
|
1423
|
+
form.append("file", file);
|
|
1424
|
+
setUploading(true);
|
|
1425
|
+
setError(null);
|
|
1426
|
+
try {
|
|
1427
|
+
const created = await filesRef.current.upload(form);
|
|
1428
|
+
setLastUploaded(created || null);
|
|
1429
|
+
setUploading(false);
|
|
1430
|
+
return created;
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
setError(err);
|
|
1433
|
+
setUploading(false);
|
|
1434
|
+
throw err;
|
|
1435
|
+
}
|
|
1436
|
+
},
|
|
1437
|
+
[ownerId, defaultFolderId, spaceType],
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
return { upload, uploading, error, lastUploaded };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1367
1443
|
/**
|
|
1368
1444
|
* Browse the end-user's Filestore folders. Returns { folders, loading, error,
|
|
1369
1445
|
* refetch }. Mirrors useFilestoreFiles for subfolder navigation: the widget
|
|
@@ -2477,3 +2553,124 @@ export function usePayments() {
|
|
|
2477
2553
|
}, []);
|
|
2478
2554
|
return { requestPayment, getPayment };
|
|
2479
2555
|
}
|
|
2556
|
+
|
|
2557
|
+
/* ============================================================================
|
|
2558
|
+
* NOTIFICATIONS CLIENT — ctx.notifications (@colixsystems/notifications-client)
|
|
2559
|
+
*
|
|
2560
|
+
* send. Covers: useSendNotification.
|
|
2561
|
+
* ==========================================================================*/
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* sc-890 — structured error thrown by `useSendNotification().send`. Carries a
|
|
2565
|
+
* stable `code` so widgets can branch without parsing message strings; mirrors
|
|
2566
|
+
* the shape of `PaymentError` / `DirectoryError`.
|
|
2567
|
+
*
|
|
2568
|
+
* `code` is one of:
|
|
2569
|
+
* - "INVALID_TITLE" / "INVALID_BODY" / "INVALID_RECIPIENT" /
|
|
2570
|
+
* "INVALID_PAYLOAD" / "VALIDATION" — 400 (bad request)
|
|
2571
|
+
* - "AUTH_REQUIRED" — 401 (no signed-in app user)
|
|
2572
|
+
* - "FORBIDDEN" — 403 (widget lacks notifications.send:appUser)
|
|
2573
|
+
* - "RECIPIENT_NOT_FOUND" — 404 (recipient not a member of the tenant)
|
|
2574
|
+
* - "RATE_LIMITED" — 429 (too many sends)
|
|
2575
|
+
* - "INTERNAL" — anything else (network, 5xx)
|
|
2576
|
+
*
|
|
2577
|
+
* The server's stable `code` (when volunteered in the error body) is preserved
|
|
2578
|
+
* over the status-derived one.
|
|
2579
|
+
*/
|
|
2580
|
+
export class NotificationError extends Error {
|
|
2581
|
+
constructor(code, message, opts) {
|
|
2582
|
+
super(message);
|
|
2583
|
+
this.name = "NotificationError";
|
|
2584
|
+
this.code = code;
|
|
2585
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
function toNotificationError(err) {
|
|
2590
|
+
if (err instanceof NotificationError) return err;
|
|
2591
|
+
// The injected notifications-client rejects with its own NotificationError
|
|
2592
|
+
// subclass carrying { code, status }; the host facade may instead throw an
|
|
2593
|
+
// axios-shaped error ({ response: { status, data: { code, error } } }). Read
|
|
2594
|
+
// both shapes so the hook surfaces a stable code either way.
|
|
2595
|
+
const status =
|
|
2596
|
+
err && err.response && typeof err.response.status === "number"
|
|
2597
|
+
? err.response.status
|
|
2598
|
+
: err && typeof err.status === "number"
|
|
2599
|
+
? err.status
|
|
2600
|
+
: null;
|
|
2601
|
+
const bodyCode =
|
|
2602
|
+
(err && err.response && err.response.data && err.response.data.code) ||
|
|
2603
|
+
(err && typeof err.code === "string" ? err.code : null);
|
|
2604
|
+
const bodyMessage =
|
|
2605
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
2606
|
+
let code = "INTERNAL";
|
|
2607
|
+
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
2608
|
+
else if (status === 401) code = "AUTH_REQUIRED";
|
|
2609
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
2610
|
+
else if (status === 404) code = "RECIPIENT_NOT_FOUND";
|
|
2611
|
+
else if (status === 429) code = "RATE_LIMITED";
|
|
2612
|
+
else if (status === 400) code = "VALIDATION";
|
|
2613
|
+
const message =
|
|
2614
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
2615
|
+
(err && typeof err.message === "string"
|
|
2616
|
+
? err.message
|
|
2617
|
+
: "Send notification failed");
|
|
2618
|
+
return new NotificationError(code, message, { cause: err });
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* sc-890 — send an in-app notification to one app user in the tenant. Returns
|
|
2623
|
+
* `{ send, sending, error }`.
|
|
2624
|
+
*
|
|
2625
|
+
* send({ recipient_user_id, title, body, link?, payload? })
|
|
2626
|
+
* → Promise<notification>. Body is snake_case VERBATIM (the wire contract,
|
|
2627
|
+
* REQ-GEN-09) — the SDK does NOT transform it. Resolves to the created
|
|
2628
|
+
* notification row (`{ id, tenant_id, user_id, title, body, link,
|
|
2629
|
+
* payload, read_at, created_at }`) and rejects with a `NotificationError`.
|
|
2630
|
+
*
|
|
2631
|
+
* The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
|
|
2632
|
+
* from an event handler (a button press, a form submit). `sending` tracks an
|
|
2633
|
+
* in-flight call and `error` holds the last `NotificationError` (cleared at the
|
|
2634
|
+
* start of each `send`). Reads the injected
|
|
2635
|
+
* `@colixsystems/notifications-client` at `ctx.notifications`.
|
|
2636
|
+
*
|
|
2637
|
+
* Requires the `notifications.send:appUser` scope in the manifest's
|
|
2638
|
+
* `requestedScopes`. A widget that declares the scope but whose caller is not
|
|
2639
|
+
* permitted receives a `NotificationError { code: "FORBIDDEN" }`.
|
|
2640
|
+
*/
|
|
2641
|
+
export function useSendNotification() {
|
|
2642
|
+
const ctx = useWidgetContextOrThrow("useSendNotification");
|
|
2643
|
+
if (!ctx.notifications || typeof ctx.notifications.send !== "function") {
|
|
2644
|
+
throw new Error(
|
|
2645
|
+
"useSendNotification: host did not inject a notifications client. The " +
|
|
2646
|
+
"widget must declare the notifications.send:appUser scope and be " +
|
|
2647
|
+
"installed in a workspace whose host supports notifications.",
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
// `ctx` is a fresh identity each host render — hold the send fn in a ref so
|
|
2651
|
+
// the callback stays stable.
|
|
2652
|
+
const sendRef = useRef(ctx.notifications.send);
|
|
2653
|
+
sendRef.current = ctx.notifications.send;
|
|
2654
|
+
|
|
2655
|
+
const [sending, setSending] = useState(false);
|
|
2656
|
+
const [error, setError] = useState(null);
|
|
2657
|
+
|
|
2658
|
+
const send = useCallback(async (body) => {
|
|
2659
|
+
setSending(true);
|
|
2660
|
+
setError(null);
|
|
2661
|
+
try {
|
|
2662
|
+
// body is snake_case verbatim ({ recipient_user_id, title, body, link?,
|
|
2663
|
+
// payload? }) — passed straight through.
|
|
2664
|
+
const row = await sendRef.current(body);
|
|
2665
|
+
setSending(false);
|
|
2666
|
+
return row;
|
|
2667
|
+
} catch (err) {
|
|
2668
|
+
const e = toNotificationError(err);
|
|
2669
|
+
setError(e);
|
|
2670
|
+
setSending(false);
|
|
2671
|
+
throw e;
|
|
2672
|
+
}
|
|
2673
|
+
}, []);
|
|
2674
|
+
|
|
2675
|
+
return { send, sending, error };
|
|
2676
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -395,6 +395,16 @@ export interface PaymentsClient {
|
|
|
395
395
|
getPayment(paymentId: string): Promise<PaymentResult>;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Structural shape of the injected `@colixsystems/notifications-client`
|
|
400
|
+
* (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the
|
|
401
|
+
* body snake_case verbatim to `/notifications/send` and resolves to the created
|
|
402
|
+
* notification row.
|
|
403
|
+
*/
|
|
404
|
+
export interface NotificationsClient {
|
|
405
|
+
send(body: SendNotificationRequest): Promise<NotificationRow>;
|
|
406
|
+
}
|
|
407
|
+
|
|
398
408
|
export interface WidgetContext<TProps = unknown> {
|
|
399
409
|
props: TProps;
|
|
400
410
|
widget: { id: string; instanceId: string; version: string };
|
|
@@ -435,6 +445,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
435
445
|
assets: AssetsClient;
|
|
436
446
|
/** Injected @colixsystems/payments-client. */
|
|
437
447
|
payments: PaymentsClient;
|
|
448
|
+
/** Injected @colixsystems/notifications-client; backs useSendNotification. */
|
|
449
|
+
notifications: NotificationsClient;
|
|
438
450
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
439
451
|
renderer: { renderNode(node: unknown): unknown };
|
|
440
452
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
@@ -612,6 +624,52 @@ export interface PaymentsApi {
|
|
|
612
624
|
*/
|
|
613
625
|
export function usePayments(): PaymentsApi;
|
|
614
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Arguments for `useSendNotification().send(...)`. snake_case VERBATIM — this
|
|
629
|
+
* is the wire contract (REQ-GEN-09). `recipient_user_id`, `title`, and `body`
|
|
630
|
+
* are required; `link` and `payload` are optional.
|
|
631
|
+
*/
|
|
632
|
+
export interface SendNotificationRequest {
|
|
633
|
+
recipient_user_id: string;
|
|
634
|
+
title: string;
|
|
635
|
+
body: string;
|
|
636
|
+
link?: string | null;
|
|
637
|
+
payload?: Record<string, unknown> | null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* A notification row, snake_case verbatim as returned by the backend
|
|
642
|
+
* (`POST /notifications/send`, 201). The shape is open-ended; the well-known
|
|
643
|
+
* fields are listed.
|
|
644
|
+
*/
|
|
645
|
+
export interface NotificationRow {
|
|
646
|
+
id: string;
|
|
647
|
+
tenant_id?: string;
|
|
648
|
+
user_id?: string;
|
|
649
|
+
title?: string;
|
|
650
|
+
body?: string;
|
|
651
|
+
link?: string | null;
|
|
652
|
+
payload?: Record<string, unknown> | null;
|
|
653
|
+
read_at?: string | null;
|
|
654
|
+
created_at?: string;
|
|
655
|
+
[key: string]: unknown;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface SendNotificationApi {
|
|
659
|
+
send(body: SendNotificationRequest): Promise<NotificationRow>;
|
|
660
|
+
sending: boolean;
|
|
661
|
+
error: NotificationError | null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* sc-890 — send an in-app notification to one app user in the tenant. Returns
|
|
666
|
+
* `{ send, sending, error }`. The hook is imperative — `send` never fires on
|
|
667
|
+
* mount; the widget calls it from an event handler. Rejects with a
|
|
668
|
+
* `NotificationError`. Requires the `notifications.send:appUser` scope in the
|
|
669
|
+
* widget manifest.
|
|
670
|
+
*/
|
|
671
|
+
export function useSendNotification(): SendNotificationApi;
|
|
672
|
+
|
|
615
673
|
export function useTheme(): ThemeTokens;
|
|
616
674
|
|
|
617
675
|
/**
|
|
@@ -905,6 +963,30 @@ export class DirectoryError extends Error {
|
|
|
905
963
|
);
|
|
906
964
|
}
|
|
907
965
|
|
|
966
|
+
/**
|
|
967
|
+
* sc-890 — error class thrown by `useSendNotification().send`. The `code` is a
|
|
968
|
+
* stable categorisation widgets can branch on.
|
|
969
|
+
*/
|
|
970
|
+
export class NotificationError extends Error {
|
|
971
|
+
code:
|
|
972
|
+
| "INVALID_TITLE"
|
|
973
|
+
| "INVALID_BODY"
|
|
974
|
+
| "INVALID_RECIPIENT"
|
|
975
|
+
| "INVALID_PAYLOAD"
|
|
976
|
+
| "VALIDATION"
|
|
977
|
+
| "AUTH_REQUIRED"
|
|
978
|
+
| "FORBIDDEN"
|
|
979
|
+
| "RECIPIENT_NOT_FOUND"
|
|
980
|
+
| "RATE_LIMITED"
|
|
981
|
+
| "INTERNAL"
|
|
982
|
+
| string;
|
|
983
|
+
constructor(
|
|
984
|
+
code: NotificationError["code"],
|
|
985
|
+
message: string,
|
|
986
|
+
opts?: { cause?: unknown },
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
|
|
908
990
|
// --------------------------------------------------------------- useUsers
|
|
909
991
|
//
|
|
910
992
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
WidgetContextProvider,
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
|
+
NotificationError,
|
|
12
13
|
DirectoryError,
|
|
13
14
|
PermissionError,
|
|
14
15
|
useDatastoreQuery,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
useAsset,
|
|
18
19
|
useAssetsByTag,
|
|
19
20
|
useFilestoreFiles,
|
|
21
|
+
useFilestoreUpload,
|
|
20
22
|
useFilestoreFolders,
|
|
21
23
|
useFileSignature,
|
|
22
24
|
useFileSignatures,
|
|
@@ -31,6 +33,7 @@ export {
|
|
|
31
33
|
useDatastoreSubscription,
|
|
32
34
|
useWidgetEvent,
|
|
33
35
|
usePayments,
|
|
36
|
+
useSendNotification,
|
|
34
37
|
useTheme,
|
|
35
38
|
useWidgetStyle,
|
|
36
39
|
useI18n,
|
|
@@ -61,6 +64,7 @@ export {
|
|
|
61
64
|
Linking,
|
|
62
65
|
Icon,
|
|
63
66
|
DateTimePicker,
|
|
67
|
+
FilePicker,
|
|
64
68
|
} from "./primitives.js";
|
|
65
69
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
66
70
|
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/index.native.js
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
WidgetContextProvider,
|
|
10
10
|
DatastoreError,
|
|
11
11
|
PaymentError,
|
|
12
|
+
NotificationError,
|
|
12
13
|
DirectoryError,
|
|
13
14
|
PermissionError,
|
|
14
15
|
useDatastoreQuery,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
useAsset,
|
|
18
19
|
useAssetsByTag,
|
|
19
20
|
useFilestoreFiles,
|
|
21
|
+
useFilestoreUpload,
|
|
20
22
|
useFilestoreFolders,
|
|
21
23
|
useFileSignature,
|
|
22
24
|
useFileSignatures,
|
|
@@ -29,6 +31,7 @@ export {
|
|
|
29
31
|
useDatastoreSubscription,
|
|
30
32
|
useWidgetEvent,
|
|
31
33
|
usePayments,
|
|
34
|
+
useSendNotification,
|
|
32
35
|
useTheme,
|
|
33
36
|
useWidgetStyle,
|
|
34
37
|
useI18n,
|
|
@@ -57,6 +60,7 @@ export {
|
|
|
57
60
|
Linking,
|
|
58
61
|
Icon,
|
|
59
62
|
DateTimePicker,
|
|
63
|
+
FilePicker,
|
|
60
64
|
} from "./primitives.native.js";
|
|
61
65
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
62
66
|
export { CONTRACT, isHookAllowed, requiredContextKeys } from "./contract.js";
|
package/dist/linter.cjs
CHANGED
|
@@ -240,11 +240,31 @@ function _classifySpecifier(spec) {
|
|
|
240
240
|
return "bare";
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
|
|
244
|
+
// `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
|
|
245
|
+
// belongs to that package, so the root carries the vetting + platforms.
|
|
246
|
+
// Mirror of linter.js.
|
|
247
|
+
function _packageRoot(spec) {
|
|
248
|
+
const parts = spec.split("/");
|
|
249
|
+
// A traversal / empty segment is a non-canonical specifier — never resolve it
|
|
250
|
+
// to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
|
|
251
|
+
if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
|
|
252
|
+
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
253
|
+
}
|
|
254
|
+
|
|
243
255
|
function _importRules(source, manifest) {
|
|
244
256
|
const findings = [];
|
|
245
257
|
const allowed = new Map(
|
|
246
258
|
CONTRACT.vettedImports.map((v) => [v.specifier, v]),
|
|
247
259
|
);
|
|
260
|
+
// Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
|
|
261
|
+
// both runtimes but kept off the author-facing vetted list — accept them so
|
|
262
|
+
// a bundle's transitive react-dom import isn't flagged import-not-vetted.
|
|
263
|
+
for (const spec of CONTRACT.allowedBareImports) {
|
|
264
|
+
if (!allowed.has(spec)) {
|
|
265
|
+
allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
248
268
|
const declaredPlatforms =
|
|
249
269
|
manifest &&
|
|
250
270
|
Array.isArray(manifest.supportedPlatforms) &&
|
|
@@ -279,7 +299,7 @@ function _importRules(source, manifest) {
|
|
|
279
299
|
});
|
|
280
300
|
continue;
|
|
281
301
|
}
|
|
282
|
-
const entry = allowed.get(spec);
|
|
302
|
+
const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
|
|
283
303
|
if (!entry) {
|
|
284
304
|
findings.push({
|
|
285
305
|
rule: "import-not-vetted",
|
|
@@ -574,6 +594,20 @@ function _lucideIconRules(source) {
|
|
|
574
594
|
return findings;
|
|
575
595
|
}
|
|
576
596
|
|
|
597
|
+
// Narrow a split-impl widget's manifest to the platform a single bundle file
|
|
598
|
+
// ships to, so `import-platform-mismatch` lints each file against what it
|
|
599
|
+
// actually targets. Mirror of linter.js.
|
|
600
|
+
function narrowManifestForFile(manifest, filename) {
|
|
601
|
+
if (!manifest || typeof manifest !== "object") return manifest;
|
|
602
|
+
if (filename === "widget.web.jsx") {
|
|
603
|
+
return { ...manifest, supportedPlatforms: ["web"] };
|
|
604
|
+
}
|
|
605
|
+
if (filename === "widget.native.jsx") {
|
|
606
|
+
return { ...manifest, supportedPlatforms: ["native"] };
|
|
607
|
+
}
|
|
608
|
+
return manifest;
|
|
609
|
+
}
|
|
610
|
+
|
|
577
611
|
function lintSource(source, options) {
|
|
578
612
|
if (typeof source !== "string") {
|
|
579
613
|
return {
|
|
@@ -630,4 +664,4 @@ function lintSource(source, options) {
|
|
|
630
664
|
return { ok: !hasErrors, findings };
|
|
631
665
|
}
|
|
632
666
|
|
|
633
|
-
module.exports = { lintSource, bannedIdentifiers };
|
|
667
|
+
module.exports = { lintSource, bannedIdentifiers, narrowManifestForFile };
|
package/dist/linter.js
CHANGED
|
@@ -266,9 +266,29 @@ function _classifySpecifier(spec) {
|
|
|
266
266
|
return "bare";
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// The package root of a bare specifier: `leaflet/dist/leaflet.css` → "leaflet",
|
|
270
|
+
// `@scope/pkg/sub` → "@scope/pkg". A subpath/asset import of a vetted package
|
|
271
|
+
// (its CSS, a marker PNG, react-dom/client) belongs to that package, so the
|
|
272
|
+
// root carries the vetting + platform metadata.
|
|
273
|
+
function _packageRoot(spec) {
|
|
274
|
+
const parts = spec.split("/");
|
|
275
|
+
// A traversal / empty segment is a non-canonical specifier — never resolve it
|
|
276
|
+
// to a vetted root, or "leaflet/../evil" would pass the gate as "leaflet".
|
|
277
|
+
if (parts.some((p) => p === "" || p === "." || p === "..")) return null;
|
|
278
|
+
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
279
|
+
}
|
|
280
|
+
|
|
269
281
|
function _importRules(source, manifest) {
|
|
270
282
|
const findings = [];
|
|
271
283
|
const allowed = new Map(CONTRACT.vettedImports.map((v) => [v.specifier, v]));
|
|
284
|
+
// Core-infra specifiers (react-dom, react-dom/client) are host-shimmed on
|
|
285
|
+
// both runtimes but kept off the author-facing vetted list — accept them so
|
|
286
|
+
// a bundle's transitive react-dom import isn't flagged import-not-vetted.
|
|
287
|
+
for (const spec of CONTRACT.allowedBareImports) {
|
|
288
|
+
if (!allowed.has(spec)) {
|
|
289
|
+
allowed.set(spec, { specifier: spec, platforms: ["web", "native"] });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
272
292
|
// Track declared `supportedPlatforms` so a widget that claims "web only"
|
|
273
293
|
// doesn't import a native-only package (and vice versa) without the
|
|
274
294
|
// marketplace listing being honest about which platforms ship.
|
|
@@ -307,8 +327,9 @@ function _importRules(source, manifest) {
|
|
|
307
327
|
});
|
|
308
328
|
continue;
|
|
309
329
|
}
|
|
310
|
-
// Bare specifier — validate against the vetted list.
|
|
311
|
-
|
|
330
|
+
// Bare specifier — validate against the vetted list. A subpath/asset
|
|
331
|
+
// import (leaflet/dist/leaflet.css) resolves to its vetted package root.
|
|
332
|
+
const entry = allowed.get(spec) || allowed.get(_packageRoot(spec));
|
|
312
333
|
if (!entry) {
|
|
313
334
|
findings.push({
|
|
314
335
|
rule: "import-not-vetted",
|
|
@@ -662,6 +683,24 @@ function _lucideIconRules(source) {
|
|
|
662
683
|
return findings;
|
|
663
684
|
}
|
|
664
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Narrow a split-impl widget's manifest to the platform a single bundle file
|
|
688
|
+
* ships to, so `import-platform-mismatch` lints each file against what it
|
|
689
|
+
* actually targets. `widget.web.jsx` → ["web"], `widget.native.jsx` →
|
|
690
|
+
* ["native"], cross-platform `widget.jsx` keeps the manifest's union claim.
|
|
691
|
+
* Returns a shallow clone; the original is untouched.
|
|
692
|
+
*/
|
|
693
|
+
export function narrowManifestForFile(manifest, filename) {
|
|
694
|
+
if (!manifest || typeof manifest !== "object") return manifest;
|
|
695
|
+
if (filename === "widget.web.jsx") {
|
|
696
|
+
return { ...manifest, supportedPlatforms: ["web"] };
|
|
697
|
+
}
|
|
698
|
+
if (filename === "widget.native.jsx") {
|
|
699
|
+
return { ...manifest, supportedPlatforms: ["native"] };
|
|
700
|
+
}
|
|
701
|
+
return manifest;
|
|
702
|
+
}
|
|
703
|
+
|
|
665
704
|
export function lintSource(source, options) {
|
|
666
705
|
if (typeof source !== "string") {
|
|
667
706
|
return {
|
package/dist/primitives.js
CHANGED
|
@@ -70,3 +70,9 @@ export { Icon } from "./icon.js";
|
|
|
70
70
|
// react-native-web mapping. Both implementations honour the same
|
|
71
71
|
// ISO 8601 value / onChange contract.
|
|
72
72
|
export { DateTimePicker } from "./datetimepicker.js";
|
|
73
|
+
// sc-1378 — `<FilePicker>` (web). Wraps a hidden DOM `<input type="file">`
|
|
74
|
+
// because react-native-web has no native equivalent. The native build's
|
|
75
|
+
// counterpart (./filepicker.native.js) degrades to a disabled trigger until
|
|
76
|
+
// a vetted expo-document-picker pin lands. `isSupported` is a static
|
|
77
|
+
// boolean widgets branch on to hide the trigger on native.
|
|
78
|
+
export { FilePicker } from "./filepicker.js";
|
|
@@ -31,3 +31,8 @@ export { Icon } from "./icon.js";
|
|
|
31
31
|
// `./datetimepicker.js`. Both files honour the same ISO 8601 value /
|
|
32
32
|
// onChange contract.
|
|
33
33
|
export { DateTimePicker } from "./datetimepicker.native.js";
|
|
34
|
+
// sc-1378 — `<FilePicker>` (native). Web counterpart (./filepicker.js)
|
|
35
|
+
// wraps a hidden DOM `<input type="file">`; native has no portable
|
|
36
|
+
// equivalent yet, so this build renders a disabled trigger and exposes
|
|
37
|
+
// `FilePicker.isSupported = false` for widgets to branch on.
|
|
38
|
+
export { FilePicker } from "./filepicker.native.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "node scripts/build.js",
|
|
45
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
|
|
45
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
48
|
"node": ">=18"
|