@colixsystems/widget-sdk 0.77.0 → 0.79.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 -2
- package/dist/contract.cjs +117 -1
- package/dist/contract.js +117 -1
- package/dist/hooks.js +350 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.js +4 -0
- package/dist/index.native.js +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,9 +22,11 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
22
22
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
23
23
|
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
|
|
24
24
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
25
|
+
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
25
26
|
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
26
27
|
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
27
28
|
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
29
|
+
| **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
|
|
28
30
|
| **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
|
|
29
31
|
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
|
|
30
32
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
|
|
@@ -45,6 +47,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
45
47
|
| **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId? })` | `{ exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.exportPdf` — `files.write:*`. `exportToPdf(html, { fileName?, folderId? })` renders the HTML to a PDF server-side and saves it as a file (`application/pdf`); same server-side renderer on web + native. |
|
|
46
48
|
| **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser` |
|
|
47
49
|
| **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`. |
|
|
50
|
+
| **IDENTIFICATION** (`ctx.identification`) | `useIdentification({ provider?, purpose?, pollIntervalMs? })` | `{ available, status, qr, autoStartToken, message, identity, identificationId, start, refresh, cancel, reset, … }` | `ctx.identification.*` — no scope (the visitor is deliberately NOT signed in). Gate the UI on `available`; `start()` opens the order and the hook polls to completion. `identity` carries `personal_number_masked` + a stable `subject_hash` — never a raw personal number. |
|
|
48
51
|
|
|
49
52
|
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 }`).
|
|
50
53
|
|
|
@@ -56,6 +59,20 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
56
59
|
|
|
57
60
|
`v0.77.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**.
|
|
58
61
|
|
|
62
|
+
### What's new in 0.78.0
|
|
63
|
+
|
|
64
|
+
**New `useIdentification()` hook — identify a visitor who is NOT signed in (REQ-IDENT, sc-4313).** A new IDENTIFICATION hook reading a newly-injected `ctx.identification` slice (the new `@colixsystems/identification-client`, constructed by both the web Player and the native Expo export). Returns `{ available, availabilityLoading, status, qr, autoStartToken, message, identity, identificationId, loading, error, start, refresh, cancel, reset }`.
|
|
65
|
+
|
|
66
|
+
It exists to **prove presence and keep the result** — an attestation on a record, a consent line, an identity check before a submit. It creates **no account and no session**: to sign someone *in* use the app's login, to attach BankID to an existing account use `useBankIdLink()`, and to e-sign a file's bytes use `useFileSignature()`.
|
|
67
|
+
|
|
68
|
+
**BankID is the first provider**, and the API is provider-abstracted — a future provider becomes available without a widget change (`options.provider` defaults to `"bankid"`).
|
|
69
|
+
|
|
70
|
+
Gate the UI on `available`: when it is `false` the provider is not configured on the deployment and no QR can ever complete, so render nothing rather than a dead button. `start()` opens an order and **the hook polls it to completion for you** (`pollIntervalMs`, default `1000`; pass `0` to drive `refresh()` yourself), clearing its timer on unmount — a widget renders state instead of owning a loop. Render `qr` with the `Image` primitive and show `message` (a display-ready instruction); `autoStartToken` opens the provider app on the same device.
|
|
71
|
+
|
|
72
|
+
**No raw personal number is reachable from a widget.** On completion `identity` is `{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at }` — `personal_number_masked` is `"19900101-****"` and `subject_hash` is stable for the same person, so a returning visitor is recognisable without the number. The full value stays server-side behind a studio-admin endpoint, so it can never end up in page JSON or a datastore column by accident. Write the masked string (and `identificationId`, to trace the proof) into your column.
|
|
73
|
+
|
|
74
|
+
`options.purpose` is a short audit label ("attest", "age_check"), capped at 120 characters. Orders expire five minutes after `start()`. It needs **no manifest scope** and **no `requestedScopes` entry** — requiring one would defeat a flow whose whole point is an anonymous visitor. Rejections surface as a structured `IdentificationError` (new named export) with a stable `.code` (`NOT_CONFIGURED` / `UNKNOWN_PROVIDER` / `NOT_FOUND` / `RATE_LIMITED` / `UNAVAILABLE` / `INTERNAL`). `CONTRACT.version` → `1.52.0`. Additive — one new hook, one new context slice, one new error class, one new client package; no existing export changed signature.
|
|
75
|
+
|
|
59
76
|
### What's new in 0.77.0
|
|
60
77
|
|
|
61
78
|
**`ui.group` is a layout hint, not a visibility rule (sc-4176).** 0.75.0 gave `"Basics"` a reserved meaning: Agent Mode's in-preview edit panel rendered only that group and pointed the author at the Builder for the rest. That withheld styling from an author already editing the widget in front of them, so the reserved behaviour is **retired**.
|
|
@@ -285,6 +302,8 @@ useEffect(() => {
|
|
|
285
302
|
|
|
286
303
|
### What's new in 0.41.0
|
|
287
304
|
|
|
305
|
+
**New `useSectionEmpty(isEmpty)` hook + optional `ctx.section` slice (sc-4416).** A widget can now tell the host it has no content to show, and the host removes its layout slot rather than reserving space for it. This closes a gap that `null` alone could not: the host wraps every widget node in an entrance element, so a widget that rendered nothing still left an empty box its parent stack put `gap` around — a dead band of whitespace exactly where the content would have been. It matters most for a per-record child collection (a policy detail page whose quiz section only exists for policies that have questions): the widget owns the rows, so only the widget can say, and `visibleWhen` cannot reach it because "has related rows" is not a field the record carries. The widget stays MOUNTED while collapsed, so when rows arrive it reports `false` and the section returns on its own — no measurement, no second pass. The slot (`ctx.section.reportEmpty`) is optional and deliberately omitted on authoring surfaces: on the Studio canvas and in the Agent Mode edit preview an empty widget must stay visible and selectable, or an absent section could never be edited. Additive — `CONTRACT.version` bumped to the next minor for a new hook plus a new optional context slice.
|
|
306
|
+
|
|
288
307
|
**New `useRefresh(handler)` hook + page-level refresh signal (sc-1179).** Pull-to-refresh on the mobile web Player + the native Expo export's `RefreshControl` now fans a page-level refresh tick out to every widget on the page. The three datastore hooks — `useDatastoreQuery`, `useDatastoreRecord`, `useAsset` — auto-subscribe their own `refetch`, so a widget built on those hooks gets refreshed for free. Widgets that need to re-run other work (a third-party `fetch`, a derived calculation) call `useRefresh(async () => { … })` directly. The handler may return a Promise — the host waits on `Promise.allSettled` of every subscriber before clearing the spinner. The slot (`ctx.refresh.subscribe`) is optional on the WidgetContext: a host that does not implement refresh (the Studio canvas preview) simply omits it and the hook collapses to a no-op. Additive — `CONTRACT.version` bumped to the next minor since the contract grew a new hook + a new (optional) context slice.
|
|
289
308
|
|
|
290
309
|
### What's new in 0.40.2
|
|
@@ -522,6 +541,7 @@ The "split-implementation + vetted package list" pivot.
|
|
|
522
541
|
### What's new in 0.11.0
|
|
523
542
|
|
|
524
543
|
- **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
|
|
544
|
+
- **`usePageContext()` reads the page's DECLARED parameters.** When a page declares parameters (Page Settings → Parameters), the host resolves them ONCE before any widget renders and fetches each `record` parameter's row for the whole page. `params` holds the values coerced to their declared types (a `number` parameter is a number, not the string `useRouteParams()` returns); `records` maps each `record` parameter to its already-loaded row — read it rather than issuing the same request from every widget. A required parameter that is absent, malformed, or whose record does not resolve never reaches the widget: the host renders one page-level state instead. Both bags are empty on a page that declares nothing, so fall back to `useRouteParams()` for a page you do not control. Additive (v0.77.0).
|
|
525
545
|
- **`useRouteParams()` reads the nav params.** Returns `currentRoute.params` — the bag a `goTo(pageId, params)` carried to this page. The flat accessor for master→detail: navigate with `goTo(detailPageId, { recordId: row.id })`, then read `const { recordId } = useRouteParams()`. It is an OBJECT — read a param off it, never call it. Empty object when the page was opened without params. Additive (v0.61.0).
|
|
526
546
|
- **`Linking` primitive re-exported.** `Linking.openURL(url)` opens an external URL with the OS handler — web (`react-native-web`) maps to `window.open` / `location.href`; native hands off to the system. Use this for external URLs; use `useNavigation().goTo(pageId)` for internal pages.
|
|
527
547
|
|
|
@@ -595,7 +615,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
595
615
|
|
|
596
616
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
597
617
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
598
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `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, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `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).
|
|
618
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `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, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `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)`). `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `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).
|
|
599
619
|
- `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.
|
|
600
620
|
- `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.
|
|
601
621
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -613,7 +633,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
613
633
|
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
614
634
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
615
635
|
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
|
|
616
|
-
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
|
|
636
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them.
|
|
617
637
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
618
638
|
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
|
|
619
639
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
package/dist/contract.cjs
CHANGED
|
@@ -358,6 +358,29 @@ const HOOKS = [
|
|
|
358
358
|
requiredContextSlice: ["refresh.subscribe"],
|
|
359
359
|
scopes: null,
|
|
360
360
|
},
|
|
361
|
+
{
|
|
362
|
+
name: "useSectionEmpty",
|
|
363
|
+
signature: "useSectionEmpty(isEmpty)",
|
|
364
|
+
description:
|
|
365
|
+
"Declare that this widget currently has NO content to show, so the host " +
|
|
366
|
+
"removes its layout slot instead of reserving space for it. Returning " +
|
|
367
|
+
"null is NOT enough on its own: the host wraps every widget node in an " +
|
|
368
|
+
"entrance wrapper, so a widget that renders nothing still leaves an " +
|
|
369
|
+
"empty box its parent stack puts `gap` around — a dead band of " +
|
|
370
|
+
"whitespace where the content would have been. Only the widget has the " +
|
|
371
|
+
"rows, so only the widget can say. Use it for a CONDITIONALLY ABSENT " +
|
|
372
|
+
"section (a per-record child collection with no rows for the record " +
|
|
373
|
+
"being viewed), never to suppress a genuine empty state a user is " +
|
|
374
|
+
"expected to fill. The widget stays mounted while collapsed, so passing " +
|
|
375
|
+
"false later brings the section back. Authoring surfaces (Studio canvas, " +
|
|
376
|
+
"Agent Mode edit preview) never collapse, so an absent section stays " +
|
|
377
|
+
"editable. Safe on a host that does not implement it — a no-op there.",
|
|
378
|
+
returnShape: {
|
|
379
|
+
"(returns)": "void",
|
|
380
|
+
},
|
|
381
|
+
requiredContextSlice: ["section.reportEmpty"],
|
|
382
|
+
scopes: null,
|
|
383
|
+
},
|
|
361
384
|
{
|
|
362
385
|
name: "useNavigation",
|
|
363
386
|
signature: "useNavigation()",
|
|
@@ -382,6 +405,18 @@ const HOOKS = [
|
|
|
382
405
|
requiredContextSlice: ["navigation"],
|
|
383
406
|
scopes: null,
|
|
384
407
|
},
|
|
408
|
+
{
|
|
409
|
+
name: "usePageContext",
|
|
410
|
+
signature: "usePageContext()",
|
|
411
|
+
returnShape: {
|
|
412
|
+
params:
|
|
413
|
+
"the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
|
|
414
|
+
records:
|
|
415
|
+
"<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
|
|
416
|
+
},
|
|
417
|
+
requiredContextSlice: ["pageContext"],
|
|
418
|
+
scopes: null,
|
|
419
|
+
},
|
|
385
420
|
{
|
|
386
421
|
name: "useDatastoreRecord",
|
|
387
422
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
@@ -834,6 +869,55 @@ const HOOKS = [
|
|
|
834
869
|
requiredContextSlice: ["directory.bankid"],
|
|
835
870
|
scopes: null,
|
|
836
871
|
},
|
|
872
|
+
// REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
|
|
873
|
+
// result. Anonymous by design (no widget scope, no session). Mirror of contract.js.
|
|
874
|
+
{
|
|
875
|
+
name: "useIdentification",
|
|
876
|
+
signature: "useIdentification(options?)",
|
|
877
|
+
description:
|
|
878
|
+
"Identify a visitor who is NOT signed in via the injected " +
|
|
879
|
+
"identification-client at ctx.identification.{available,start,get,cancel}. " +
|
|
880
|
+
"Returns { available, availabilityLoading, status, qr, autoStartToken, " +
|
|
881
|
+
"message, identity, identificationId, loading, error, start, refresh, " +
|
|
882
|
+
"cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
|
|
883
|
+
"attestation on a record, a consent line, an identity check before a " +
|
|
884
|
+
"submit. It creates NO account and NO session: to sign someone IN use the " +
|
|
885
|
+
"app's login, to attach BankID to an existing account use useBankIdLink(), " +
|
|
886
|
+
"to e-sign a file use useFileSignature(). On mount it reads provider " +
|
|
887
|
+
"availability; `available` is false when identification can't be used here " +
|
|
888
|
+
"(provider not configured) — render nothing rather than a dead button. " +
|
|
889
|
+
"start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
|
|
890
|
+
"with the Image primitive) and the hook then POLLS to completion itself " +
|
|
891
|
+
"(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
|
|
892
|
+
"cancel() aborts; reset() clears the flow. autoStartToken opens the " +
|
|
893
|
+
"provider app on the SAME device (bankid:///?autostarttoken=<token>" +
|
|
894
|
+
"&redirect=null). On complete, `identity` is { provider, name, given_name, " +
|
|
895
|
+
"surname, personal_number_masked, subject_hash, identified_at } — there is " +
|
|
896
|
+
"NO raw personal number by design: write personal_number_masked " +
|
|
897
|
+
"(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
|
|
898
|
+
"subject_hash is stable per person so a returning visitor is recognisable " +
|
|
899
|
+
"without the number. `options.purpose` is a short audit label capped at 120 " +
|
|
900
|
+
"chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
|
|
901
|
+
returnShape: {
|
|
902
|
+
available: "boolean // false → don't offer the flow",
|
|
903
|
+
availabilityLoading: "boolean",
|
|
904
|
+
status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
|
|
905
|
+
qr: "string | null // PNG data-URL of the animated QR",
|
|
906
|
+
autoStartToken: "string | null // same-device deeplink token",
|
|
907
|
+
message: "string | null // display-ready instruction for this step",
|
|
908
|
+
identity:
|
|
909
|
+
"{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
|
|
910
|
+
identificationId: "string | null // store alongside an attestation",
|
|
911
|
+
loading: "boolean",
|
|
912
|
+
error: "IdentificationError | null",
|
|
913
|
+
start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
|
|
914
|
+
refresh: "() => Promise<void>",
|
|
915
|
+
cancel: "() => Promise<void>",
|
|
916
|
+
reset: "() => void",
|
|
917
|
+
},
|
|
918
|
+
requiredContextSlice: ["identification"],
|
|
919
|
+
scopes: null,
|
|
920
|
+
},
|
|
837
921
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
838
922
|
// management for a single record. Mirror of contract.js.
|
|
839
923
|
{
|
|
@@ -1306,6 +1390,16 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1306
1390
|
required: true,
|
|
1307
1391
|
fields: { push: "function", replace: "function", back: "function" },
|
|
1308
1392
|
},
|
|
1393
|
+
pageContext: {
|
|
1394
|
+
description:
|
|
1395
|
+
"REQ-NAV-05 — the PAGE's resolved parameters. " +
|
|
1396
|
+
"{ params: { <name>: value }, records: { <name>: Record|null } }. " +
|
|
1397
|
+
"`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
|
|
1398
|
+
"`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
|
|
1399
|
+
"Both bags are empty on a page that declares no parameters. Backs usePageContext().",
|
|
1400
|
+
required: true,
|
|
1401
|
+
fields: { params: "object", records: "object" },
|
|
1402
|
+
},
|
|
1309
1403
|
datastore: {
|
|
1310
1404
|
description:
|
|
1311
1405
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -1367,6 +1461,21 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1367
1461
|
required: false,
|
|
1368
1462
|
fields: { subscribe: "function" },
|
|
1369
1463
|
},
|
|
1464
|
+
// sc-4416 — widget -> host emptiness signal. The widget owns the knowledge
|
|
1465
|
+
// (it has the rows); the host owns the spacing (the slot and its gap).
|
|
1466
|
+
// Optional so an AUTHORING host can omit it on purpose: with no slice the
|
|
1467
|
+
// hook is a no-op and an empty widget stays visible and selectable on the
|
|
1468
|
+
// Studio canvas and in the Agent Mode edit preview.
|
|
1469
|
+
section: {
|
|
1470
|
+
description:
|
|
1471
|
+
"Optional layout-slot slot. { reportEmpty(isEmpty) }. Called by the " +
|
|
1472
|
+
"widget when it has no content to show; the host then drops the node's " +
|
|
1473
|
+
"layout slot so its parent stack puts no gap around it. Backs " +
|
|
1474
|
+
"useSectionEmpty(). Omitted on authoring surfaces, where an empty " +
|
|
1475
|
+
"widget must stay visible.",
|
|
1476
|
+
required: false,
|
|
1477
|
+
fields: { reportEmpty: "function" },
|
|
1478
|
+
},
|
|
1370
1479
|
events: {
|
|
1371
1480
|
description: "{ emit(name, payload) }.",
|
|
1372
1481
|
required: true,
|
|
@@ -1385,6 +1494,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1385
1494
|
required: true,
|
|
1386
1495
|
fields: { send: "function" },
|
|
1387
1496
|
},
|
|
1497
|
+
// REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.js.
|
|
1498
|
+
identification: {
|
|
1499
|
+
description:
|
|
1500
|
+
"Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
|
|
1501
|
+
required: true,
|
|
1502
|
+
fields: { available: "function", start: "function", get: "function", cancel: "function" },
|
|
1503
|
+
},
|
|
1388
1504
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
1389
1505
|
// administration, and per-record VirtualPermission facades that used to
|
|
1390
1506
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -2290,7 +2406,7 @@ const CONTRACT = deepFreeze({
|
|
|
2290
2406
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2291
2407
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2292
2408
|
// the Developer guide and the agent prompt reading one source.
|
|
2293
|
-
version: "1.
|
|
2409
|
+
version: "1.53.0",
|
|
2294
2410
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2295
2411
|
hooks: HOOKS,
|
|
2296
2412
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -358,6 +358,29 @@ const HOOKS = [
|
|
|
358
358
|
requiredContextSlice: ["refresh.subscribe"],
|
|
359
359
|
scopes: null,
|
|
360
360
|
},
|
|
361
|
+
{
|
|
362
|
+
name: "useSectionEmpty",
|
|
363
|
+
signature: "useSectionEmpty(isEmpty)",
|
|
364
|
+
description:
|
|
365
|
+
"Declare that this widget currently has NO content to show, so the host " +
|
|
366
|
+
"removes its layout slot instead of reserving space for it. Returning " +
|
|
367
|
+
"null is NOT enough on its own: the host wraps every widget node in an " +
|
|
368
|
+
"entrance wrapper, so a widget that renders nothing still leaves an " +
|
|
369
|
+
"empty box its parent stack puts `gap` around — a dead band of " +
|
|
370
|
+
"whitespace where the content would have been. Only the widget has the " +
|
|
371
|
+
"rows, so only the widget can say. Use it for a CONDITIONALLY ABSENT " +
|
|
372
|
+
"section (a per-record child collection with no rows for the record " +
|
|
373
|
+
"being viewed), never to suppress a genuine empty state a user is " +
|
|
374
|
+
"expected to fill. The widget stays mounted while collapsed, so passing " +
|
|
375
|
+
"false later brings the section back. Authoring surfaces (Studio canvas, " +
|
|
376
|
+
"Agent Mode edit preview) never collapse, so an absent section stays " +
|
|
377
|
+
"editable. Safe on a host that does not implement it — a no-op there.",
|
|
378
|
+
returnShape: {
|
|
379
|
+
"(returns)": "void",
|
|
380
|
+
},
|
|
381
|
+
requiredContextSlice: ["section.reportEmpty"],
|
|
382
|
+
scopes: null,
|
|
383
|
+
},
|
|
361
384
|
{
|
|
362
385
|
name: "useNavigation",
|
|
363
386
|
signature: "useNavigation()",
|
|
@@ -382,6 +405,18 @@ const HOOKS = [
|
|
|
382
405
|
requiredContextSlice: ["navigation"],
|
|
383
406
|
scopes: null,
|
|
384
407
|
},
|
|
408
|
+
{
|
|
409
|
+
name: "usePageContext",
|
|
410
|
+
signature: "usePageContext()",
|
|
411
|
+
returnShape: {
|
|
412
|
+
params:
|
|
413
|
+
"the page's DECLARED parameters, coerced to their declared types (a number param is a number here, not the string useRouteParams() returns). Empty when the page declares none.",
|
|
414
|
+
records:
|
|
415
|
+
"<paramName> → the row the HOST already fetched for a `record` parameter. Read it instead of fetching the same record again; empty when the page declares none.",
|
|
416
|
+
},
|
|
417
|
+
requiredContextSlice: ["pageContext"],
|
|
418
|
+
scopes: null,
|
|
419
|
+
},
|
|
385
420
|
{
|
|
386
421
|
name: "useDatastoreRecord",
|
|
387
422
|
signature: "useDatastoreRecord(tableId, recordId)",
|
|
@@ -834,6 +869,55 @@ const HOOKS = [
|
|
|
834
869
|
requiredContextSlice: ["directory.bankid"],
|
|
835
870
|
scopes: null,
|
|
836
871
|
},
|
|
872
|
+
// REQ-IDENT (sc-4313) — identify a visitor who is NOT signed in and keep the
|
|
873
|
+
// result. Anonymous by design (no widget scope, no session). Mirror of contract.cjs.
|
|
874
|
+
{
|
|
875
|
+
name: "useIdentification",
|
|
876
|
+
signature: "useIdentification(options?)",
|
|
877
|
+
description:
|
|
878
|
+
"Identify a visitor who is NOT signed in via the injected " +
|
|
879
|
+
"identification-client at ctx.identification.{available,start,get,cancel}. " +
|
|
880
|
+
"Returns { available, availabilityLoading, status, qr, autoStartToken, " +
|
|
881
|
+
"message, identity, identificationId, loading, error, start, refresh, " +
|
|
882
|
+
"cancel, reset }. USE IT TO PROVE PRESENCE AND KEEP THE RESULT — an " +
|
|
883
|
+
"attestation on a record, a consent line, an identity check before a " +
|
|
884
|
+
"submit. It creates NO account and NO session: to sign someone IN use the " +
|
|
885
|
+
"app's login, to attach BankID to an existing account use useBankIdLink(), " +
|
|
886
|
+
"to e-sign a file use useFileSignature(). On mount it reads provider " +
|
|
887
|
+
"availability; `available` is false when identification can't be used here " +
|
|
888
|
+
"(provider not configured) — render nothing rather than a dead button. " +
|
|
889
|
+
"start() opens an order (status 'pending' + qr, a PNG data-URL to render " +
|
|
890
|
+
"with the Image primitive) and the hook then POLLS to completion itself " +
|
|
891
|
+
"(pollIntervalMs, default 1000; pass 0 to drive refresh() yourself); " +
|
|
892
|
+
"cancel() aborts; reset() clears the flow. autoStartToken opens the " +
|
|
893
|
+
"provider app on the SAME device (bankid:///?autostarttoken=<token>" +
|
|
894
|
+
"&redirect=null). On complete, `identity` is { provider, name, given_name, " +
|
|
895
|
+
"surname, personal_number_masked, subject_hash, identified_at } — there is " +
|
|
896
|
+
"NO raw personal number by design: write personal_number_masked " +
|
|
897
|
+
"(\"19900101-****\") and, if you need to trace the proof, identificationId. " +
|
|
898
|
+
"subject_hash is stable per person so a returning visitor is recognisable " +
|
|
899
|
+
"without the number. `options.purpose` is a short audit label capped at 120 " +
|
|
900
|
+
"chars. Orders expire 5 minutes after start(). No requestedScopes entry needed.",
|
|
901
|
+
returnShape: {
|
|
902
|
+
available: "boolean // false → don't offer the flow",
|
|
903
|
+
availabilityLoading: "boolean",
|
|
904
|
+
status: "'pending' | 'complete' | 'failed' | 'cancelled' | null",
|
|
905
|
+
qr: "string | null // PNG data-URL of the animated QR",
|
|
906
|
+
autoStartToken: "string | null // same-device deeplink token",
|
|
907
|
+
message: "string | null // display-ready instruction for this step",
|
|
908
|
+
identity:
|
|
909
|
+
"{ provider, name, given_name, surname, personal_number_masked, subject_hash, identified_at } | null",
|
|
910
|
+
identificationId: "string | null // store alongside an attestation",
|
|
911
|
+
loading: "boolean",
|
|
912
|
+
error: "IdentificationError | null",
|
|
913
|
+
start: "() => Promise<{ identification_id, status, qr, auto_start_token, expires_at }>",
|
|
914
|
+
refresh: "() => Promise<void>",
|
|
915
|
+
cancel: "() => Promise<void>",
|
|
916
|
+
reset: "() => void",
|
|
917
|
+
},
|
|
918
|
+
requiredContextSlice: ["identification"],
|
|
919
|
+
scopes: null,
|
|
920
|
+
},
|
|
837
921
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
|
838
922
|
// management for a single record. Mirror of contract.js.
|
|
839
923
|
{
|
|
@@ -1306,6 +1390,16 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1306
1390
|
required: true,
|
|
1307
1391
|
fields: { push: "function", replace: "function", back: "function" },
|
|
1308
1392
|
},
|
|
1393
|
+
pageContext: {
|
|
1394
|
+
description:
|
|
1395
|
+
"REQ-NAV-05 — the PAGE's resolved parameters. " +
|
|
1396
|
+
"{ params: { <name>: value }, records: { <name>: Record|null } }. " +
|
|
1397
|
+
"`params` are the page's DECLARED parameters coerced to their declared types (a number param is a number, not the string useRouteParams() returns). " +
|
|
1398
|
+
"`records` holds the row the HOST already fetched for each `record` parameter — read it instead of fetching the same record again. " +
|
|
1399
|
+
"Both bags are empty on a page that declares no parameters. Backs usePageContext().",
|
|
1400
|
+
required: true,
|
|
1401
|
+
fields: { params: "object", records: "object" },
|
|
1402
|
+
},
|
|
1309
1403
|
datastore: {
|
|
1310
1404
|
description:
|
|
1311
1405
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -1367,6 +1461,21 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1367
1461
|
required: false,
|
|
1368
1462
|
fields: { subscribe: "function" },
|
|
1369
1463
|
},
|
|
1464
|
+
// sc-4416 — widget -> host emptiness signal. The widget owns the knowledge
|
|
1465
|
+
// (it has the rows); the host owns the spacing (the slot and its gap).
|
|
1466
|
+
// Optional so an AUTHORING host can omit it on purpose: with no slice the
|
|
1467
|
+
// hook is a no-op and an empty widget stays visible and selectable on the
|
|
1468
|
+
// Studio canvas and in the Agent Mode edit preview.
|
|
1469
|
+
section: {
|
|
1470
|
+
description:
|
|
1471
|
+
"Optional layout-slot slot. { reportEmpty(isEmpty) }. Called by the " +
|
|
1472
|
+
"widget when it has no content to show; the host then drops the node's " +
|
|
1473
|
+
"layout slot so its parent stack puts no gap around it. Backs " +
|
|
1474
|
+
"useSectionEmpty(). Omitted on authoring surfaces, where an empty " +
|
|
1475
|
+
"widget must stay visible.",
|
|
1476
|
+
required: false,
|
|
1477
|
+
fields: { reportEmpty: "function" },
|
|
1478
|
+
},
|
|
1370
1479
|
events: {
|
|
1371
1480
|
description: "{ emit(name, payload) }.",
|
|
1372
1481
|
required: true,
|
|
@@ -1385,6 +1494,13 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1385
1494
|
required: true,
|
|
1386
1495
|
fields: { send: "function" },
|
|
1387
1496
|
},
|
|
1497
|
+
// REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.cjs.
|
|
1498
|
+
identification: {
|
|
1499
|
+
description:
|
|
1500
|
+
"Injected @colixsystems/identification-client instance (REQ-IDENT). { available() -> Promise<{ available, providers }>, start(body) -> Promise<order>, get(id) -> Promise<state>, cancel(id) -> Promise<state> }. Backs useIdentification(); no widget scope and no signed-in user required — identifying a NOT-signed-in visitor is the point, so the endpoints are anonymous. Bodies and rows are snake_case verbatim. A completed identification exposes name + personal_number_masked + a stable subject_hash; the RAW personal number is never reachable from a widget (it lives behind a studio-admin endpoint).",
|
|
1501
|
+
required: true,
|
|
1502
|
+
fields: { available: "function", start: "function", get: "function", cancel: "function" },
|
|
1503
|
+
},
|
|
1388
1504
|
// REQ-WSDK-DOMAIN-CLIENTS — the AppUser administration, AppUserGroup
|
|
1389
1505
|
// administration, and per-record VirtualPermission facades that used to
|
|
1390
1506
|
// live here (`users`, `groups`, `recordPermissions`) were folded into the
|
|
@@ -2290,7 +2406,7 @@ const CONTRACT = deepFreeze({
|
|
|
2290
2406
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2291
2407
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2292
2408
|
// the Developer guide and the agent prompt reading one source.
|
|
2293
|
-
version: "1.
|
|
2409
|
+
version: "1.53.0",
|
|
2294
2410
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2295
2411
|
hooks: HOOKS,
|
|
2296
2412
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -242,6 +242,53 @@ export function useRefresh(handler) {
|
|
|
242
242
|
}, []);
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/**
|
|
246
|
+
* sc-4416 — declare that this widget currently has NO content to show, so the
|
|
247
|
+
* host removes its layout slot instead of reserving space for it.
|
|
248
|
+
*
|
|
249
|
+
* const { data: rows } = useDatastoreQuery(props.tableId, query);
|
|
250
|
+
* useSectionEmpty(rows.length === 0);
|
|
251
|
+
* if (rows.length === 0) return null;
|
|
252
|
+
*
|
|
253
|
+
* Returning `null` is not enough on its own. The host wraps every widget node
|
|
254
|
+
* in an element (the mount-time entrance wrapper), so a widget that renders
|
|
255
|
+
* nothing still leaves an empty box that its parent stack puts `gap` around —
|
|
256
|
+
* a dead band of whitespace exactly where the content would have been. This
|
|
257
|
+
* hook is how the widget tells the host to collapse that slot; nothing else
|
|
258
|
+
* can know, because only the widget has the rows.
|
|
259
|
+
*
|
|
260
|
+
* Reach for it whenever a section is CONDITIONALLY ABSENT — a per-record child
|
|
261
|
+
* collection with no rows for the record being viewed (a policy with no quiz
|
|
262
|
+
* questions), a panel that only applies to some states. Do NOT use it to hide
|
|
263
|
+
* a genuine empty state: a list the user is expected to fill should still say
|
|
264
|
+
* "Nothing here yet" so the page does not look broken.
|
|
265
|
+
*
|
|
266
|
+
* The widget stays MOUNTED while collapsed, so when rows do arrive it simply
|
|
267
|
+
* calls `useSectionEmpty(false)` on the next render and the section reappears.
|
|
268
|
+
* Call it unconditionally (it is a hook) and let the argument carry the state.
|
|
269
|
+
*
|
|
270
|
+
* Authoring surfaces never collapse — the Studio canvas and the Agent Mode
|
|
271
|
+
* edit preview keep an empty widget visible and selectable, or an absent
|
|
272
|
+
* section could not be edited. Safe to call on a host that does not implement
|
|
273
|
+
* it: the hook collapses to a no-op.
|
|
274
|
+
*/
|
|
275
|
+
export function useSectionEmpty(isEmpty) {
|
|
276
|
+
const ctx = useWidgetContextOrThrow("useSectionEmpty");
|
|
277
|
+
const report = ctx.section && ctx.section.reportEmpty;
|
|
278
|
+
const reportRef = useRef(report);
|
|
279
|
+
reportRef.current = report;
|
|
280
|
+
const empty = isEmpty === true;
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const fn = reportRef.current;
|
|
283
|
+
if (typeof fn !== "function") return undefined;
|
|
284
|
+
fn(empty);
|
|
285
|
+
// No cleanup on change — releasing the collapse before re-declaring it
|
|
286
|
+
// would flash the reserved slot back in between the two renders. The host
|
|
287
|
+
// drops its per-node state when the node unmounts.
|
|
288
|
+
return undefined;
|
|
289
|
+
}, [empty]);
|
|
290
|
+
}
|
|
291
|
+
|
|
245
292
|
/**
|
|
246
293
|
* Returns the host-provided navigation surface:
|
|
247
294
|
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
@@ -261,6 +308,13 @@ export function useNavigation() {
|
|
|
261
308
|
}
|
|
262
309
|
|
|
263
310
|
const EMPTY_PARAMS = Object.freeze({});
|
|
311
|
+
// Stable references so `useEffect([params])` / `[records]` don't re-fire every
|
|
312
|
+
// render on a page that declares no parameters.
|
|
313
|
+
const EMPTY_PAGE_RECORDS = Object.freeze({});
|
|
314
|
+
const EMPTY_PAGE_CONTEXT = Object.freeze({
|
|
315
|
+
params: EMPTY_PARAMS,
|
|
316
|
+
records: EMPTY_PAGE_RECORDS,
|
|
317
|
+
});
|
|
264
318
|
|
|
265
319
|
/**
|
|
266
320
|
* Returns the current route's navigation params — the bag a `goTo(pageId, params)`
|
|
@@ -281,6 +335,35 @@ export function useRouteParams() {
|
|
|
281
335
|
return route.params || EMPTY_PARAMS;
|
|
282
336
|
}
|
|
283
337
|
|
|
338
|
+
/**
|
|
339
|
+
* REQ-NAV-05 — the PAGE's resolved context: `{ params, records }`.
|
|
340
|
+
*
|
|
341
|
+
* When the page declares typed parameters (Page Settings → Parameters), the
|
|
342
|
+
* host resolves them ONCE before any widget renders: `params` holds the values
|
|
343
|
+
* coerced to their declared types (a `number` param is a number here, not the
|
|
344
|
+
* "42" string `useRouteParams()` returns), and `records` maps each `record`
|
|
345
|
+
* param to the row the host already fetched. Read the row from `records`
|
|
346
|
+
* instead of fetching it again — that duplicate fetch per widget is exactly
|
|
347
|
+
* what this hook exists to remove:
|
|
348
|
+
*
|
|
349
|
+
* const { records } = usePageContext();
|
|
350
|
+
* const policy = records.policy_id; // already loaded, no request
|
|
351
|
+
*
|
|
352
|
+
* A required parameter that is absent, malformed, or whose record does not
|
|
353
|
+
* resolve never reaches the widget — the host renders one page-level state
|
|
354
|
+
* instead. Both bags are empty on a page that declares nothing and on the
|
|
355
|
+
* Studio canvas, so a widget can always read them; fall back to
|
|
356
|
+
* `useRouteParams()` for a page you do not control.
|
|
357
|
+
*/
|
|
358
|
+
export function usePageContext() {
|
|
359
|
+
const ctx = useWidgetContextOrThrow("usePageContext");
|
|
360
|
+
const page = ctx.pageContext || EMPTY_PAGE_CONTEXT;
|
|
361
|
+
return {
|
|
362
|
+
params: page.params || EMPTY_PARAMS,
|
|
363
|
+
records: page.records || EMPTY_PAGE_RECORDS,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
284
367
|
/**
|
|
285
368
|
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
286
369
|
* the host's translation table and falls back to `fallback ?? key` when
|
|
@@ -3025,6 +3108,273 @@ export function useBankIdLink() {
|
|
|
3025
3108
|
};
|
|
3026
3109
|
}
|
|
3027
3110
|
|
|
3111
|
+
/* ============================================================================
|
|
3112
|
+
* IDENTIFICATION CLIENT — ctx.identification
|
|
3113
|
+
* (@colixsystems/identification-client)
|
|
3114
|
+
*
|
|
3115
|
+
* available, start, get, cancel. Covers: useIdentification.
|
|
3116
|
+
* ==========================================================================*/
|
|
3117
|
+
|
|
3118
|
+
/**
|
|
3119
|
+
* Structured error thrown by `useIdentification` callbacks. Carries a stable
|
|
3120
|
+
* `code` so widgets can branch without parsing message strings.
|
|
3121
|
+
*
|
|
3122
|
+
* `code` is one of:
|
|
3123
|
+
* - "NOT_CONFIGURED" — the provider is not set up on this deployment
|
|
3124
|
+
* - "UNKNOWN_PROVIDER"— the requested provider does not exist
|
|
3125
|
+
* - "NOT_FOUND" — no such identification in this workspace
|
|
3126
|
+
* - "RATE_LIMITED" — too many starts; back off and retry
|
|
3127
|
+
* - "UNAVAILABLE" — the provider itself failed. Retryable
|
|
3128
|
+
* - "INTERNAL" — anything else
|
|
3129
|
+
*/
|
|
3130
|
+
export class IdentificationError extends Error {
|
|
3131
|
+
constructor(code, message, opts) {
|
|
3132
|
+
super(message);
|
|
3133
|
+
this.name = "IdentificationError";
|
|
3134
|
+
this.code = code;
|
|
3135
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
function toIdentificationError(err) {
|
|
3140
|
+
if (err instanceof IdentificationError) return err;
|
|
3141
|
+
// The injected client already throws typed errors carrying .code / .status;
|
|
3142
|
+
// fall back to the axios-ish shape the other hooks normalise.
|
|
3143
|
+
const status =
|
|
3144
|
+
(err && typeof err.status === "number" ? err.status : null) ??
|
|
3145
|
+
(err && err.response && typeof err.response.status === "number"
|
|
3146
|
+
? err.response.status
|
|
3147
|
+
: null);
|
|
3148
|
+
const bodyCode =
|
|
3149
|
+
(err && typeof err.code === "string" ? err.code : null) ||
|
|
3150
|
+
(err && err.response && err.response.data && err.response.data.code);
|
|
3151
|
+
let code = "INTERNAL";
|
|
3152
|
+
if (bodyCode === "IDENTIFICATION_NOT_CONFIGURED") code = "NOT_CONFIGURED";
|
|
3153
|
+
else if (bodyCode === "UNKNOWN_PROVIDER") code = "UNKNOWN_PROVIDER";
|
|
3154
|
+
else if (bodyCode === "IDENTIFICATION_UNAVAILABLE") code = "UNAVAILABLE";
|
|
3155
|
+
else if (bodyCode === "RATE_LIMITED" || status === 429) code = "RATE_LIMITED";
|
|
3156
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
3157
|
+
else if (status === 409) code = "NOT_CONFIGURED";
|
|
3158
|
+
else if (status === 502) code = "UNAVAILABLE";
|
|
3159
|
+
const message =
|
|
3160
|
+
(err && typeof err.message === "string" && err.message) ||
|
|
3161
|
+
"Identification failed";
|
|
3162
|
+
return new IdentificationError(code, message, { cause: err });
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
/**
|
|
3166
|
+
* Identify a visitor who is NOT signed in (REQ-IDENT). Returns
|
|
3167
|
+
* `{ available, availabilityLoading, status, qr, autoStartToken, message,
|
|
3168
|
+
* identity, identificationId, loading, error, start, refresh, cancel, reset }`.
|
|
3169
|
+
*
|
|
3170
|
+
* `start()` opens an order and the hook then POLLS it for you until it is
|
|
3171
|
+
* terminal, clearing the timer on unmount — so a widget renders state rather
|
|
3172
|
+
* than running a loop. `refresh()` polls once by hand (set `pollIntervalMs: 0`
|
|
3173
|
+
* to own the cadence yourself); `cancel()` aborts; `reset()` clears the flow so
|
|
3174
|
+
* the visitor can start over. Reads
|
|
3175
|
+
* `ctx.identification.{available,start,get,cancel}`.
|
|
3176
|
+
*
|
|
3177
|
+
* WHAT THIS IS FOR — proving a real person holding a credential was present, so
|
|
3178
|
+
* the app can KEEP that: an attestation on a record, a consent line, an identity
|
|
3179
|
+
* check before a submit. It creates NO account and NO session. To sign someone
|
|
3180
|
+
* IN with BankID use the app's login; to attach BankID to an existing account
|
|
3181
|
+
* use `useBankIdLink()`; to e-sign a file's bytes use `useFileSignature()`.
|
|
3182
|
+
*
|
|
3183
|
+
* RENDER THE FLOW LIKE THIS. Gate on `available` first — when it is false the
|
|
3184
|
+
* provider is not configured and no QR can ever complete, so show nothing (or an
|
|
3185
|
+
* explanatory line) rather than a dead button. While `status === "pending"`,
|
|
3186
|
+
* render `qr` with the `Image` primitive and show `message` (a display-ready,
|
|
3187
|
+
* localised instruction such as "Enter your security code in the BankID app").
|
|
3188
|
+
* On the SAME device, `autoStartToken` opens the provider app directly —
|
|
3189
|
+
* `bankid:///?autostarttoken=<token>&redirect=null`.
|
|
3190
|
+
*
|
|
3191
|
+
* ON COMPLETION you get `identity`:
|
|
3192
|
+
* `{ provider, name, given_name, surname, personal_number_masked,
|
|
3193
|
+
* subject_hash, identified_at }`
|
|
3194
|
+
* There is deliberately NO raw personal number: `personal_number_masked` is
|
|
3195
|
+
* `"19900101-****"` and `subject_hash` is stable for the same person, so you can
|
|
3196
|
+
* recognise a returning visitor without ever holding the number. Write the
|
|
3197
|
+
* masked string (and `identificationId`, if you want to trace the proof) into
|
|
3198
|
+
* your datastore column — never try to reconstruct the full number.
|
|
3199
|
+
*
|
|
3200
|
+
* `purpose` is a short audit label ("attest", "age_check"), capped at 120 chars
|
|
3201
|
+
* server-side. Orders expire five minutes after `start()`.
|
|
3202
|
+
*/
|
|
3203
|
+
export function useIdentification(options) {
|
|
3204
|
+
const ctx = useWidgetContextOrThrow("useIdentification");
|
|
3205
|
+
if (
|
|
3206
|
+
!ctx.identification ||
|
|
3207
|
+
typeof ctx.identification.start !== "function"
|
|
3208
|
+
) {
|
|
3209
|
+
throw new Error(
|
|
3210
|
+
"useIdentification: host did not inject an identification client (ctx.identification)",
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
3213
|
+
const apiRef = useRef(ctx.identification);
|
|
3214
|
+
apiRef.current = ctx.identification;
|
|
3215
|
+
|
|
3216
|
+
const opts = options || {};
|
|
3217
|
+
const provider = opts.provider || "bankid";
|
|
3218
|
+
const purpose = opts.purpose;
|
|
3219
|
+
const pollIntervalMs =
|
|
3220
|
+
typeof opts.pollIntervalMs === "number" ? opts.pollIntervalMs : 1000;
|
|
3221
|
+
|
|
3222
|
+
const [available, setAvailable] = useState(false);
|
|
3223
|
+
const [availabilityLoading, setAvailabilityLoading] = useState(true);
|
|
3224
|
+
const [status, setStatus] = useState(null); // null | pending | complete | failed | cancelled
|
|
3225
|
+
const [qr, setQr] = useState(null);
|
|
3226
|
+
const [autoStartToken, setAutoStartToken] = useState(null);
|
|
3227
|
+
const [message, setMessage] = useState(null);
|
|
3228
|
+
const [identity, setIdentity] = useState(null);
|
|
3229
|
+
const [identificationId, setIdentificationId] = useState(null);
|
|
3230
|
+
const [loading, setLoading] = useState(false);
|
|
3231
|
+
const [error, setError] = useState(null);
|
|
3232
|
+
|
|
3233
|
+
const idRef = useRef(null);
|
|
3234
|
+
// Guards every async setState: a flow that resolves after unmount (or after a
|
|
3235
|
+
// reset) must not write into a dead render.
|
|
3236
|
+
const aliveRef = useRef(true);
|
|
3237
|
+
useEffect(() => {
|
|
3238
|
+
aliveRef.current = true;
|
|
3239
|
+
return () => {
|
|
3240
|
+
aliveRef.current = false;
|
|
3241
|
+
};
|
|
3242
|
+
}, []);
|
|
3243
|
+
|
|
3244
|
+
useEffect(() => {
|
|
3245
|
+
let cancelled = false;
|
|
3246
|
+
(async () => {
|
|
3247
|
+
try {
|
|
3248
|
+
const res = await apiRef.current.available();
|
|
3249
|
+
if (cancelled) return;
|
|
3250
|
+
setAvailable(Boolean(res && res.available));
|
|
3251
|
+
} catch {
|
|
3252
|
+
if (cancelled) return;
|
|
3253
|
+
// Availability is a pre-flight nicety, not the flow — a failure here
|
|
3254
|
+
// means "don't offer it", not an error the widget must render.
|
|
3255
|
+
setAvailable(false);
|
|
3256
|
+
} finally {
|
|
3257
|
+
if (!cancelled) setAvailabilityLoading(false);
|
|
3258
|
+
}
|
|
3259
|
+
})();
|
|
3260
|
+
return () => {
|
|
3261
|
+
cancelled = true;
|
|
3262
|
+
};
|
|
3263
|
+
}, []);
|
|
3264
|
+
|
|
3265
|
+
const _applyState = useCallback((res) => {
|
|
3266
|
+
if (!res) return null;
|
|
3267
|
+
if (res.status != null) setStatus(res.status);
|
|
3268
|
+
if (res.qr !== undefined) setQr(res.qr || null);
|
|
3269
|
+
if (res.message !== undefined) setMessage(res.message || null);
|
|
3270
|
+
if (res.identity !== undefined) setIdentity(res.identity || null);
|
|
3271
|
+
return res;
|
|
3272
|
+
}, []);
|
|
3273
|
+
|
|
3274
|
+
const refresh = useCallback(async () => {
|
|
3275
|
+
const id = idRef.current;
|
|
3276
|
+
if (!id) return null;
|
|
3277
|
+
try {
|
|
3278
|
+
const res = await apiRef.current.get(id);
|
|
3279
|
+
if (!aliveRef.current || idRef.current !== id) return res;
|
|
3280
|
+
return _applyState(res);
|
|
3281
|
+
} catch (err) {
|
|
3282
|
+
if (!aliveRef.current || idRef.current !== id) return null;
|
|
3283
|
+
const e = toIdentificationError(err);
|
|
3284
|
+
// A transient provider blip is already retried inside the client, so an
|
|
3285
|
+
// error surfacing here ends the flow rather than spinning forever.
|
|
3286
|
+
setError(e);
|
|
3287
|
+
setStatus("failed");
|
|
3288
|
+
setMessage(e.message);
|
|
3289
|
+
return null;
|
|
3290
|
+
}
|
|
3291
|
+
}, [_applyState]);
|
|
3292
|
+
|
|
3293
|
+
// Poll while the order is pending. Cleared on terminal state and on unmount.
|
|
3294
|
+
useEffect(() => {
|
|
3295
|
+
if (status !== "pending" || pollIntervalMs <= 0) return undefined;
|
|
3296
|
+
const timer = setInterval(() => {
|
|
3297
|
+
refresh();
|
|
3298
|
+
}, pollIntervalMs);
|
|
3299
|
+
return () => clearInterval(timer);
|
|
3300
|
+
}, [status, pollIntervalMs, refresh]);
|
|
3301
|
+
|
|
3302
|
+
const start = useCallback(async () => {
|
|
3303
|
+
setLoading(true);
|
|
3304
|
+
setError(null);
|
|
3305
|
+
setIdentity(null);
|
|
3306
|
+
try {
|
|
3307
|
+
const res = await apiRef.current.start({ provider, purpose });
|
|
3308
|
+
const newId = res && res.identification_id ? res.identification_id : null;
|
|
3309
|
+
idRef.current = newId;
|
|
3310
|
+
if (!aliveRef.current) return res;
|
|
3311
|
+
setIdentificationId(newId);
|
|
3312
|
+
setStatus(res && res.status ? res.status : "pending");
|
|
3313
|
+
setQr(res && res.qr ? res.qr : null);
|
|
3314
|
+
setAutoStartToken(res && res.auto_start_token ? res.auto_start_token : null);
|
|
3315
|
+
setMessage(null);
|
|
3316
|
+
setLoading(false);
|
|
3317
|
+
return res;
|
|
3318
|
+
} catch (err) {
|
|
3319
|
+
const e = toIdentificationError(err);
|
|
3320
|
+
if (aliveRef.current) {
|
|
3321
|
+
setError(e);
|
|
3322
|
+
setStatus("failed");
|
|
3323
|
+
setMessage(e.message);
|
|
3324
|
+
setLoading(false);
|
|
3325
|
+
}
|
|
3326
|
+
throw e;
|
|
3327
|
+
}
|
|
3328
|
+
}, [provider, purpose]);
|
|
3329
|
+
|
|
3330
|
+
const cancel = useCallback(async () => {
|
|
3331
|
+
const id = idRef.current;
|
|
3332
|
+
idRef.current = null;
|
|
3333
|
+
if (aliveRef.current) {
|
|
3334
|
+
setStatus(null);
|
|
3335
|
+
setQr(null);
|
|
3336
|
+
setAutoStartToken(null);
|
|
3337
|
+
setMessage(null);
|
|
3338
|
+
}
|
|
3339
|
+
if (id) {
|
|
3340
|
+
try {
|
|
3341
|
+
await apiRef.current.cancel(id);
|
|
3342
|
+
} catch {
|
|
3343
|
+
// Best-effort — the order may have already expired or completed.
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
}, []);
|
|
3347
|
+
|
|
3348
|
+
const reset = useCallback(() => {
|
|
3349
|
+
idRef.current = null;
|
|
3350
|
+
setStatus(null);
|
|
3351
|
+
setQr(null);
|
|
3352
|
+
setAutoStartToken(null);
|
|
3353
|
+
setMessage(null);
|
|
3354
|
+
setIdentity(null);
|
|
3355
|
+
setIdentificationId(null);
|
|
3356
|
+
setError(null);
|
|
3357
|
+
setLoading(false);
|
|
3358
|
+
}, []);
|
|
3359
|
+
|
|
3360
|
+
return {
|
|
3361
|
+
available,
|
|
3362
|
+
availabilityLoading,
|
|
3363
|
+
status,
|
|
3364
|
+
qr,
|
|
3365
|
+
autoStartToken,
|
|
3366
|
+
message,
|
|
3367
|
+
identity,
|
|
3368
|
+
identificationId,
|
|
3369
|
+
loading,
|
|
3370
|
+
error,
|
|
3371
|
+
start,
|
|
3372
|
+
refresh,
|
|
3373
|
+
cancel,
|
|
3374
|
+
reset,
|
|
3375
|
+
};
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3028
3378
|
/* ============================================================================
|
|
3029
3379
|
* PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
|
|
3030
3380
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -445,6 +445,59 @@ export interface PaymentsClient {
|
|
|
445
445
|
getPayment(paymentId: string): Promise<PaymentResult>;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/**
|
|
449
|
+
* A verified person a completed identification resolved to (REQ-IDENT).
|
|
450
|
+
*
|
|
451
|
+
* There is deliberately NO raw personal number: the full value stays
|
|
452
|
+
* server-side. `personal_number_masked` is e.g. `"19900101-****"`, and
|
|
453
|
+
* `subject_hash` is stable for the same person so a returning visitor can be
|
|
454
|
+
* recognised without it.
|
|
455
|
+
*/
|
|
456
|
+
export interface Identity {
|
|
457
|
+
provider: string;
|
|
458
|
+
name: string | null;
|
|
459
|
+
given_name: string | null;
|
|
460
|
+
surname: string | null;
|
|
461
|
+
personal_number_masked: string | null;
|
|
462
|
+
subject_hash: string | null;
|
|
463
|
+
identified_at: string | null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Structural shape of the injected `@colixsystems/identification-client`
|
|
468
|
+
* (`ctx.identification`, REQ-IDENT). Backs `useIdentification`. Identifies a
|
|
469
|
+
* visitor who is NOT signed in; creates no account and no session.
|
|
470
|
+
*/
|
|
471
|
+
export interface IdentificationClient {
|
|
472
|
+
available(): Promise<{
|
|
473
|
+
available: boolean;
|
|
474
|
+
providers: Array<{ provider: string; available: boolean }>;
|
|
475
|
+
}>;
|
|
476
|
+
start(body?: { provider?: string; purpose?: string }): Promise<{
|
|
477
|
+
identification_id: string;
|
|
478
|
+
provider: string;
|
|
479
|
+
purpose: string | null;
|
|
480
|
+
status: "pending";
|
|
481
|
+
auto_start_token: string | null;
|
|
482
|
+
qr: string | null;
|
|
483
|
+
expires_at: string;
|
|
484
|
+
}>;
|
|
485
|
+
get(identificationId: string): Promise<{
|
|
486
|
+
identification_id: string;
|
|
487
|
+
provider: string;
|
|
488
|
+
purpose: string | null;
|
|
489
|
+
status: "pending" | "complete" | "failed" | "cancelled";
|
|
490
|
+
hint_code?: string | null;
|
|
491
|
+
message?: string | null;
|
|
492
|
+
qr?: string | null;
|
|
493
|
+
identity?: Identity;
|
|
494
|
+
}>;
|
|
495
|
+
cancel(identificationId: string): Promise<{
|
|
496
|
+
identification_id: string;
|
|
497
|
+
status: "pending" | "complete" | "failed" | "cancelled";
|
|
498
|
+
}>;
|
|
499
|
+
}
|
|
500
|
+
|
|
448
501
|
/**
|
|
449
502
|
* Structural shape of the injected `@colixsystems/notifications-client`
|
|
450
503
|
* (`ctx.notifications`, sc-890). Backs `useSendNotification`. `send` POSTs the
|
|
@@ -497,6 +550,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
497
550
|
payments: PaymentsClient;
|
|
498
551
|
/** Injected @colixsystems/notifications-client; backs useSendNotification. */
|
|
499
552
|
notifications: NotificationsClient;
|
|
553
|
+
/** Injected @colixsystems/identification-client; backs useIdentification. */
|
|
554
|
+
identification: IdentificationClient;
|
|
500
555
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
501
556
|
renderer: { renderNode(node: unknown): unknown };
|
|
502
557
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
@@ -1001,6 +1056,20 @@ export function useNavigation(): {
|
|
|
1001
1056
|
*/
|
|
1002
1057
|
export function useRouteParams(): Record<string, unknown>;
|
|
1003
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* REQ-NAV-05 — the PAGE's resolved context. When the page declares typed
|
|
1061
|
+
* parameters, the host resolves them ONCE before any widget renders: `params`
|
|
1062
|
+
* holds the values coerced to their declared types (a `number` parameter is a
|
|
1063
|
+
* number here, not the string `useRouteParams()` returns), and `records` maps
|
|
1064
|
+
* each `record` parameter to the row the host already fetched — read it instead
|
|
1065
|
+
* of fetching the same record again. Both bags are empty on a page that
|
|
1066
|
+
* declares nothing.
|
|
1067
|
+
*/
|
|
1068
|
+
export function usePageContext(): {
|
|
1069
|
+
params: Record<string, unknown>;
|
|
1070
|
+
records: Record<string, Record<string, unknown> | null>;
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1004
1073
|
/**
|
|
1005
1074
|
* Static API for external URLs. `openURL(url)` opens a URL with the OS
|
|
1006
1075
|
* handler (web: react-native-web maps to `window.open` / `location.href`;
|
|
@@ -1027,6 +1096,20 @@ export function useRefresh(
|
|
|
1027
1096
|
handler: () => void | Promise<unknown>,
|
|
1028
1097
|
): void;
|
|
1029
1098
|
|
|
1099
|
+
/**
|
|
1100
|
+
* sc-4416 — declare that this widget currently has NO content to show, so the
|
|
1101
|
+
* host removes its layout slot rather than reserving space for it.
|
|
1102
|
+
*
|
|
1103
|
+
* Returning `null` alone is not enough: the host wraps every widget node in an
|
|
1104
|
+
* entrance wrapper, so a widget rendering nothing still leaves an empty box
|
|
1105
|
+
* that its parent stack puts `gap` around. Use it for a CONDITIONALLY ABSENT
|
|
1106
|
+
* section (a per-record child collection with no rows for this record), never
|
|
1107
|
+
* to suppress a genuine empty state. The widget stays mounted while collapsed,
|
|
1108
|
+
* so passing `false` later brings the section back. Authoring surfaces never
|
|
1109
|
+
* collapse. Safe on a host that does not implement it — a no-op there.
|
|
1110
|
+
*/
|
|
1111
|
+
export function useSectionEmpty(isEmpty: boolean): void;
|
|
1112
|
+
|
|
1030
1113
|
/** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
|
|
1031
1114
|
export interface GeolocationOptions {
|
|
1032
1115
|
enableHighAccuracy?: boolean;
|
|
@@ -1299,6 +1382,76 @@ export interface BankIdLinkApi {
|
|
|
1299
1382
|
*/
|
|
1300
1383
|
export function useBankIdLink(): BankIdLinkApi;
|
|
1301
1384
|
|
|
1385
|
+
// ----------------------------------------------------- useIdentification
|
|
1386
|
+
//
|
|
1387
|
+
// REQ-IDENT — identify a visitor who is NOT signed in and keep the result.
|
|
1388
|
+
// Reads the injected identification-client at `ctx.identification`. Anonymous by
|
|
1389
|
+
// design — no requestedScopes entry and no session needed.
|
|
1390
|
+
|
|
1391
|
+
/** Stable machine codes on an IdentificationError. */
|
|
1392
|
+
export type IdentificationErrorCode =
|
|
1393
|
+
| "NOT_CONFIGURED"
|
|
1394
|
+
| "UNKNOWN_PROVIDER"
|
|
1395
|
+
| "NOT_FOUND"
|
|
1396
|
+
| "RATE_LIMITED"
|
|
1397
|
+
| "UNAVAILABLE"
|
|
1398
|
+
| "INTERNAL";
|
|
1399
|
+
|
|
1400
|
+
export class IdentificationError extends Error {
|
|
1401
|
+
code: IdentificationErrorCode;
|
|
1402
|
+
cause?: unknown;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export interface UseIdentificationOptions {
|
|
1406
|
+
/** Provider to identify with. Defaults to "bankid". */
|
|
1407
|
+
provider?: string;
|
|
1408
|
+
/** Short audit label ("attest", "age_check"). Capped at 120 chars server-side. */
|
|
1409
|
+
purpose?: string;
|
|
1410
|
+
/** Poll cadence while pending, in ms. Defaults to 1000; 0 disables auto-polling. */
|
|
1411
|
+
pollIntervalMs?: number;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
export interface IdentificationApi {
|
|
1415
|
+
/** Whether identification can be used here (provider configured + enabled). Gate the UI on this. */
|
|
1416
|
+
available: boolean;
|
|
1417
|
+
availabilityLoading: boolean;
|
|
1418
|
+
/** The active order's state, or null when no order is in flight. */
|
|
1419
|
+
status: "pending" | "complete" | "failed" | "cancelled" | null;
|
|
1420
|
+
/** PNG data-URL of the animated QR while pending (render with the Image primitive). */
|
|
1421
|
+
qr: string | null;
|
|
1422
|
+
/** Same-device deeplink token: `bankid:///?autostarttoken=<token>&redirect=null`. */
|
|
1423
|
+
autoStartToken: string | null;
|
|
1424
|
+
/** Display-ready instruction for the current step. */
|
|
1425
|
+
message: string | null;
|
|
1426
|
+
/** The verified person, set once status is "complete". */
|
|
1427
|
+
identity: Identity | null;
|
|
1428
|
+
/** The order id — store it alongside an attestation to trace back to the proof. */
|
|
1429
|
+
identificationId: string | null;
|
|
1430
|
+
loading: boolean;
|
|
1431
|
+
error: IdentificationError | null;
|
|
1432
|
+
/** Open an order -> sets status "pending" + qr, then polls to completion. */
|
|
1433
|
+
start(): Promise<unknown>;
|
|
1434
|
+
/** Poll the open order once by hand (for pollIntervalMs: 0). */
|
|
1435
|
+
refresh(): Promise<unknown>;
|
|
1436
|
+
/** Abort the in-flight order. */
|
|
1437
|
+
cancel(): Promise<void>;
|
|
1438
|
+
/** Clear the flow so the visitor can start over. */
|
|
1439
|
+
reset(): void;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Identify a visitor who is NOT signed in, so the app can keep the result (an
|
|
1444
|
+
* attestation on a record, a consent line, a pre-submit identity check). Polls
|
|
1445
|
+
* the order for you while pending and clears the timer on unmount.
|
|
1446
|
+
*
|
|
1447
|
+
* Creates no account and no session — to sign someone IN use the app's login, to
|
|
1448
|
+
* attach BankID to an existing account use `useBankIdLink()`, and to e-sign a
|
|
1449
|
+
* file use `useFileSignature()`. Hide the affordance when `available` is false.
|
|
1450
|
+
*/
|
|
1451
|
+
export function useIdentification(
|
|
1452
|
+
options?: UseIdentificationOptions,
|
|
1453
|
+
): IdentificationApi;
|
|
1454
|
+
|
|
1302
1455
|
// ----------------------------------------------------- useRecordPermissions
|
|
1303
1456
|
//
|
|
1304
1457
|
// REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — per-record VirtualPermission
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,8 @@ export {
|
|
|
31
31
|
useUsers,
|
|
32
32
|
useGroups,
|
|
33
33
|
useBankIdLink,
|
|
34
|
+
useIdentification,
|
|
35
|
+
IdentificationError,
|
|
34
36
|
useRecordPermissions,
|
|
35
37
|
useDatastoreSubscription,
|
|
36
38
|
useWidgetEvent,
|
|
@@ -45,8 +47,10 @@ export {
|
|
|
45
47
|
useFill,
|
|
46
48
|
useNavigation,
|
|
47
49
|
useRouteParams,
|
|
50
|
+
usePageContext,
|
|
48
51
|
useChildRenderer,
|
|
49
52
|
useRefresh,
|
|
53
|
+
useSectionEmpty,
|
|
50
54
|
useGeolocation,
|
|
51
55
|
GeolocationError,
|
|
52
56
|
WidgetTree,
|
package/dist/index.native.js
CHANGED
|
@@ -31,6 +31,8 @@ export {
|
|
|
31
31
|
useUsers,
|
|
32
32
|
useGroups,
|
|
33
33
|
useBankIdLink,
|
|
34
|
+
useIdentification,
|
|
35
|
+
IdentificationError,
|
|
34
36
|
useRecordPermissions,
|
|
35
37
|
useDatastoreSubscription,
|
|
36
38
|
useWidgetEvent,
|
|
@@ -45,8 +47,10 @@ export {
|
|
|
45
47
|
useFill,
|
|
46
48
|
useNavigation,
|
|
47
49
|
useRouteParams,
|
|
50
|
+
usePageContext,
|
|
48
51
|
useChildRenderer,
|
|
49
52
|
useRefresh,
|
|
53
|
+
useSectionEmpty,
|
|
50
54
|
useGeolocation,
|
|
51
55
|
GeolocationError,
|
|
52
56
|
WidgetTree,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"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-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
51
|
+
"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-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|