@colixsystems/widget-sdk 0.17.0 → 0.19.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 +135 -17
- package/dist/contract.cjs +156 -66
- package/dist/contract.js +161 -66
- package/dist/hooks.js +776 -387
- package/dist/index.d.ts +292 -37
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +56 -0
- package/dist/linter.js +57 -0
- package/dist/manifest.cjs +75 -2
- package/dist/manifest.js +75 -2
- package/package.json +2 -2
package/dist/hooks.js
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// exported app) own the runtime semantics — this file is the SDK surface
|
|
9
9
|
// widgets call.
|
|
10
10
|
//
|
|
11
|
+
// The hooks below are GROUPED by the domain client they read, so the
|
|
12
|
+
// four-client mapping is obvious at a glance:
|
|
13
|
+
// - CORE — WidgetContext + non-data hooks (no domain client)
|
|
14
|
+
// - DATASTORE — ctx.datastore (@colixsystems/datastore-client)
|
|
15
|
+
// - FILES — ctx.files (@colixsystems/files-client)
|
|
16
|
+
// - DIRECTORY — ctx.directory (@colixsystems/directory-client)
|
|
17
|
+
// - PAYMENTS — ctx.payments (@colixsystems/payments-client)
|
|
18
|
+
//
|
|
11
19
|
// This file avoids JSX so it can ship as a plain .js without a transform.
|
|
12
20
|
|
|
13
21
|
import React, {
|
|
@@ -22,6 +30,172 @@ import React, {
|
|
|
22
30
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
23
31
|
const HostWidgetContext = createContext(null);
|
|
24
32
|
|
|
33
|
+
/* ============================================================================
|
|
34
|
+
* CORE — WidgetContext + non-data hooks (no domain client)
|
|
35
|
+
*
|
|
36
|
+
* The provider, the context accessor, and the hooks that read host-provided
|
|
37
|
+
* state directly off the WidgetContext (events, theme, user, renderer,
|
|
38
|
+
* navigation, i18n) rather than through one of the four domain clients.
|
|
39
|
+
* ==========================================================================*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wraps children with the host-provided WidgetContext.
|
|
43
|
+
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
44
|
+
*/
|
|
45
|
+
export function WidgetContextProvider({ value, children }) {
|
|
46
|
+
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Exported for ./toast.js + future hook modules outside hooks.js. Internal
|
|
50
|
+
// utility — widget code should not import this directly.
|
|
51
|
+
export function useWidgetContextOrThrow(hookName) {
|
|
52
|
+
const ctx = useContext(HostWidgetContext);
|
|
53
|
+
if (ctx == null) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
56
|
+
`The host (Studio, Player, or exported app) is responsible for mounting it.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return ctx;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Emit a named widget event through ctx.events.emit. Page-level event
|
|
64
|
+
* bindings (subscribed by the host) decide what happens next.
|
|
65
|
+
*/
|
|
66
|
+
export function useWidgetEvent(name) {
|
|
67
|
+
const ctx = useWidgetContextOrThrow("useWidgetEvent");
|
|
68
|
+
return useCallback((payload) => ctx.events.emit(name, payload), [ctx, name]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns the host-provided theme tokens. The host guarantees every field
|
|
73
|
+
* documented in CONTRACT.themeTokens is present (defaults merged with
|
|
74
|
+
* tenant overrides), so widgets read `theme.colors.primary` without
|
|
75
|
+
* optional chaining.
|
|
76
|
+
*/
|
|
77
|
+
export function useTheme() {
|
|
78
|
+
const ctx = useWidgetContextOrThrow("useTheme");
|
|
79
|
+
return ctx.workspace.theme;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns the active end-user identity VERBATIM from the host, e.g.
|
|
84
|
+
* `{ id, email, display_name, roles, group_ids }` (snake_case fields).
|
|
85
|
+
*
|
|
86
|
+
* `id` is `null` for anonymous visitors (and on the Studio canvas preview,
|
|
87
|
+
* which renders widgets as if signed-out so the public branch shows). The
|
|
88
|
+
* host (`buildHostWidgetContext` + the native `WidgetHost`) populates the
|
|
89
|
+
* fields; widgets read them directly. The current principal is host-provided
|
|
90
|
+
* — this hook does NOT read a data-client.
|
|
91
|
+
*
|
|
92
|
+
* Use this to render the signed-in user's name in a header, branch on
|
|
93
|
+
* roles, or stamp a created-by field.
|
|
94
|
+
*/
|
|
95
|
+
export function useUser() {
|
|
96
|
+
const ctx = useWidgetContextOrThrow("useUser");
|
|
97
|
+
return ctx.user;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns the host's child-node renderer:
|
|
102
|
+
* { renderNode(node) }.
|
|
103
|
+
*
|
|
104
|
+
* Widgets like Tabs / Card / a custom container call `renderNode(child)`
|
|
105
|
+
* to render an author-authored page-tree node nested inside themselves.
|
|
106
|
+
* The renderer closes over the surrounding render context (breakpoint,
|
|
107
|
+
* page ctx, parent) that the host already knows, so the widget doesn't
|
|
108
|
+
* need to plumb any of that through.
|
|
109
|
+
*
|
|
110
|
+
* Prefer the `WidgetTree` component for the common case
|
|
111
|
+
* (`<WidgetTree node={child} />`); reach for the hook when you need to
|
|
112
|
+
* branch on the node's shape before rendering.
|
|
113
|
+
*/
|
|
114
|
+
export function useChildRenderer() {
|
|
115
|
+
const ctx = useWidgetContextOrThrow("useChildRenderer");
|
|
116
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
"useChildRenderer: host did not inject a child-node renderer",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return ctx.renderer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Renders an author-authored page-tree node through the host's child
|
|
126
|
+
* renderer. The widget hands off a node (or null) and gets back a React
|
|
127
|
+
* element rendered with the same dispatch the top-level page uses, so
|
|
128
|
+
* Tabs / Card / custom container widgets can host arbitrary child
|
|
129
|
+
* widgets without importing the host.
|
|
130
|
+
*/
|
|
131
|
+
export function WidgetTree({ node }) {
|
|
132
|
+
const ctx = useWidgetContextOrThrow("WidgetTree");
|
|
133
|
+
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (!node) return null;
|
|
137
|
+
return ctx.renderer.renderNode(node);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Returns the host-provided navigation surface:
|
|
142
|
+
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
143
|
+
*
|
|
144
|
+
* `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
|
|
145
|
+
* pops the stack. `push` / `replace` / `back` are aliases that map to
|
|
146
|
+
* the platform's native navigator (react-router on web, react-navigation
|
|
147
|
+
* on native). Missing methods degrade to no-ops on the Studio canvas
|
|
148
|
+
* preview, where there is no live router.
|
|
149
|
+
*
|
|
150
|
+
* For EXTERNAL URLs use the `Linking` primitive — `Linking.openURL(url)`
|
|
151
|
+
* works on both platforms.
|
|
152
|
+
*/
|
|
153
|
+
export function useNavigation() {
|
|
154
|
+
const ctx = useWidgetContextOrThrow("useNavigation");
|
|
155
|
+
return ctx.navigation;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Returns { t, locale }. `t(key, fallback)` resolves `{{t:key}}` against
|
|
160
|
+
* the host's translation table and falls back to `fallback ?? key` when
|
|
161
|
+
* the key is missing. The host's i18n.t may or may not honour the two-arg
|
|
162
|
+
* form; we degrade gracefully either way.
|
|
163
|
+
*/
|
|
164
|
+
export function useI18n() {
|
|
165
|
+
const ctx = useWidgetContextOrThrow("useI18n");
|
|
166
|
+
const i18n = ctx.i18n || {};
|
|
167
|
+
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
168
|
+
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
169
|
+
const t = useCallback(
|
|
170
|
+
(key, fallback) => {
|
|
171
|
+
if (typeof key !== "string" || !key) {
|
|
172
|
+
return typeof fallback === "string" ? fallback : "";
|
|
173
|
+
}
|
|
174
|
+
if (hostT) {
|
|
175
|
+
// Try the two-arg form first; if the host's `t` ignores `fallback`
|
|
176
|
+
// and returns the bare key on a miss, swap in the fallback.
|
|
177
|
+
const out = hostT(key, fallback);
|
|
178
|
+
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
// Host returned the key (unresolved) or nothing usable.
|
|
182
|
+
return typeof fallback === "string" ? fallback : key;
|
|
183
|
+
}
|
|
184
|
+
return typeof fallback === "string" ? fallback : key;
|
|
185
|
+
},
|
|
186
|
+
[hostT],
|
|
187
|
+
);
|
|
188
|
+
return { t, locale };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ============================================================================
|
|
192
|
+
* DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
|
|
193
|
+
*
|
|
194
|
+
* Records, schema, aggregate, and record-level permissions. Covers:
|
|
195
|
+
* useDatastoreQuery, useDatastoreRecord, useDatastoreSchema,
|
|
196
|
+
* useDatastoreMutation, useRecordPermissions.
|
|
197
|
+
* ==========================================================================*/
|
|
198
|
+
|
|
25
199
|
/**
|
|
26
200
|
* Structured error thrown by `useDatastoreMutation` callbacks (and surfaced
|
|
27
201
|
* by `useDatastoreQuery` in its `error` slot). Carries a stable `code` so
|
|
@@ -53,29 +227,6 @@ export class DatastoreError extends Error {
|
|
|
53
227
|
}
|
|
54
228
|
}
|
|
55
229
|
|
|
56
|
-
/**
|
|
57
|
-
* Wraps children with the host-provided WidgetContext.
|
|
58
|
-
* The host (Studio/Player/native shell) builds the value and renders this provider.
|
|
59
|
-
*/
|
|
60
|
-
export function WidgetContextProvider({ value, children }) {
|
|
61
|
-
return React.createElement(HostWidgetContext.Provider, { value }, children);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Exported for ./toast.js + future hook modules outside hooks.js. Internal
|
|
65
|
-
// utility — widget code should not import this directly.
|
|
66
|
-
export function useWidgetContextOrThrow(hookName) {
|
|
67
|
-
const ctx = useContext(HostWidgetContext);
|
|
68
|
-
if (ctx == null) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`${hookName} must be used inside a WidgetContextProvider. ` +
|
|
71
|
-
`The host (Studio, Player, or exported app) is responsible for mounting it.`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
return ctx;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --------------------------------------------------------------- helpers
|
|
78
|
-
|
|
79
230
|
/**
|
|
80
231
|
* Coerce an arbitrary thrown value (axios error, plain Error, string) into
|
|
81
232
|
* a DatastoreError with a stable `.code`. Reads `error.response.status` if
|
|
@@ -127,15 +278,16 @@ function toDatastoreError(err) {
|
|
|
127
278
|
});
|
|
128
279
|
}
|
|
129
280
|
|
|
130
|
-
// --------------------------------------------------------------- hooks
|
|
131
|
-
|
|
132
281
|
/**
|
|
133
282
|
* Stateful datastore query hook. Returns { data, loading, error, refetch }.
|
|
134
283
|
*
|
|
135
|
-
* The host
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
284
|
+
* The host injects a `@colixsystems/datastore-client` instance at
|
|
285
|
+
* `ctx.datastore`. We call `ctx.datastore.records(table).list(query)`, which
|
|
286
|
+
* resolves to the `{ data, meta }` list envelope VERBATIM (the client no
|
|
287
|
+
* longer unwraps to a bare array), so we read `res.data` (defaulting to `[]`)
|
|
288
|
+
* into component state. Author column values inside each row keep whatever
|
|
289
|
+
* the author named them. We re-fetch when [table, JSON.stringify(query)]
|
|
290
|
+
* changes. `refetch` re-runs the same call on demand.
|
|
139
291
|
*
|
|
140
292
|
* When `table` is falsy (e.g. the user hasn't bound a `tableRef` property
|
|
141
293
|
* yet), the hook resolves to { data: [], loading: false, error: null,
|
|
@@ -189,10 +341,13 @@ export function useDatastoreQuery(table, query) {
|
|
|
189
341
|
setError(null);
|
|
190
342
|
try {
|
|
191
343
|
const ns = recordsRef.current(t);
|
|
192
|
-
const
|
|
344
|
+
const res = await ns.list(queryRef.current);
|
|
345
|
+
// The datastore-client returns the { data, meta } list envelope
|
|
346
|
+
// verbatim — unwrap to the rows array (default []).
|
|
347
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
193
348
|
// Discard the result if a newer fetch has started since we kicked off.
|
|
194
349
|
if (runRef.current !== myRun) return;
|
|
195
|
-
setData(
|
|
350
|
+
setData(rows);
|
|
196
351
|
setLoading(false);
|
|
197
352
|
} catch (err) {
|
|
198
353
|
if (runRef.current !== myRun) return;
|
|
@@ -225,10 +380,12 @@ export function useDatastoreQuery(table, query) {
|
|
|
225
380
|
* Stateful single-record query hook. Returns { data, loading, error, refetch }
|
|
226
381
|
* where `data` is one row (`null` until loaded, never an array).
|
|
227
382
|
*
|
|
228
|
-
* The host
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
383
|
+
* The host injects a `@colixsystems/datastore-client` instance at
|
|
384
|
+
* `ctx.datastore`. We call `ctx.datastore.records(table).get(id)`, which
|
|
385
|
+
* resolves to a single record object (no `{ data, meta }` envelope — `get`
|
|
386
|
+
* returns the row directly). Author column values keep whatever the author
|
|
387
|
+
* named them. We hold the result in component state and re-fetch when
|
|
388
|
+
* [table, id] changes. `refetch` re-runs the call on demand.
|
|
232
389
|
*
|
|
233
390
|
* When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
|
|
234
391
|
* record id yet), the hook resolves to { data: null, loading: false,
|
|
@@ -372,82 +529,27 @@ export function useDatastoreSchema(tableId) {
|
|
|
372
529
|
return { schema, loading, error, refetch };
|
|
373
530
|
}
|
|
374
531
|
|
|
375
|
-
/**
|
|
376
|
-
* Stateful file-asset resolver hook. Returns { url, file, loading, error,
|
|
377
|
-
* refetch }.
|
|
378
|
-
*
|
|
379
|
-
* The host's file client exposes `files.get(fileId)` which resolves to
|
|
380
|
-
* `{ url, ...meta }` — the absolute URL is composed against the host's
|
|
381
|
-
* API base so the widget can drop it straight into an `<Image source>`
|
|
382
|
-
* without knowing where the API lives. A missing/soft-deleted asset
|
|
383
|
-
* surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
|
|
384
|
-
*
|
|
385
|
-
* When `fileId` is falsy the hook collapses to { url: null, file: null,
|
|
386
|
-
* loading: false, error: null, refetch } without a network round-trip,
|
|
387
|
-
* so a widget rendering before the author has bound an asset stays
|
|
388
|
-
* loop-free.
|
|
389
|
-
*/
|
|
390
|
-
export function useFile(fileId) {
|
|
391
|
-
const ctx = useWidgetContextOrThrow("useFile");
|
|
392
|
-
if (!ctx.files || typeof ctx.files.get !== "function") {
|
|
393
|
-
throw new Error("useFile: host did not inject a files client");
|
|
394
|
-
}
|
|
395
|
-
const ready = Boolean(fileId);
|
|
396
|
-
const [file, setFile] = useState(null);
|
|
397
|
-
const [loading, setLoading] = useState(ready);
|
|
398
|
-
const [error, setError] = useState(null);
|
|
399
|
-
|
|
400
|
-
const fileIdRef = useRef(fileId);
|
|
401
|
-
const getRef = useRef(ctx.files.get);
|
|
402
|
-
fileIdRef.current = fileId;
|
|
403
|
-
getRef.current = ctx.files.get;
|
|
404
|
-
|
|
405
|
-
const runRef = useRef(0);
|
|
406
|
-
|
|
407
|
-
const doFetch = useCallback(async () => {
|
|
408
|
-
const myRun = ++runRef.current;
|
|
409
|
-
const id = fileIdRef.current;
|
|
410
|
-
if (!id) {
|
|
411
|
-
setLoading(false);
|
|
412
|
-
setError(null);
|
|
413
|
-
setFile(null);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
setLoading(true);
|
|
417
|
-
setError(null);
|
|
418
|
-
try {
|
|
419
|
-
const f = await getRef.current(id);
|
|
420
|
-
if (runRef.current !== myRun) return;
|
|
421
|
-
setFile(f || null);
|
|
422
|
-
setLoading(false);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
if (runRef.current !== myRun) return;
|
|
425
|
-
setError(toDatastoreError(err));
|
|
426
|
-
setLoading(false);
|
|
427
|
-
}
|
|
428
|
-
}, []);
|
|
429
|
-
|
|
430
|
-
useEffect(() => {
|
|
431
|
-
doFetch();
|
|
432
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
433
|
-
}, [fileId]);
|
|
434
|
-
|
|
435
|
-
const refetch = useCallback(async () => {
|
|
436
|
-
await doFetch();
|
|
437
|
-
}, [doFetch]);
|
|
438
|
-
|
|
439
|
-
const url =
|
|
440
|
-
file && typeof file.url === "string" && file.url.length > 0
|
|
441
|
-
? file.url
|
|
442
|
-
: null;
|
|
443
|
-
return { url, file, loading, error, refetch };
|
|
444
|
-
}
|
|
445
|
-
|
|
446
532
|
/**
|
|
447
533
|
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
448
|
-
* returning a Promise.
|
|
449
|
-
*
|
|
450
|
-
* `
|
|
534
|
+
* returning a Promise. Routes through the injected
|
|
535
|
+
* `@colixsystems/datastore-client` at `ctx.datastore`:
|
|
536
|
+
* `ctx.datastore.records(table).{create,update,delete}`. `update` is a
|
|
537
|
+
* partial PATCH (the client issues PATCH), so callers pass only the changed
|
|
538
|
+
* fields. Values pass through verbatim (snake_case / author-named columns).
|
|
539
|
+
* Rejected promises throw a DatastoreError carrying a stable `.code`
|
|
540
|
+
* (`VALIDATION`, `CONSTRAINT_VIOLATION`, `FORBIDDEN`, `NOT_FOUND`,
|
|
541
|
+
* `INTERNAL`) plus an optional `fieldErrors` map.
|
|
542
|
+
*
|
|
543
|
+
* When `table` is falsy (the author hasn't bound a `tableRef` yet) the hook
|
|
544
|
+
* does NOT throw at render — it returns callbacks that reject with a
|
|
545
|
+
* `DatastoreError { code: "VALIDATION" }` only if actually invoked. This
|
|
546
|
+
* mirrors the guarded posture of `useDatastoreQuery` / `useDatastoreRecord`:
|
|
547
|
+
* a widget calls this hook unconditionally at the top of its body (Rules of
|
|
548
|
+
* Hooks) and renders its unbound empty state without crashing. The
|
|
549
|
+
* `records(table)` namespace is resolved lazily inside each callback — the
|
|
550
|
+
* client throws on an empty table, so building it eagerly at render would
|
|
551
|
+
* crash any widget mounted before its table is bound (the failure the Form
|
|
552
|
+
* Input widget hit).
|
|
451
553
|
*/
|
|
452
554
|
export function useDatastoreMutation(table) {
|
|
453
555
|
const ctx = useWidgetContextOrThrow("useDatastoreMutation");
|
|
@@ -456,314 +558,406 @@ export function useDatastoreMutation(table) {
|
|
|
456
558
|
"useDatastoreMutation: host did not inject a datastore client",
|
|
457
559
|
);
|
|
458
560
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
561
|
+
|
|
562
|
+
// Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
|
|
563
|
+
// identity on every host render, so we hold the live table + records
|
|
564
|
+
// factory in refs to keep the returned callbacks stable. We deliberately
|
|
565
|
+
// do NOT call `records(table)` here at render.
|
|
566
|
+
const tableRef = useRef(table);
|
|
567
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
568
|
+
tableRef.current = table;
|
|
569
|
+
recordsRef.current = ctx.datastore.records;
|
|
570
|
+
|
|
571
|
+
// Resolve records(table) lazily. Returns null when no table is bound so
|
|
572
|
+
// the callbacks reject with a clear VALIDATION error rather than letting
|
|
573
|
+
// the client's raw TypeError surface (or crashing at render).
|
|
574
|
+
const resolveNs = useCallback(() => {
|
|
575
|
+
const t = tableRef.current;
|
|
576
|
+
if (!t) return null;
|
|
577
|
+
return recordsRef.current(t);
|
|
578
|
+
}, []);
|
|
579
|
+
|
|
580
|
+
const create = useCallback(
|
|
581
|
+
async (values) => {
|
|
582
|
+
const ns = resolveNs();
|
|
583
|
+
if (!ns) {
|
|
584
|
+
throw new DatastoreError(
|
|
585
|
+
"VALIDATION",
|
|
586
|
+
"useDatastoreMutation: no table bound — set a source table before creating a record.",
|
|
587
|
+
);
|
|
588
|
+
}
|
|
462
589
|
try {
|
|
463
590
|
return await ns.create(values);
|
|
464
591
|
} catch (err) {
|
|
465
592
|
throw toDatastoreError(err);
|
|
466
593
|
}
|
|
467
594
|
},
|
|
468
|
-
|
|
595
|
+
[resolveNs],
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const update = useCallback(
|
|
599
|
+
async (id, values) => {
|
|
600
|
+
const ns = resolveNs();
|
|
601
|
+
if (!ns) {
|
|
602
|
+
throw new DatastoreError(
|
|
603
|
+
"VALIDATION",
|
|
604
|
+
"useDatastoreMutation: no table bound — set a source table before updating a record.",
|
|
605
|
+
);
|
|
606
|
+
}
|
|
469
607
|
try {
|
|
470
608
|
return await ns.update(id, values);
|
|
471
609
|
} catch (err) {
|
|
472
610
|
throw toDatastoreError(err);
|
|
473
611
|
}
|
|
474
612
|
},
|
|
475
|
-
|
|
613
|
+
[resolveNs],
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const del = useCallback(
|
|
617
|
+
async (id) => {
|
|
618
|
+
const ns = resolveNs();
|
|
619
|
+
if (!ns) {
|
|
620
|
+
throw new DatastoreError(
|
|
621
|
+
"VALIDATION",
|
|
622
|
+
"useDatastoreMutation: no table bound — set a source table before deleting a record.",
|
|
623
|
+
);
|
|
624
|
+
}
|
|
476
625
|
try {
|
|
477
626
|
return await ns.delete(id);
|
|
478
627
|
} catch (err) {
|
|
479
628
|
throw toDatastoreError(err);
|
|
480
629
|
}
|
|
481
630
|
},
|
|
482
|
-
|
|
483
|
-
|
|
631
|
+
[resolveNs],
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
return { create, update, delete: del };
|
|
635
|
+
}
|
|
484
636
|
|
|
485
637
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
638
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — structured error thrown by
|
|
639
|
+
* `useRecordPermissions` mutation callbacks (and surfaced by the hook's
|
|
640
|
+
* `error` slot when the initial list fetch fails). Carries a stable
|
|
641
|
+
* `code` so widgets can branch on the error class without parsing
|
|
642
|
+
* message strings; mirrors the shape of `DirectoryError`.
|
|
643
|
+
*
|
|
644
|
+
* `code` is one of:
|
|
645
|
+
* - "FORBIDDEN" — 403 from the host (caller lacks canGrant on the record).
|
|
646
|
+
* - "VALIDATION" — 400 / 422 (missing principal, malformed body).
|
|
647
|
+
* - "NOT_FOUND" — 404 (record absent, cross-tenant, or permission row
|
|
648
|
+
* not found on revoke/update).
|
|
649
|
+
* - "CONFLICT" — 409 (e.g. trying to edit a template-derived row).
|
|
650
|
+
* - "INTERNAL" — anything else (network, 5xx).
|
|
651
|
+
*/
|
|
652
|
+
export class PermissionError extends Error {
|
|
653
|
+
constructor(code, message, opts) {
|
|
654
|
+
super(message);
|
|
655
|
+
this.name = "PermissionError";
|
|
656
|
+
this.code = code;
|
|
657
|
+
if (opts && opts.status !== undefined) this.status = opts.status;
|
|
658
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function toPermissionError(err) {
|
|
663
|
+
if (err instanceof PermissionError) return err;
|
|
664
|
+
const status =
|
|
665
|
+
err && err.response && typeof err.response.status === "number"
|
|
666
|
+
? err.response.status
|
|
667
|
+
: null;
|
|
668
|
+
const bodyCode =
|
|
669
|
+
err && err.response && err.response.data && err.response.data.code;
|
|
670
|
+
const bodyMessage =
|
|
671
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
672
|
+
let code = "INTERNAL";
|
|
673
|
+
if (status === 403) code = "FORBIDDEN";
|
|
674
|
+
else if (status === 404) code = "NOT_FOUND";
|
|
675
|
+
else if (status === 409) code = "CONFLICT";
|
|
676
|
+
else if (status === 400 || status === 422) code = "VALIDATION";
|
|
677
|
+
if (typeof bodyCode === "string" && bodyCode) {
|
|
678
|
+
// Preserve the server's stable code over the status-derived one when
|
|
679
|
+
// the server volunteered it (e.g. TEMPLATE_DERIVED on edit/delete of
|
|
680
|
+
// an inherit/template row).
|
|
681
|
+
code = bodyCode;
|
|
682
|
+
}
|
|
683
|
+
const message =
|
|
684
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
685
|
+
(err && typeof err.message === "string"
|
|
686
|
+
? err.message
|
|
687
|
+
: "Record permission call failed");
|
|
688
|
+
return new PermissionError(code, message, { status, cause: err });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const _NOOP_PERMISSIONS_RESULT = Object.freeze({
|
|
692
|
+
permissions: [],
|
|
693
|
+
loading: false,
|
|
694
|
+
error: null,
|
|
695
|
+
// Mutation no-ops resolve with null so a widget that does
|
|
696
|
+
// `await grant(...)` doesn't crash when called against an unbound
|
|
697
|
+
// (tableId / recordId null) hook.
|
|
698
|
+
grant: async () => null,
|
|
699
|
+
revoke: async () => undefined,
|
|
700
|
+
update: async () => null,
|
|
701
|
+
refetch: async () => undefined,
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* REQ-ACL-06 / REQ-ACL-RELINHERIT-05 — manage per-record VirtualPermission
|
|
706
|
+
* grants on a single record. Returns
|
|
707
|
+
* `{ permissions, loading, error, grant, revoke, update, refetch }`.
|
|
488
708
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* directory projection the backend hands to non-Studio (Player) callers.
|
|
492
|
-
* Use it to build a chat people-list, an @-mention picker, or to resolve
|
|
493
|
-
* an author id to a display name. Mutating users is NOT part of this
|
|
494
|
-
* surface; the directory is read-only from a widget.
|
|
709
|
+
* `permissions` is an array of snake_case rows VERBATIM, e.g.
|
|
710
|
+
* `{ id, user_id, group_id, can_read, can_write, can_delete, can_grant, ... }`.
|
|
495
711
|
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* (
|
|
499
|
-
* `
|
|
500
|
-
*
|
|
712
|
+
* Reads through the injected `@colixsystems/datastore-client` at
|
|
713
|
+
* `ctx.datastore.records(tableId).permissions(recordId)`, which exposes:
|
|
714
|
+
* - `list()` → `Promise<{ data, meta }>` (envelope; we unwrap `res.data`)
|
|
715
|
+
* - `grant(body)` → `Promise<row>`
|
|
716
|
+
* - `update(permissionId, patch)` → `Promise<row>`
|
|
717
|
+
* - `revoke(permissionId)` → `Promise<void>`
|
|
501
718
|
*
|
|
502
|
-
*
|
|
503
|
-
* `
|
|
719
|
+
* Grant / update bodies are snake_case verbatim
|
|
720
|
+
* (`{ user_id | group_id, can_read, can_write, can_delete, can_grant }`) —
|
|
721
|
+
* the SDK does NOT transform them. Rows come back snake_case too; widgets
|
|
722
|
+
* read those fields directly.
|
|
723
|
+
*
|
|
724
|
+
* When `tableId` OR `recordId` is null / undefined / empty (e.g. the
|
|
725
|
+
* widget hasn't picked an active channel yet), the hook collapses to a
|
|
726
|
+
* stable empty result without a network round-trip. Mutation methods
|
|
727
|
+
* are safe no-ops in that state so a widget can call them
|
|
728
|
+
* unconditionally.
|
|
729
|
+
*
|
|
730
|
+
* Requires the `acl.write:records` scope in the widget manifest's
|
|
731
|
+
* `requestedScopes`. The underlying REST endpoint gates the call on
|
|
732
|
+
* `can_grant` for the target record (Studio owners short-circuit;
|
|
733
|
+
* APP_USER actors must hold `can_grant` via REQ-ACL-05 / REQ-ACL-06).
|
|
734
|
+
* A widget that declares the scope but whose caller lacks the grant
|
|
735
|
+
* receives `PermissionError { code: "FORBIDDEN" }`.
|
|
504
736
|
*/
|
|
505
|
-
export function
|
|
506
|
-
const ctx = useWidgetContextOrThrow("
|
|
507
|
-
if (!ctx.
|
|
508
|
-
throw new Error(
|
|
737
|
+
export function useRecordPermissions(tableId, recordId) {
|
|
738
|
+
const ctx = useWidgetContextOrThrow("useRecordPermissions");
|
|
739
|
+
if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
|
|
740
|
+
throw new Error(
|
|
741
|
+
"useRecordPermissions: host did not inject a datastore client (ctx.datastore.records)",
|
|
742
|
+
);
|
|
509
743
|
}
|
|
510
|
-
const
|
|
511
|
-
const [
|
|
744
|
+
const ready = Boolean(tableId && recordId);
|
|
745
|
+
const [permissions, setPermissions] = useState([]);
|
|
746
|
+
const [loading, setLoading] = useState(ready);
|
|
512
747
|
const [error, setError] = useState(null);
|
|
513
748
|
|
|
514
|
-
// Same ref discipline as useDatastoreQuery
|
|
515
|
-
// WidgetContext value every render, so we capture the
|
|
516
|
-
// client in refs to keep
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
749
|
+
// Same ref discipline as useDirectory / useDatastoreQuery — the host
|
|
750
|
+
// rebuilds the WidgetContext value every render, so we capture the
|
|
751
|
+
// live ids + client in refs to keep refetch + grant + revoke + update
|
|
752
|
+
// stable callback identities.
|
|
753
|
+
const tableIdRef = useRef(tableId);
|
|
754
|
+
const recordIdRef = useRef(recordId);
|
|
755
|
+
const recordsRef = useRef(ctx.datastore.records);
|
|
756
|
+
tableIdRef.current = tableId;
|
|
757
|
+
recordIdRef.current = recordId;
|
|
758
|
+
recordsRef.current = ctx.datastore.records;
|
|
759
|
+
|
|
760
|
+
// Resolve the per-record permissions namespace
|
|
761
|
+
// (ctx.datastore.records(tableId).permissions(recordId)) for the bound
|
|
762
|
+
// ids. Returns null when either id is missing.
|
|
763
|
+
const resolveNs = useCallback(() => {
|
|
764
|
+
const t = tableIdRef.current;
|
|
765
|
+
const r = recordIdRef.current;
|
|
766
|
+
if (!t || !r) return null;
|
|
767
|
+
const recordsNs = recordsRef.current(t);
|
|
768
|
+
if (!recordsNs || typeof recordsNs.permissions !== "function") {
|
|
769
|
+
throw new Error(
|
|
770
|
+
"useRecordPermissions: datastore client does not expose records(table).permissions(recordId)",
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
return recordsNs.permissions(r);
|
|
774
|
+
}, []);
|
|
521
775
|
|
|
522
776
|
const runRef = useRef(0);
|
|
523
777
|
|
|
524
778
|
const doFetch = useCallback(async () => {
|
|
525
779
|
const myRun = ++runRef.current;
|
|
780
|
+
const ns = resolveNs();
|
|
781
|
+
if (!ns) {
|
|
782
|
+
// Collapse to a stable empty result without a network round-trip
|
|
783
|
+
// so a widget rendering before an active record is picked stays
|
|
784
|
+
// loop-free.
|
|
785
|
+
setLoading(false);
|
|
786
|
+
setError(null);
|
|
787
|
+
setPermissions([]);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
526
790
|
setLoading(true);
|
|
527
791
|
setError(null);
|
|
528
792
|
try {
|
|
529
|
-
const
|
|
793
|
+
const res = await ns.list();
|
|
794
|
+
// permissions().list() returns the { data, meta } envelope verbatim.
|
|
795
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
530
796
|
if (runRef.current !== myRun) return;
|
|
531
|
-
|
|
797
|
+
setPermissions(rows);
|
|
532
798
|
setLoading(false);
|
|
533
799
|
} catch (err) {
|
|
534
800
|
if (runRef.current !== myRun) return;
|
|
535
|
-
setError(
|
|
801
|
+
setError(toPermissionError(err));
|
|
536
802
|
setLoading(false);
|
|
537
803
|
}
|
|
538
|
-
}, []);
|
|
804
|
+
}, [resolveNs]);
|
|
539
805
|
|
|
540
|
-
const queryKey = (() => {
|
|
541
|
-
try {
|
|
542
|
-
return JSON.stringify(query);
|
|
543
|
-
} catch (_e) {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
})();
|
|
547
806
|
useEffect(() => {
|
|
807
|
+
if (!ready) {
|
|
808
|
+
// Reset the slot when the widget unbinds (e.g. user navigates
|
|
809
|
+
// back to the picker) so the next render doesn't show stale
|
|
810
|
+
// permissions from a previous record.
|
|
811
|
+
setPermissions([]);
|
|
812
|
+
setLoading(false);
|
|
813
|
+
setError(null);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
548
816
|
doFetch();
|
|
549
817
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
550
|
-
}, [
|
|
818
|
+
}, [tableId, recordId]);
|
|
551
819
|
|
|
552
820
|
const refetch = useCallback(async () => {
|
|
553
821
|
await doFetch();
|
|
554
822
|
}, [doFetch]);
|
|
555
823
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
* optional chaining.
|
|
573
|
-
*/
|
|
574
|
-
export function useTheme() {
|
|
575
|
-
const ctx = useWidgetContextOrThrow("useTheme");
|
|
576
|
-
return ctx.workspace.theme;
|
|
577
|
-
}
|
|
824
|
+
const grant = useCallback(async (body) => {
|
|
825
|
+
const ns = resolveNs();
|
|
826
|
+
if (!ns) return null;
|
|
827
|
+
try {
|
|
828
|
+
// body is snake_case verbatim ({ user_id | group_id, can_read,
|
|
829
|
+
// can_write, can_delete, can_grant }) — passed straight through.
|
|
830
|
+
const row = await ns.grant(body);
|
|
831
|
+
// Refresh after the mutation so subsequent reads observe the
|
|
832
|
+
// grant. The client returns the created row too, which we hand
|
|
833
|
+
// back to the caller for inline use.
|
|
834
|
+
await doFetch();
|
|
835
|
+
return row;
|
|
836
|
+
} catch (err) {
|
|
837
|
+
throw toPermissionError(err);
|
|
838
|
+
}
|
|
839
|
+
}, [resolveNs, doFetch]);
|
|
578
840
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
* roles, or stamp a created-by field. Email is opaque to widgets that
|
|
590
|
-
* only need a display name — prefer `displayName` for UI strings.
|
|
591
|
-
*/
|
|
592
|
-
export function useUser() {
|
|
593
|
-
const ctx = useWidgetContextOrThrow("useUser");
|
|
594
|
-
return ctx.user;
|
|
595
|
-
}
|
|
841
|
+
const revoke = useCallback(async (permissionId) => {
|
|
842
|
+
const ns = resolveNs();
|
|
843
|
+
if (!ns) return undefined;
|
|
844
|
+
try {
|
|
845
|
+
await ns.revoke(permissionId);
|
|
846
|
+
await doFetch();
|
|
847
|
+
} catch (err) {
|
|
848
|
+
throw toPermissionError(err);
|
|
849
|
+
}
|
|
850
|
+
}, [resolveNs, doFetch]);
|
|
596
851
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
);
|
|
852
|
+
const update = useCallback(async (permissionId, body) => {
|
|
853
|
+
const ns = resolveNs();
|
|
854
|
+
if (!ns) return null;
|
|
855
|
+
try {
|
|
856
|
+
// body (the patch) is snake_case verbatim.
|
|
857
|
+
const row = await ns.update(permissionId, body);
|
|
858
|
+
await doFetch();
|
|
859
|
+
return row;
|
|
860
|
+
} catch (err) {
|
|
861
|
+
throw toPermissionError(err);
|
|
862
|
+
}
|
|
863
|
+
}, [resolveNs, doFetch]);
|
|
864
|
+
|
|
865
|
+
if (!ready) {
|
|
866
|
+
// Mutation no-ops are safe: a widget that does
|
|
867
|
+
// `await grant({...})` against an unbound hook resolves to null
|
|
868
|
+
// rather than throwing. Same shape as the populated case so the
|
|
869
|
+
// caller can branch on `tableId/recordId` once, at the top.
|
|
870
|
+
return _NOOP_PERMISSIONS_RESULT;
|
|
617
871
|
}
|
|
618
|
-
return ctx.renderer;
|
|
619
|
-
}
|
|
620
872
|
|
|
621
|
-
|
|
622
|
-
* Renders an author-authored page-tree node through the host's child
|
|
623
|
-
* renderer. The widget hands off a node (or null) and gets back a React
|
|
624
|
-
* element rendered with the same dispatch the top-level page uses, so
|
|
625
|
-
* Tabs / Card / custom container widgets can host arbitrary child
|
|
626
|
-
* widgets without importing the host.
|
|
627
|
-
*/
|
|
628
|
-
export function WidgetTree({ node }) {
|
|
629
|
-
const ctx = useWidgetContextOrThrow("WidgetTree");
|
|
630
|
-
if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
633
|
-
if (!node) return null;
|
|
634
|
-
return ctx.renderer.renderNode(node);
|
|
873
|
+
return { permissions, loading, error, grant, revoke, update, refetch };
|
|
635
874
|
}
|
|
636
875
|
|
|
637
|
-
|
|
638
|
-
*
|
|
639
|
-
* `{ goTo, goBack, push, replace, back, currentRoute }`.
|
|
640
|
-
*
|
|
641
|
-
* `goTo(pageId, params?)` navigates to an internal app page. `goBack()`
|
|
642
|
-
* pops the stack. `push` / `replace` / `back` are aliases that map to
|
|
643
|
-
* the platform's native navigator (react-router on web, react-navigation
|
|
644
|
-
* on native). Missing methods degrade to no-ops on the Studio canvas
|
|
645
|
-
* preview, where there is no live router.
|
|
876
|
+
/* ============================================================================
|
|
877
|
+
* FILES CLIENT — ctx.files (@colixsystems/files-client)
|
|
646
878
|
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*/
|
|
650
|
-
export function useNavigation() {
|
|
651
|
-
const ctx = useWidgetContextOrThrow("useNavigation");
|
|
652
|
-
return ctx.navigation;
|
|
653
|
-
}
|
|
879
|
+
* Files, folders, shares. Covers: useFile.
|
|
880
|
+
* ==========================================================================*/
|
|
654
881
|
|
|
655
882
|
/**
|
|
656
|
-
*
|
|
657
|
-
*
|
|
883
|
+
* Stateful file-asset resolver hook. Returns { url, file, loading, error,
|
|
884
|
+
* refetch }.
|
|
658
885
|
*
|
|
659
|
-
* `
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
886
|
+
* The host injects a `@colixsystems/files-client` instance at `ctx.files`,
|
|
887
|
+
* FLATTENED so file ops are top-level. We call `ctx.files.get(fileId)`,
|
|
888
|
+
* which resolves to `{ url, ...meta }` — the returned file already carries
|
|
889
|
+
* an absolute URL so the widget can drop it straight into an `<Image
|
|
890
|
+
* source>` without knowing where the API lives. A missing/soft-deleted
|
|
891
|
+
* asset surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
|
|
892
|
+
*
|
|
893
|
+
* When `fileId` is falsy the hook collapses to { url: null, file: null,
|
|
894
|
+
* loading: false, error: null, refetch } without a network round-trip,
|
|
895
|
+
* so a widget rendering before the author has bound an asset stays
|
|
896
|
+
* loop-free.
|
|
666
897
|
*/
|
|
667
|
-
export
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
this.code = code;
|
|
672
|
-
if (opts && opts.cause) this.cause = opts.cause;
|
|
898
|
+
export function useFile(fileId) {
|
|
899
|
+
const ctx = useWidgetContextOrThrow("useFile");
|
|
900
|
+
if (!ctx.files || typeof ctx.files.get !== "function") {
|
|
901
|
+
throw new Error("useFile: host did not inject a files client");
|
|
673
902
|
}
|
|
674
|
-
|
|
903
|
+
const ready = Boolean(fileId);
|
|
904
|
+
const [file, setFile] = useState(null);
|
|
905
|
+
const [loading, setLoading] = useState(ready);
|
|
906
|
+
const [error, setError] = useState(null);
|
|
675
907
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
? err.response.status
|
|
681
|
-
: null;
|
|
682
|
-
const bodyCode =
|
|
683
|
-
err && err.response && err.response.data && err.response.data.code;
|
|
684
|
-
const bodyMessage =
|
|
685
|
-
err && err.response && err.response.data && err.response.data.error;
|
|
686
|
-
let code = "INTERNAL";
|
|
687
|
-
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
688
|
-
else if (status === 401) code = "AUTH_REQUIRED";
|
|
689
|
-
else if (status === 402) code = "DECLINED";
|
|
690
|
-
else if (status === 403) code = "FORBIDDEN";
|
|
691
|
-
else if (status === 400) code = "VALIDATION";
|
|
692
|
-
const message =
|
|
693
|
-
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
694
|
-
(err && typeof err.message === "string"
|
|
695
|
-
? err.message
|
|
696
|
-
: "Payment request failed");
|
|
697
|
-
return new PaymentError(code, message, { cause: err });
|
|
698
|
-
}
|
|
908
|
+
const fileIdRef = useRef(fileId);
|
|
909
|
+
const getRef = useRef(ctx.files.get);
|
|
910
|
+
fileIdRef.current = fileId;
|
|
911
|
+
getRef.current = ctx.files.get;
|
|
699
912
|
|
|
700
|
-
|
|
701
|
-
* Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
|
|
702
|
-
* `{ requestPayment, getPayment }`.
|
|
703
|
-
*
|
|
704
|
-
* requestPayment({ amountCents, currency?, description, metadata? })
|
|
705
|
-
* → Promise<{ id, status, checkoutUrl?, ... }>. The host either
|
|
706
|
-
* auto-confirms (mock provider, `status: "PAID"`, no redirect) or
|
|
707
|
-
* returns a hosted-Checkout `checkoutUrl` the widget should open
|
|
708
|
-
* (Stripe provider, `status: "PENDING"`). Rejects with a
|
|
709
|
-
* `PaymentError`.
|
|
710
|
-
* getPayment(paymentId) → Promise<payment> — poll the terminal status.
|
|
711
|
-
*
|
|
712
|
-
* Requires the `payments.charge:appUser` scope in the manifest's
|
|
713
|
-
* `requestedScopes`. The charge settles to the workspace owner; the app
|
|
714
|
-
* user confirms the amount in hosted Checkout. No card data touches the
|
|
715
|
-
* widget — never collect card fields yourself.
|
|
716
|
-
*/
|
|
717
|
-
export function usePayments() {
|
|
718
|
-
const ctx = useWidgetContextOrThrow("usePayments");
|
|
719
|
-
if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
|
|
720
|
-
throw new Error(
|
|
721
|
-
"usePayments: host did not inject a payments client. The widget must " +
|
|
722
|
-
"declare the payments.charge:appUser scope and be installed in a " +
|
|
723
|
-
"workspace whose host supports payments.",
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
const requestRef = useRef(ctx.payments.requestPayment);
|
|
727
|
-
const getRef = useRef(
|
|
728
|
-
typeof ctx.payments.getPayment === "function"
|
|
729
|
-
? ctx.payments.getPayment
|
|
730
|
-
: null,
|
|
731
|
-
);
|
|
732
|
-
requestRef.current = ctx.payments.requestPayment;
|
|
733
|
-
getRef.current =
|
|
734
|
-
typeof ctx.payments.getPayment === "function"
|
|
735
|
-
? ctx.payments.getPayment
|
|
736
|
-
: null;
|
|
913
|
+
const runRef = useRef(0);
|
|
737
914
|
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (!getRef.current) {
|
|
747
|
-
throw new PaymentError(
|
|
748
|
-
"INTERNAL",
|
|
749
|
-
"getPayment is not supported by this host.",
|
|
750
|
-
);
|
|
915
|
+
const doFetch = useCallback(async () => {
|
|
916
|
+
const myRun = ++runRef.current;
|
|
917
|
+
const id = fileIdRef.current;
|
|
918
|
+
if (!id) {
|
|
919
|
+
setLoading(false);
|
|
920
|
+
setError(null);
|
|
921
|
+
setFile(null);
|
|
922
|
+
return;
|
|
751
923
|
}
|
|
924
|
+
setLoading(true);
|
|
925
|
+
setError(null);
|
|
752
926
|
try {
|
|
753
|
-
|
|
927
|
+
const f = await getRef.current(id);
|
|
928
|
+
if (runRef.current !== myRun) return;
|
|
929
|
+
setFile(f || null);
|
|
930
|
+
setLoading(false);
|
|
754
931
|
} catch (err) {
|
|
755
|
-
|
|
932
|
+
if (runRef.current !== myRun) return;
|
|
933
|
+
setError(toDatastoreError(err));
|
|
934
|
+
setLoading(false);
|
|
756
935
|
}
|
|
757
936
|
}, []);
|
|
758
|
-
|
|
937
|
+
|
|
938
|
+
useEffect(() => {
|
|
939
|
+
doFetch();
|
|
940
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
941
|
+
}, [fileId]);
|
|
942
|
+
|
|
943
|
+
const refetch = useCallback(async () => {
|
|
944
|
+
await doFetch();
|
|
945
|
+
}, [doFetch]);
|
|
946
|
+
|
|
947
|
+
const url =
|
|
948
|
+
file && typeof file.url === "string" && file.url.length > 0
|
|
949
|
+
? file.url
|
|
950
|
+
: null;
|
|
951
|
+
return { url, file, loading, error, refetch };
|
|
759
952
|
}
|
|
760
953
|
|
|
761
|
-
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
|
|
954
|
+
/* ============================================================================
|
|
955
|
+
* DIRECTORY CLIENT — ctx.directory (@colixsystems/directory-client)
|
|
956
|
+
*
|
|
957
|
+
* Me, users, groups, invites. Covers:
|
|
958
|
+
* useDirectory, useUsers, useGroups.
|
|
959
|
+
* ==========================================================================*/
|
|
960
|
+
|
|
767
961
|
/**
|
|
768
962
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — structured error thrown by `useUsers` /
|
|
769
963
|
* `useGroups` callbacks. Carries a stable `code` so widgets can branch
|
|
@@ -814,14 +1008,103 @@ function toDirectoryError(err) {
|
|
|
814
1008
|
return new DirectoryError(code, message, { cause: err });
|
|
815
1009
|
}
|
|
816
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* Stateful user-directory query hook. Returns { users, loading, error,
|
|
1013
|
+
* refetch }.
|
|
1014
|
+
*
|
|
1015
|
+
* The host injects a `@colixsystems/directory-client` instance at
|
|
1016
|
+
* `ctx.directory`. We call `ctx.directory.users.list(query)`, which resolves
|
|
1017
|
+
* to the `{ data, meta }` list envelope VERBATIM; we unwrap `res.data`
|
|
1018
|
+
* (default `[]`) into the `users` slot. Rows are snake_case (`id`, `name`,
|
|
1019
|
+
* `role`, …) — the privacy-reduced directory projection the backend hands to
|
|
1020
|
+
* non-Studio (Player) callers. Use it to build a chat people-list, an
|
|
1021
|
+
* @-mention picker, or to resolve an author id to a display name. Mutating
|
|
1022
|
+
* users is NOT part of this surface; the directory is read-only here (see
|
|
1023
|
+
* useUsers for administration).
|
|
1024
|
+
*
|
|
1025
|
+
* `query` is an optional `{ q?, role?, is_active?, limit?, offset? }`
|
|
1026
|
+
* object passed through verbatim. The hook re-fetches whenever
|
|
1027
|
+
* `JSON.stringify(query)` changes and exposes `refetch` for on-demand
|
|
1028
|
+
* reloads (e.g. a chat roster refresh).
|
|
1029
|
+
*
|
|
1030
|
+
* Requires the `directory.read:users` scope in the widget manifest's
|
|
1031
|
+
* `requestedScopes`.
|
|
1032
|
+
*/
|
|
1033
|
+
export function useDirectory(query) {
|
|
1034
|
+
const ctx = useWidgetContextOrThrow("useDirectory");
|
|
1035
|
+
if (
|
|
1036
|
+
!ctx.directory ||
|
|
1037
|
+
!ctx.directory.users ||
|
|
1038
|
+
typeof ctx.directory.users.list !== "function"
|
|
1039
|
+
) {
|
|
1040
|
+
throw new Error(
|
|
1041
|
+
"useDirectory: host did not inject a directory client (ctx.directory.users.list)",
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
const [users, setUsers] = useState([]);
|
|
1045
|
+
const [loading, setLoading] = useState(true);
|
|
1046
|
+
const [error, setError] = useState(null);
|
|
1047
|
+
|
|
1048
|
+
// Same ref discipline as useDatastoreQuery: the host rebuilds the
|
|
1049
|
+
// WidgetContext value every render, so we capture the live query +
|
|
1050
|
+
// client in refs to keep `refetch` a stable identity.
|
|
1051
|
+
const queryRef = useRef(query);
|
|
1052
|
+
const usersNsRef = useRef(ctx.directory.users);
|
|
1053
|
+
queryRef.current = query;
|
|
1054
|
+
usersNsRef.current = ctx.directory.users;
|
|
1055
|
+
|
|
1056
|
+
const runRef = useRef(0);
|
|
1057
|
+
|
|
1058
|
+
const doFetch = useCallback(async () => {
|
|
1059
|
+
const myRun = ++runRef.current;
|
|
1060
|
+
setLoading(true);
|
|
1061
|
+
setError(null);
|
|
1062
|
+
try {
|
|
1063
|
+
const res = await usersNsRef.current.list(queryRef.current);
|
|
1064
|
+
// Directory list returns the { data, meta } envelope verbatim.
|
|
1065
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
1066
|
+
if (runRef.current !== myRun) return;
|
|
1067
|
+
setUsers(rows);
|
|
1068
|
+
setLoading(false);
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
if (runRef.current !== myRun) return;
|
|
1071
|
+
setError(toDatastoreError(err));
|
|
1072
|
+
setLoading(false);
|
|
1073
|
+
}
|
|
1074
|
+
}, []);
|
|
1075
|
+
|
|
1076
|
+
const queryKey = (() => {
|
|
1077
|
+
try {
|
|
1078
|
+
return JSON.stringify(query);
|
|
1079
|
+
} catch (_e) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
})();
|
|
1083
|
+
useEffect(() => {
|
|
1084
|
+
doFetch();
|
|
1085
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1086
|
+
}, [queryKey]);
|
|
1087
|
+
|
|
1088
|
+
const refetch = useCallback(async () => {
|
|
1089
|
+
await doFetch();
|
|
1090
|
+
}, [doFetch]);
|
|
1091
|
+
|
|
1092
|
+
return { users, loading, error, refetch };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
817
1095
|
/**
|
|
818
1096
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration hook.
|
|
819
1097
|
*
|
|
820
1098
|
* Returns `{ users, loading, error, refetch, invite, deactivate,
|
|
821
|
-
* reactivate, remove }`.
|
|
822
|
-
* `
|
|
823
|
-
* `
|
|
824
|
-
*
|
|
1099
|
+
* reactivate, remove }`. Reads through the injected
|
|
1100
|
+
* `@colixsystems/directory-client` at `ctx.directory.users.{list, get,
|
|
1101
|
+
* invite, deactivate, reactivate}` — `list` resolves to the `{ data, meta }`
|
|
1102
|
+
* envelope VERBATIM, so we unwrap `res.data` (default `[]`). User rows are
|
|
1103
|
+
* snake_case (`id`, `name`, `email`, `role`, `is_active`, …) and bodies
|
|
1104
|
+
* (e.g. `{ email, name, group_ids? }`) pass through verbatim. The list
|
|
1105
|
+
* refetches whenever `JSON.stringify(query)` changes; the imperative methods
|
|
1106
|
+
* reject with a `DirectoryError`. Reads require the `users.read:*` scope;
|
|
1107
|
+
* mutations additionally require `users.write:*`. The host's signed
|
|
825
1108
|
* `X-Widget-Scopes` header + a tenant-scoped SystemAcl `users.read` /
|
|
826
1109
|
* `users.write` capability grant gate the underlying endpoint
|
|
827
1110
|
* (REQ-ACL-SYS M3 §4.3) — a widget that declares the scopes but whose
|
|
@@ -829,17 +1112,23 @@ function toDirectoryError(err) {
|
|
|
829
1112
|
*/
|
|
830
1113
|
export function useUsers(query) {
|
|
831
1114
|
const ctx = useWidgetContextOrThrow("useUsers");
|
|
832
|
-
if (
|
|
833
|
-
|
|
1115
|
+
if (
|
|
1116
|
+
!ctx.directory ||
|
|
1117
|
+
!ctx.directory.users ||
|
|
1118
|
+
typeof ctx.directory.users.list !== "function"
|
|
1119
|
+
) {
|
|
1120
|
+
throw new Error(
|
|
1121
|
+
"useUsers: host did not inject a directory client (ctx.directory.users)",
|
|
1122
|
+
);
|
|
834
1123
|
}
|
|
835
1124
|
const [users, setUsers] = useState([]);
|
|
836
1125
|
const [loading, setLoading] = useState(true);
|
|
837
1126
|
const [error, setError] = useState(null);
|
|
838
1127
|
|
|
839
1128
|
const queryRef = useRef(query);
|
|
840
|
-
const usersRef = useRef(ctx.users);
|
|
1129
|
+
const usersRef = useRef(ctx.directory.users);
|
|
841
1130
|
queryRef.current = query;
|
|
842
|
-
usersRef.current = ctx.users;
|
|
1131
|
+
usersRef.current = ctx.directory.users;
|
|
843
1132
|
|
|
844
1133
|
const runRef = useRef(0);
|
|
845
1134
|
|
|
@@ -848,9 +1137,11 @@ export function useUsers(query) {
|
|
|
848
1137
|
setLoading(true);
|
|
849
1138
|
setError(null);
|
|
850
1139
|
try {
|
|
851
|
-
const
|
|
1140
|
+
const res = await usersRef.current.list(queryRef.current);
|
|
1141
|
+
// Directory users.list returns the { data, meta } envelope verbatim.
|
|
1142
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
852
1143
|
if (runRef.current !== myRun) return;
|
|
853
|
-
setUsers(
|
|
1144
|
+
setUsers(rows);
|
|
854
1145
|
setLoading(false);
|
|
855
1146
|
} catch (err) {
|
|
856
1147
|
if (runRef.current !== myRun) return;
|
|
@@ -911,22 +1202,33 @@ export function useUsers(query) {
|
|
|
911
1202
|
* REQ-USERMGMT / REQ-ACL-SYS M3 — AppUserGroup administration hook.
|
|
912
1203
|
*
|
|
913
1204
|
* Returns `{ groups, loading, error, refetch, create, remove, addMember,
|
|
914
|
-
* removeMember }`. Reads
|
|
915
|
-
* `groups.
|
|
1205
|
+
* removeMember }`. Reads through the injected
|
|
1206
|
+
* `@colixsystems/directory-client` at `ctx.directory.groups.{list, create,
|
|
1207
|
+
* remove, addMember, removeMember, listMine}` — `list` resolves to the
|
|
1208
|
+
* `{ data, meta }` envelope VERBATIM, so we unwrap `res.data` (default `[]`).
|
|
1209
|
+
* Group rows are snake_case and bodies pass through verbatim. Reads require
|
|
1210
|
+
* `groups.read:*`; mutations require `groups.write:*`. Same X-Widget-Scopes +
|
|
1211
|
+
* SystemAcl gating as `useUsers`.
|
|
916
1212
|
*/
|
|
917
1213
|
export function useGroups(query) {
|
|
918
1214
|
const ctx = useWidgetContextOrThrow("useGroups");
|
|
919
|
-
if (
|
|
920
|
-
|
|
1215
|
+
if (
|
|
1216
|
+
!ctx.directory ||
|
|
1217
|
+
!ctx.directory.groups ||
|
|
1218
|
+
typeof ctx.directory.groups.list !== "function"
|
|
1219
|
+
) {
|
|
1220
|
+
throw new Error(
|
|
1221
|
+
"useGroups: host did not inject a directory client (ctx.directory.groups)",
|
|
1222
|
+
);
|
|
921
1223
|
}
|
|
922
1224
|
const [groups, setGroups] = useState([]);
|
|
923
1225
|
const [loading, setLoading] = useState(true);
|
|
924
1226
|
const [error, setError] = useState(null);
|
|
925
1227
|
|
|
926
1228
|
const queryRef = useRef(query);
|
|
927
|
-
const groupsRef = useRef(ctx.groups);
|
|
1229
|
+
const groupsRef = useRef(ctx.directory.groups);
|
|
928
1230
|
queryRef.current = query;
|
|
929
|
-
groupsRef.current = ctx.groups;
|
|
1231
|
+
groupsRef.current = ctx.directory.groups;
|
|
930
1232
|
|
|
931
1233
|
const runRef = useRef(0);
|
|
932
1234
|
|
|
@@ -935,9 +1237,11 @@ export function useGroups(query) {
|
|
|
935
1237
|
setLoading(true);
|
|
936
1238
|
setError(null);
|
|
937
1239
|
try {
|
|
938
|
-
const
|
|
1240
|
+
const res = await groupsRef.current.list(queryRef.current);
|
|
1241
|
+
// Directory groups.list returns the { data, meta } envelope verbatim.
|
|
1242
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
939
1243
|
if (runRef.current !== myRun) return;
|
|
940
|
-
setGroups(
|
|
1244
|
+
setGroups(rows);
|
|
941
1245
|
setLoading(false);
|
|
942
1246
|
} catch (err) {
|
|
943
1247
|
if (runRef.current !== myRun) return;
|
|
@@ -994,29 +1298,114 @@ export function useGroups(query) {
|
|
|
994
1298
|
return { groups, loading, error, refetch, create, remove, addMember, removeMember };
|
|
995
1299
|
}
|
|
996
1300
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1301
|
+
/* ============================================================================
|
|
1302
|
+
* PAYMENTS CLIENT — ctx.payments (@colixsystems/payments-client)
|
|
1303
|
+
*
|
|
1304
|
+
* requestPayment, getPayment. Covers: usePayments.
|
|
1305
|
+
* ==========================================================================*/
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Structured error thrown by `usePayments` callbacks. Carries a stable
|
|
1309
|
+
* `code` so widgets can branch without parsing message strings.
|
|
1310
|
+
*
|
|
1311
|
+
* `code` is one of:
|
|
1312
|
+
* - "AUTH_REQUIRED" — no signed-in app user
|
|
1313
|
+
* - "PAYMENTS_SCOPE_NOT_GRANTED"— widget lacks payments.charge:appUser
|
|
1314
|
+
* - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" — bad request
|
|
1315
|
+
* - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
|
|
1316
|
+
* - "DECLINED" — the charge was declined
|
|
1317
|
+
* - "INTERNAL" — anything else
|
|
1318
|
+
*/
|
|
1319
|
+
export class PaymentError extends Error {
|
|
1320
|
+
constructor(code, message, opts) {
|
|
1321
|
+
super(message);
|
|
1322
|
+
this.name = "PaymentError";
|
|
1323
|
+
this.code = code;
|
|
1324
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function toPaymentError(err) {
|
|
1329
|
+
if (err instanceof PaymentError) return err;
|
|
1330
|
+
const status =
|
|
1331
|
+
err && err.response && typeof err.response.status === "number"
|
|
1332
|
+
? err.response.status
|
|
1333
|
+
: null;
|
|
1334
|
+
const bodyCode =
|
|
1335
|
+
err && err.response && err.response.data && err.response.data.code;
|
|
1336
|
+
const bodyMessage =
|
|
1337
|
+
err && err.response && err.response.data && err.response.data.error;
|
|
1338
|
+
let code = "INTERNAL";
|
|
1339
|
+
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
1340
|
+
else if (status === 401) code = "AUTH_REQUIRED";
|
|
1341
|
+
else if (status === 402) code = "DECLINED";
|
|
1342
|
+
else if (status === 403) code = "FORBIDDEN";
|
|
1343
|
+
else if (status === 400) code = "VALIDATION";
|
|
1344
|
+
const message =
|
|
1345
|
+
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
1346
|
+
(err && typeof err.message === "string"
|
|
1347
|
+
? err.message
|
|
1348
|
+
: "Payment request failed");
|
|
1349
|
+
return new PaymentError(code, message, { cause: err });
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Incoming app-user payments (REQ-BILL-07-WIDGETPAY). Returns
|
|
1354
|
+
* `{ requestPayment, getPayment }`.
|
|
1355
|
+
*
|
|
1356
|
+
* requestPayment({ amountCents, currency?, description, metadata? })
|
|
1357
|
+
* → Promise<{ id, status, checkoutUrl?, ... }>. The host either
|
|
1358
|
+
* auto-confirms (mock provider, `status: "PAID"`, no redirect) or
|
|
1359
|
+
* returns a hosted-Checkout `checkoutUrl` the widget should open
|
|
1360
|
+
* (Stripe provider, `status: "PENDING"`). Rejects with a
|
|
1361
|
+
* `PaymentError`.
|
|
1362
|
+
* getPayment(paymentId) → Promise<payment> — poll the terminal status.
|
|
1363
|
+
*
|
|
1364
|
+
* Requires the `payments.charge:appUser` scope in the manifest's
|
|
1365
|
+
* `requestedScopes`. The charge settles to the workspace owner; the app
|
|
1366
|
+
* user confirms the amount in hosted Checkout. No card data touches the
|
|
1367
|
+
* widget — never collect card fields yourself.
|
|
1368
|
+
*/
|
|
1369
|
+
export function usePayments() {
|
|
1370
|
+
const ctx = useWidgetContextOrThrow("usePayments");
|
|
1371
|
+
if (!ctx.payments || typeof ctx.payments.requestPayment !== "function") {
|
|
1372
|
+
throw new Error(
|
|
1373
|
+
"usePayments: host did not inject a payments client. The widget must " +
|
|
1374
|
+
"declare the payments.charge:appUser scope and be installed in a " +
|
|
1375
|
+
"workspace whose host supports payments.",
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
const requestRef = useRef(ctx.payments.requestPayment);
|
|
1379
|
+
const getRef = useRef(
|
|
1380
|
+
typeof ctx.payments.getPayment === "function"
|
|
1381
|
+
? ctx.payments.getPayment
|
|
1382
|
+
: null,
|
|
1020
1383
|
);
|
|
1021
|
-
|
|
1384
|
+
requestRef.current = ctx.payments.requestPayment;
|
|
1385
|
+
getRef.current =
|
|
1386
|
+
typeof ctx.payments.getPayment === "function"
|
|
1387
|
+
? ctx.payments.getPayment
|
|
1388
|
+
: null;
|
|
1389
|
+
|
|
1390
|
+
const requestPayment = useCallback(async (args) => {
|
|
1391
|
+
try {
|
|
1392
|
+
return await requestRef.current(args);
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
throw toPaymentError(err);
|
|
1395
|
+
}
|
|
1396
|
+
}, []);
|
|
1397
|
+
const getPayment = useCallback(async (paymentId) => {
|
|
1398
|
+
if (!getRef.current) {
|
|
1399
|
+
throw new PaymentError(
|
|
1400
|
+
"INTERNAL",
|
|
1401
|
+
"getPayment is not supported by this host.",
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
try {
|
|
1405
|
+
return await getRef.current(paymentId);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
throw toPaymentError(err);
|
|
1408
|
+
}
|
|
1409
|
+
}, []);
|
|
1410
|
+
return { requestPayment, getPayment };
|
|
1022
1411
|
}
|