@colixsystems/widget-sdk 0.11.0 → 0.13.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 CHANGED
@@ -6,7 +6,16 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
6
6
 
7
7
  ## Status
8
8
 
9
- `v0.11.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**.
9
+ `v0.13.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**.
10
+
11
+ ### What's new in 0.13.0
12
+
13
+ - **`WidgetTree` component + `useChildRenderer()` hook** wired. Container widgets (Tabs, Card, …) can now render arbitrary author-authored child page-tree nodes through the SDK without importing the host renderer. The host pre-binds the surrounding render context (breakpoint, page ctx, parent) into a closure on `ctx.renderer.renderNode(node)` — the widget just passes the child node. Additive.
14
+
15
+ ### What's new in 0.12.0
16
+
17
+ - **`useDatastoreRecord(tableId, recordId)` is wired.** Returns `{ data, loading, error, refetch }` for a single record fetched through the host's `records(table).get(id)`. Sister to `useDatastoreQuery`; mirrors its ref discipline so `refetch` stays a stable callback identity. A 404 surfaces as `DatastoreError.code === "NOT_FOUND"`. Additive.
18
+ - **`useFile(fileId)` is wired** + new `WidgetContext.files` slice. Returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL the widget can drop straight into `<Image source>`. Backed by a new `files.get(fileId)` host facade (web: `widgetHostFiles` through `api/client`; native: `hostFiles` in the export's `widgetHost.js`). Additive.
10
19
 
11
20
  ### What's new in 0.11.0
12
21
 
@@ -80,7 +89,8 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
80
89
 
81
90
  - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
82
91
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
83
- - `useDatastoreQuery`, `useDatastoreMutation`, `useDirectory`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`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)`).
92
+ - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreMutation`, `useDirectory`, `useFile`, `useWidgetEvent`, `usePayments`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useChildRenderer` — hooks that read from the host-provided `WidgetContext`. `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (`id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation — for external URLs use the `Linking` primitive (`Linking.openURL(url)`). `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useFile(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).
93
+ - `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.
84
94
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet` — re-exported from `react-native`. The web build aliases `react-native` to `react-native-web` so widgets render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. See https://reactnative.dev/docs/ for per-component props.
85
95
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
86
96
 
package/dist/contract.cjs CHANGED
@@ -68,6 +68,15 @@ const HOOKS = [
68
68
  requiredContextSlice: ["user"],
69
69
  scopes: null,
70
70
  },
71
+ {
72
+ name: "useChildRenderer",
73
+ signature: "useChildRenderer()",
74
+ returnShape: {
75
+ renderNode: "(node) => ReactElement",
76
+ },
77
+ requiredContextSlice: ["renderer.renderNode"],
78
+ scopes: null,
79
+ },
71
80
  {
72
81
  name: "useNavigation",
73
82
  signature: "useNavigation()",
@@ -82,6 +91,31 @@ const HOOKS = [
82
91
  requiredContextSlice: ["navigation"],
83
92
  scopes: null,
84
93
  },
94
+ {
95
+ name: "useDatastoreRecord",
96
+ signature: "useDatastoreRecord(tableId, recordId)",
97
+ returnShape: {
98
+ data: "Record | null",
99
+ loading: "boolean",
100
+ error: "DatastoreError | null",
101
+ refetch: "() => Promise<void>",
102
+ },
103
+ requiredContextSlice: ["datastore.records"],
104
+ scopes: ["datastore.read:<table>"],
105
+ },
106
+ {
107
+ name: "useFile",
108
+ signature: "useFile(fileId)",
109
+ returnShape: {
110
+ url: "string | null",
111
+ file: "{ id, url, storedFilename, mimeType, sizeBytes, ... } | null",
112
+ loading: "boolean",
113
+ error: "DatastoreError | null",
114
+ refetch: "() => Promise<void>",
115
+ },
116
+ requiredContextSlice: ["files.get"],
117
+ scopes: null,
118
+ },
85
119
  {
86
120
  name: "useDatastoreQuery",
87
121
  signature: "useDatastoreQuery(tableId, options?)",
@@ -376,6 +410,18 @@ const WIDGET_CONTEXT_SHAPE = {
376
410
  required: true,
377
411
  fields: { listUsers: "function" },
378
412
  },
413
+ files: {
414
+ description:
415
+ "Read-only asset resolver. { get(fileId) -> Promise<{ id, url, storedFilename, mimeType, sizeBytes, ... }> }. Backs useFile(); resolves an asset id to an absolute URL the widget can drop into an <Image source>. The url field is always an absolute URL composed against the host's API base.",
416
+ required: true,
417
+ fields: { get: "function" },
418
+ },
419
+ renderer: {
420
+ description:
421
+ "Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
422
+ required: true,
423
+ fields: { renderNode: "function" },
424
+ },
379
425
  events: {
380
426
  description: "{ emit(name, payload) }.",
381
427
  required: true,
package/dist/contract.js CHANGED
@@ -68,6 +68,15 @@ const HOOKS = [
68
68
  requiredContextSlice: ["user"],
69
69
  scopes: null,
70
70
  },
71
+ {
72
+ name: "useChildRenderer",
73
+ signature: "useChildRenderer()",
74
+ returnShape: {
75
+ renderNode: "(node) => ReactElement",
76
+ },
77
+ requiredContextSlice: ["renderer.renderNode"],
78
+ scopes: null,
79
+ },
71
80
  {
72
81
  name: "useNavigation",
73
82
  signature: "useNavigation()",
@@ -82,6 +91,31 @@ const HOOKS = [
82
91
  requiredContextSlice: ["navigation"],
83
92
  scopes: null,
84
93
  },
94
+ {
95
+ name: "useDatastoreRecord",
96
+ signature: "useDatastoreRecord(tableId, recordId)",
97
+ returnShape: {
98
+ data: "Record | null",
99
+ loading: "boolean",
100
+ error: "DatastoreError | null",
101
+ refetch: "() => Promise<void>",
102
+ },
103
+ requiredContextSlice: ["datastore.records"],
104
+ scopes: ["datastore.read:<table>"],
105
+ },
106
+ {
107
+ name: "useFile",
108
+ signature: "useFile(fileId)",
109
+ returnShape: {
110
+ url: "string | null",
111
+ file: "{ id, url, storedFilename, mimeType, sizeBytes, ... } | null",
112
+ loading: "boolean",
113
+ error: "DatastoreError | null",
114
+ refetch: "() => Promise<void>",
115
+ },
116
+ requiredContextSlice: ["files.get"],
117
+ scopes: null,
118
+ },
85
119
  {
86
120
  name: "useDatastoreQuery",
87
121
  signature: "useDatastoreQuery(tableId, options?)",
@@ -370,6 +404,18 @@ const WIDGET_CONTEXT_SHAPE = {
370
404
  required: true,
371
405
  fields: { listUsers: "function" },
372
406
  },
407
+ files: {
408
+ description:
409
+ "Read-only asset resolver. { get(fileId) -> Promise<{ id, url, storedFilename, mimeType, sizeBytes, ... }> }. Backs useFile(); resolves an asset id to an absolute URL the widget can drop into an <Image source>. The url field is always an absolute URL composed against the host's API base.",
410
+ required: true,
411
+ fields: { get: "function" },
412
+ },
413
+ renderer: {
414
+ description:
415
+ "Host child-node renderer. { renderNode(node) -> ReactElement }. Backs WidgetTree / useChildRenderer; lets a container widget (Tabs, Card, …) render arbitrary author-authored child nodes without importing the host renderer. The closure pre-binds breakpoint + page ctx + parent so the widget passes only the child node.",
416
+ required: true,
417
+ fields: { renderNode: "function" },
418
+ },
373
419
  events: {
374
420
  description: "{ emit(name, payload) }.",
375
421
  required: true,
package/dist/hooks.js CHANGED
@@ -219,6 +219,153 @@ export function useDatastoreQuery(table, query) {
219
219
  return { data, loading, error, refetch };
220
220
  }
221
221
 
222
+ /**
223
+ * Stateful single-record query hook. Returns { data, loading, error, refetch }
224
+ * where `data` is one row (`null` until loaded, never an array).
225
+ *
226
+ * The host's datastore client exposes `records(table).get(id)` which
227
+ * resolves to a single record object. We hold the result in component
228
+ * state and re-fetch when [table, id] changes. `refetch` re-runs the
229
+ * call on demand.
230
+ *
231
+ * When `table` OR `recordId` is falsy (e.g. the author hasn't bound the
232
+ * record id yet), the hook resolves to { data: null, loading: false,
233
+ * error: null, refetch } so the widget can render its empty state
234
+ * without throwing. A 404 surfaces as a DatastoreError with
235
+ * `code: "NOT_FOUND"`.
236
+ */
237
+ export function useDatastoreRecord(table, recordId) {
238
+ const ctx = useWidgetContextOrThrow("useDatastoreRecord");
239
+ if (!ctx.datastore || typeof ctx.datastore.records !== "function") {
240
+ throw new Error(
241
+ "useDatastoreRecord: host did not inject a datastore client",
242
+ );
243
+ }
244
+ const ready = Boolean(table && recordId);
245
+ const [data, setData] = useState(null);
246
+ const [loading, setLoading] = useState(ready);
247
+ const [error, setError] = useState(null);
248
+
249
+ // Same ref discipline as useDatastoreQuery — `ctx` is a fresh object
250
+ // identity on every host render, so we hold the live inputs in refs
251
+ // to keep `refetch` a stable callback.
252
+ const tableRef = useRef(table);
253
+ const recordIdRef = useRef(recordId);
254
+ const recordsRef = useRef(ctx.datastore.records);
255
+ tableRef.current = table;
256
+ recordIdRef.current = recordId;
257
+ recordsRef.current = ctx.datastore.records;
258
+
259
+ const runRef = useRef(0);
260
+
261
+ const doFetch = useCallback(async () => {
262
+ const myRun = ++runRef.current;
263
+ const t = tableRef.current;
264
+ const id = recordIdRef.current;
265
+ if (!t || !id) {
266
+ setLoading(false);
267
+ setError(null);
268
+ setData(null);
269
+ return;
270
+ }
271
+ setLoading(true);
272
+ setError(null);
273
+ try {
274
+ const ns = recordsRef.current(t);
275
+ const row = await ns.get(id);
276
+ if (runRef.current !== myRun) return;
277
+ setData(row || null);
278
+ setLoading(false);
279
+ } catch (err) {
280
+ if (runRef.current !== myRun) return;
281
+ setError(toDatastoreError(err));
282
+ setLoading(false);
283
+ }
284
+ }, []);
285
+
286
+ useEffect(() => {
287
+ doFetch();
288
+ // eslint-disable-next-line react-hooks/exhaustive-deps
289
+ }, [table, recordId]);
290
+
291
+ const refetch = useCallback(async () => {
292
+ await doFetch();
293
+ }, [doFetch]);
294
+
295
+ return { data, loading, error, refetch };
296
+ }
297
+
298
+ /**
299
+ * Stateful file-asset resolver hook. Returns { url, file, loading, error,
300
+ * refetch }.
301
+ *
302
+ * The host's file client exposes `files.get(fileId)` which resolves to
303
+ * `{ url, ...meta }` — the absolute URL is composed against the host's
304
+ * API base so the widget can drop it straight into an `<Image source>`
305
+ * without knowing where the API lives. A missing/soft-deleted asset
306
+ * surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
307
+ *
308
+ * When `fileId` is falsy the hook collapses to { url: null, file: null,
309
+ * loading: false, error: null, refetch } without a network round-trip,
310
+ * so a widget rendering before the author has bound an asset stays
311
+ * loop-free.
312
+ */
313
+ export function useFile(fileId) {
314
+ const ctx = useWidgetContextOrThrow("useFile");
315
+ if (!ctx.files || typeof ctx.files.get !== "function") {
316
+ throw new Error("useFile: host did not inject a files client");
317
+ }
318
+ const ready = Boolean(fileId);
319
+ const [file, setFile] = useState(null);
320
+ const [loading, setLoading] = useState(ready);
321
+ const [error, setError] = useState(null);
322
+
323
+ const fileIdRef = useRef(fileId);
324
+ const getRef = useRef(ctx.files.get);
325
+ fileIdRef.current = fileId;
326
+ getRef.current = ctx.files.get;
327
+
328
+ const runRef = useRef(0);
329
+
330
+ const doFetch = useCallback(async () => {
331
+ const myRun = ++runRef.current;
332
+ const id = fileIdRef.current;
333
+ if (!id) {
334
+ setLoading(false);
335
+ setError(null);
336
+ setFile(null);
337
+ return;
338
+ }
339
+ setLoading(true);
340
+ setError(null);
341
+ try {
342
+ const f = await getRef.current(id);
343
+ if (runRef.current !== myRun) return;
344
+ setFile(f || null);
345
+ setLoading(false);
346
+ } catch (err) {
347
+ if (runRef.current !== myRun) return;
348
+ setError(toDatastoreError(err));
349
+ setLoading(false);
350
+ }
351
+ }, []);
352
+
353
+ useEffect(() => {
354
+ doFetch();
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps
356
+ }, [fileId]);
357
+
358
+ const refetch = useCallback(async () => {
359
+ await doFetch();
360
+ }, [doFetch]);
361
+
362
+ const url =
363
+ file && typeof file.url === "string" && file.url.length > 0
364
+ ? file.url
365
+ : null;
366
+ return { url, file, loading, error, refetch };
367
+ }
368
+
222
369
  /**
223
370
  * Datastore mutation hook. Returns { create, update, delete }, each method
224
371
  * returning a Promise. Rejected promises throw a DatastoreError carrying a
@@ -370,6 +517,46 @@ export function useUser() {
370
517
  return ctx.user;
371
518
  }
372
519
 
520
+ /**
521
+ * Returns the host's child-node renderer:
522
+ * { renderNode(node) }.
523
+ *
524
+ * Widgets like Tabs / Card / a custom container call `renderNode(child)`
525
+ * to render an author-authored page-tree node nested inside themselves.
526
+ * The renderer closes over the surrounding render context (breakpoint,
527
+ * page ctx, parent) that the host already knows, so the widget doesn't
528
+ * need to plumb any of that through.
529
+ *
530
+ * Prefer the `WidgetTree` component for the common case
531
+ * (`<WidgetTree node={child} />`); reach for the hook when you need to
532
+ * branch on the node's shape before rendering.
533
+ */
534
+ export function useChildRenderer() {
535
+ const ctx = useWidgetContextOrThrow("useChildRenderer");
536
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
537
+ throw new Error(
538
+ "useChildRenderer: host did not inject a child-node renderer",
539
+ );
540
+ }
541
+ return ctx.renderer;
542
+ }
543
+
544
+ /**
545
+ * Renders an author-authored page-tree node through the host's child
546
+ * renderer. The widget hands off a node (or null) and gets back a React
547
+ * element rendered with the same dispatch the top-level page uses, so
548
+ * Tabs / Card / custom container widgets can host arbitrary child
549
+ * widgets without importing the host.
550
+ */
551
+ export function WidgetTree({ node }) {
552
+ const ctx = useWidgetContextOrThrow("WidgetTree");
553
+ if (!ctx.renderer || typeof ctx.renderer.renderNode !== "function") {
554
+ return null;
555
+ }
556
+ if (!node) return null;
557
+ return ctx.renderer.renderNode(node);
558
+ }
559
+
373
560
  /**
374
561
  * Returns the host-provided navigation surface:
375
562
  * `{ goTo, goBack, push, replace, back, currentRoute }`.
package/dist/index.d.ts CHANGED
@@ -363,6 +363,56 @@ export function usePayments(): PaymentsApi;
363
363
 
364
364
  export function useTheme(): ThemeTokens;
365
365
 
366
+ /**
367
+ * Stateful single-record fetch hook. Returns `{ data, loading, error,
368
+ * refetch }`. `data` is one row or `null` (never an array).
369
+ */
370
+ export function useDatastoreRecord(
371
+ tableId: string | null | undefined,
372
+ recordId: string | null | undefined,
373
+ ): {
374
+ data: unknown | null;
375
+ loading: boolean;
376
+ error: DatastoreError | null;
377
+ refetch(): Promise<void>;
378
+ };
379
+
380
+ /**
381
+ * Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
382
+ * refetch }`. The `url` is an absolute URL composed against the host's API
383
+ * base; safe to pass straight to `<Image source>`.
384
+ */
385
+ /**
386
+ * The host's child-node renderer surface. `renderNode(node)` returns a
387
+ * React element rendered with the same dispatch the top-level page uses;
388
+ * the closure pre-binds breakpoint / page ctx / parent.
389
+ */
390
+ export function useChildRenderer(): {
391
+ renderNode(node: unknown): unknown;
392
+ };
393
+
394
+ /**
395
+ * Renders an author-authored page-tree node through the host's child
396
+ * renderer. Prefer this over `useChildRenderer()` for the common case
397
+ * (`<WidgetTree node={child} />`).
398
+ */
399
+ export const WidgetTree: (props: { node: unknown }) => unknown;
400
+
401
+ export function useFile(fileId: string | null | undefined): {
402
+ url: string | null;
403
+ file: {
404
+ id: string;
405
+ url: string;
406
+ storedFilename?: string;
407
+ mimeType?: string;
408
+ sizeBytes?: number;
409
+ [k: string]: unknown;
410
+ } | null;
411
+ loading: boolean;
412
+ error: DatastoreError | null;
413
+ refetch(): Promise<void>;
414
+ };
415
+
366
416
  export function useI18n(): {
367
417
  locale: string;
368
418
  t(key: string, fallback?: string): string;
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@ export {
10
10
  DatastoreError,
11
11
  PaymentError,
12
12
  useDatastoreQuery,
13
+ useDatastoreRecord,
14
+ useFile,
13
15
  useDatastoreMutation,
14
16
  useDirectory,
15
17
  useWidgetEvent,
@@ -18,6 +20,8 @@ export {
18
20
  useI18n,
19
21
  useUser,
20
22
  useNavigation,
23
+ useChildRenderer,
24
+ WidgetTree,
21
25
  } from "./hooks.js";
22
26
  export {
23
27
  Text,
@@ -10,6 +10,8 @@ export {
10
10
  DatastoreError,
11
11
  PaymentError,
12
12
  useDatastoreQuery,
13
+ useDatastoreRecord,
14
+ useFile,
13
15
  useDatastoreMutation,
14
16
  useDirectory,
15
17
  useWidgetEvent,
@@ -18,6 +20,8 @@ export {
18
20
  useI18n,
19
21
  useUser,
20
22
  useNavigation,
23
+ useChildRenderer,
24
+ WidgetTree,
21
25
  } from "./hooks.js";
22
26
  export {
23
27
  Text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.13.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",