@colixsystems/widget-sdk 0.19.0 → 0.22.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 +55 -3
- package/dist/contract.cjs +100 -4
- package/dist/contract.js +100 -4
- package/dist/hooks.js +51 -3
- package/dist/index.d.ts +44 -0
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/dist/manifest.cjs +82 -0
- package/dist/manifest.js +82 -0
- package/dist/property-schema.js +25 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,9 +22,10 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
22
22
|
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
|
|
23
23
|
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
24
24
|
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
25
|
+
| **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`. |
|
|
25
26
|
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
|
|
26
27
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` (falls back to a CustomEvent / console) — no scope |
|
|
27
|
-
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope |
|
|
28
|
+
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) then falls back to the raw key. |
|
|
28
29
|
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options?)` | `{ data, loading, error, refetch }` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
29
30
|
| **DATASTORE** | `useDatastoreRecord(table, id)` | `{ data, loading, error, refetch }` | `records(table).get` — `datastore.read:<table>` |
|
|
30
31
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
@@ -44,9 +45,44 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
44
45
|
|
|
45
46
|
## Status
|
|
46
47
|
|
|
47
|
-
`v0.
|
|
48
|
+
`v0.22.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**.
|
|
48
49
|
|
|
49
|
-
### What's new in 0.
|
|
50
|
+
### What's new in 0.22.0
|
|
51
|
+
|
|
52
|
+
**New `valueRef` propertySchema type — bind a widget to a single value in the datastore (REQ-WDG-VALUEREF).**
|
|
53
|
+
|
|
54
|
+
- **`valueRef`** is a composite picker: the Studio Properties Panel renders three cascading dropdowns — pick a **table**, then a **record**, then a **column** — and the bound widget resolves the one cell. It is the discoverable replacement for hand-typing a `tableRef` + a raw record-id `string` + a `columnRef` separately (the old Field Value shape).
|
|
55
|
+
- **Persisted value is an object** `{ tableId, recordId, column }` (new `ValueRefBinding` type), unlike every other ref type, which is a bare string. A widget reads it with `useDatastoreRecord(value.tableId, value.recordId)` then `record[value.column]`; any missing piece means "no value".
|
|
56
|
+
- **tenant-copy** remaps `tableId` to the copied table and **nulls `recordId`** (records are business data and are never copied), so a copied workspace shows the widget's fallback until the new operator re-picks a record. `column` (a name) is preserved verbatim.
|
|
57
|
+
- The built-in **Field Value** widget now uses `valueRef` (back-compatible: already-saved Field Value widgets that stored `tableId`/`recordId`/`column` as separate props keep rendering).
|
|
58
|
+
- **`CONTRACT.version` → `1.12.0`** (additive: one new optional propertySchema type). No existing export or type changed signature.
|
|
59
|
+
|
|
60
|
+
### What's new in 0.21.1
|
|
61
|
+
|
|
62
|
+
**Default theme tokens corrected to the product's advertised brand (fix).**
|
|
63
|
+
|
|
64
|
+
- **`themeTokens.colors.primary` → `#3b82f6` (blue), `colors.secondary` → `#10b981` (green).** Previously these defaulted to a stale coral/slate that no other surface used — the Theme Settings tab and the Player chrome already advertised the blue/green default. A tenant that never customised its theme therefore rendered widgets (`useTheme().colors.primary`) in coral until a first save persisted the blue, a visible divergence between the unsaved and saved-defaults render. No export, signature, type, or token shape changed — default values only.
|
|
65
|
+
- **`CONTRACT.version` → `1.11.1`.** Patch: a default-value fix; the documented contract (token names + shape) is unchanged.
|
|
66
|
+
|
|
67
|
+
### What's new in 0.21.0
|
|
68
|
+
|
|
69
|
+
**Widgets can fill their page-grid tile's height (REQ-LAY-08).**
|
|
70
|
+
|
|
71
|
+
- **New `useFill()` hook.** Returns a `boolean` — `true` when the host has sized this widget to fill the available height of its layout slot (a page-grid tile whose author chose "Fill tile height", or a widget type that fills by default: the layout containers + the media widgets Image / Chart / Map / Video). A widget that has a meaningful filled form switches to a stretch layout (`flex: 1` / `height: "100%"`) when it reads `true`; others ignore it. Defaults to `false`, so calling it is always safe.
|
|
72
|
+
- **New optional `WidgetContext.fill` slice** backs the hook. It is optional (defaults `false`), so existing hosts and widgets are unaffected. The web Player host and the native export host inject the SAME value, so a widget's fill behaviour is identical on both platforms.
|
|
73
|
+
- **`CONTRACT.version` → `1.11.0`** (additive: one new hook + one new optional context slice). No existing export changed signature.
|
|
74
|
+
|
|
75
|
+
### What's new in 0.20.0
|
|
76
|
+
|
|
77
|
+
**Widgets can ship their own translations (REQ-L10N-WIDGET).**
|
|
78
|
+
|
|
79
|
+
- **New optional `manifest.translations` field.** Shape `{ <key>: { en: string, <locale>?: string } }` — `en` is required per key; additional locales are optional. `validateManifest` structurally validates it (≤100 keys, key matches `/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`, value ≤1 KB, namespaced key ≤128 chars); the marketplace analyzer enforces the same caps.
|
|
80
|
+
- **`useI18n().t(key)` now auto-namespaces.** The host derives a per-widget prefix from the widget id and resolves `widget.<id>.<key>` first, then falls back to the raw key (so shared app keys and pre-1.10 widgets are unaffected). Authors call `t("greeting")` and never type the prefix — the same behaviour on web and in the exported native app (both hosts inject `ctx.widget.id`).
|
|
81
|
+
- **Install-time seeding.** When a widget is installed, the host merges its `translations` into the tenant's localization dictionary under that namespace — non-destructively (it never overwrites an admin's edit, never creates a language the tenant didn't add, and seeds the tenant's base language from the widget's `en` so every key renders). Keys persist across uninstalls; admins prune them with the Translations screen's bulk delete (by id or by `widget.<id>.` prefix).
|
|
82
|
+
- **New exported helpers** `widgetTranslationPrefix(id)` / `widgetTranslationKey(id, key)` (from `@colixsystems/widget-sdk/contract`) are the single source of the key format, shared by `useI18n` and the host seeder.
|
|
83
|
+
- **`CONTRACT.version` → `1.10.0`** (additive: one new optional manifest field + the `useI18n` namespacing behaviour). No existing export changed signature.
|
|
84
|
+
|
|
85
|
+
### What was in 0.19.0
|
|
50
86
|
|
|
51
87
|
**The data layer splits into four injected domain clients; the SDK becomes core-only.**
|
|
52
88
|
|
|
@@ -201,6 +237,22 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
201
237
|
- `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`). 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. `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.
|
|
202
238
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
203
239
|
|
|
240
|
+
## Design & visual polish
|
|
241
|
+
|
|
242
|
+
A widget that works but looks unfinished is only half done. `useTheme()` is the styling contract — compose **with** it rather than just reading from it. This is the same guidance the AI Widget Builder follows when it generates a widget.
|
|
243
|
+
|
|
244
|
+
**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
|
|
245
|
+
|
|
246
|
+
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners. Don't hardcode raw pixel values.
|
|
247
|
+
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
248
|
+
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.md` + a `colors.border` hairline or a subtle shadow. Use the status roles (`danger / success / warning / info`) for state.
|
|
249
|
+
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
250
|
+
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme.
|
|
251
|
+
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useFile()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
252
|
+
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
253
|
+
|
|
254
|
+
**Honest ceilings:** the styling surface is React Native style objects, not full CSS. There are no per-widget gradients, no custom CSS keyframe animations or `transition` strings, and shadows are limited to the five elevation presets (`none / sm / md / lg / xl`). Aim for clean, confident, professional polish within those bounds.
|
|
255
|
+
|
|
204
256
|
## Managing app users from a widget
|
|
205
257
|
|
|
206
258
|
`useUsers()` and `useGroups()` let a widget invite, deactivate, reactivate, and remove members, plus create / delete groups and add / remove their members. Two gates apply: the manifest must declare the scope (the static analyzer + the host's signed `X-Widget-Scopes` header agree), and the calling APP_USER must hold the matching `users.*` / `groups.*` capability (a SystemAcl grant the Studio admin issues via the Roles UI). A widget that declares `users.write:*` but whose caller lacks the grant gets a `DirectoryError` with `code: 'FORBIDDEN'` — surface that as a "you do not have permission" message.
|
package/dist/contract.cjs
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
12
12
|
colors: Object.freeze({
|
|
13
|
-
primary: "#
|
|
13
|
+
primary: "#3b82f6",
|
|
14
14
|
onPrimary: "#ffffff",
|
|
15
|
-
secondary: "#
|
|
15
|
+
secondary: "#10b981",
|
|
16
16
|
onSecondary: "#ffffff",
|
|
17
17
|
surface: "#ffffff",
|
|
18
18
|
onSurface: "#111827",
|
|
@@ -70,6 +70,26 @@ const HOOKS = [
|
|
|
70
70
|
requiredContextSlice: ["user"],
|
|
71
71
|
scopes: null,
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
name: "useFill",
|
|
75
|
+
signature: "useFill()",
|
|
76
|
+
description:
|
|
77
|
+
"Returns true when the host has sized this widget to fill its layout " +
|
|
78
|
+
'slot\'s available height — a page-grid tile whose author chose "Fill ' +
|
|
79
|
+
'tile height", or a widget type that fills by default (containers and ' +
|
|
80
|
+
"media). When true, a widget that has a meaningful filled form (Image, " +
|
|
81
|
+
"Chart, Map, Video) should switch from its intrinsic height to a stretch " +
|
|
82
|
+
'style (flex: 1 / height: "100%"); widgets with no useful filled form ' +
|
|
83
|
+
"may ignore it. Defaults to false wherever the host has not opted the " +
|
|
84
|
+
"widget into filling, so calling it is always safe. The SAME value is " +
|
|
85
|
+
"injected by the web Player and the native export (CLAUDE.md §3), so a " +
|
|
86
|
+
"widget's fill behaviour is identical on both platforms.",
|
|
87
|
+
returnShape: {
|
|
88
|
+
"(returns)": "boolean",
|
|
89
|
+
},
|
|
90
|
+
requiredContextSlice: ["fill"],
|
|
91
|
+
scopes: null,
|
|
92
|
+
},
|
|
73
93
|
{
|
|
74
94
|
name: "useChildRenderer",
|
|
75
95
|
signature: "useChildRenderer()",
|
|
@@ -579,6 +599,13 @@ const MANIFEST_SCHEMA = {
|
|
|
579
599
|
" — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
|
|
580
600
|
default: [],
|
|
581
601
|
},
|
|
602
|
+
translations: {
|
|
603
|
+
type: "object",
|
|
604
|
+
required: false,
|
|
605
|
+
description:
|
|
606
|
+
"Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤100 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
|
|
607
|
+
default: {},
|
|
608
|
+
},
|
|
582
609
|
};
|
|
583
610
|
|
|
584
611
|
const WIDGET_CONTEXT_SHAPE = {
|
|
@@ -594,6 +621,19 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
594
621
|
required: true,
|
|
595
622
|
fields: { id: "manifest.id", version: "manifest.version" },
|
|
596
623
|
},
|
|
624
|
+
// REQ-LAY-08 — optional host layout hint backing useFill(). True when the
|
|
625
|
+
// host sized this widget to fill its layout slot's available height (a
|
|
626
|
+
// page-grid tile set to "Fill tile height", or a default-fill widget type).
|
|
627
|
+
// Optional: a host that never fills a widget simply omits it and useFill()
|
|
628
|
+
// returns false, so existing hosts need no change.
|
|
629
|
+
fill: {
|
|
630
|
+
description:
|
|
631
|
+
"Optional host layout hint (boolean). True when the host sized this " +
|
|
632
|
+
"widget to fill its layout slot's available height (page-grid tile " +
|
|
633
|
+
"fill). Backs useFill(); defaults to false when absent.",
|
|
634
|
+
required: false,
|
|
635
|
+
fields: {},
|
|
636
|
+
},
|
|
597
637
|
user: {
|
|
598
638
|
description:
|
|
599
639
|
"Signed-in user, host-provided VERBATIM (snake_case: { id, email, display_name, roles, group_ids }). Not a data-client.",
|
|
@@ -901,6 +941,20 @@ function deepFreeze(value) {
|
|
|
901
941
|
return Object.freeze(value);
|
|
902
942
|
}
|
|
903
943
|
|
|
944
|
+
// REQ-L10N-WIDGET — a widget's manifest `translations` are merged into the
|
|
945
|
+
// tenant dictionary under a per-widget namespace so they never collide with
|
|
946
|
+
// app keys or another widget's keys. The prefix is DERIVED from the widget id
|
|
947
|
+
// (it "follows the widget"), so authors call `t("greeting")` and the host
|
|
948
|
+
// resolves `widget.<id>.greeting`. This is the ONE definition of the key
|
|
949
|
+
// format: the SDK `useI18n` hook (lookup) and the backend seeder (write) both
|
|
950
|
+
// call it, so the wire key and the lookup key can never drift.
|
|
951
|
+
function widgetTranslationPrefix(id) {
|
|
952
|
+
return `widget.${id}.`;
|
|
953
|
+
}
|
|
954
|
+
function widgetTranslationKey(id, key) {
|
|
955
|
+
return `widget.${id}.${key}`;
|
|
956
|
+
}
|
|
957
|
+
|
|
904
958
|
const CONTRACT = deepFreeze({
|
|
905
959
|
// REQ-WSDK-PLATFORM bump:
|
|
906
960
|
// - `vettedImports` is a new field (rich allowlist with platforms +
|
|
@@ -952,7 +1006,43 @@ const CONTRACT = deepFreeze({
|
|
|
952
1006
|
// snake_case. Hooks now unwrap the list envelope (res.data ?? []). The
|
|
953
1007
|
// SDK stays duck-typed — it imports none of the four data SDKs. Minor
|
|
954
1008
|
// bump on the contract's pre-1.0 versioning (the breaking channel).
|
|
955
|
-
|
|
1009
|
+
//
|
|
1010
|
+
// 1.10.0: additive (REQ-L10N-WIDGET) — manifests may declare an optional
|
|
1011
|
+
// `translations` map ({ <key>: { en, <locale>? } }, en required). The
|
|
1012
|
+
// host seeds them into the tenant l10n dictionary under a per-widget
|
|
1013
|
+
// namespace at install; `useI18n().t(key)` now auto-prefixes the lookup
|
|
1014
|
+
// with `widget.<ctx.widget.id>.` (then falls back to the raw key, so
|
|
1015
|
+
// existing widgets and shared app keys keep resolving). New exported
|
|
1016
|
+
// helpers widgetTranslationPrefix / widgetTranslationKey are the single
|
|
1017
|
+
// source of the key format, shared by the hook and the backend seeder.
|
|
1018
|
+
//
|
|
1019
|
+
// 1.11.0: additive (REQ-LAY-08) — new useFill() hook + the optional `fill`
|
|
1020
|
+
// host-context slice it reads. The host sets ctx.fill=true when it has
|
|
1021
|
+
// sized a widget to fill its layout slot's available height (a page-grid
|
|
1022
|
+
// tile whose author chose "Fill tile height", or a default-fill widget
|
|
1023
|
+
// type — containers + media). Widgets that can stretch (Image, Chart,
|
|
1024
|
+
// Map, Video) switch to flex:1/height:"100%" when useFill() is true; all
|
|
1025
|
+
// others ignore it. The slice is OPTIONAL (defaults false) so existing
|
|
1026
|
+
// hosts need no change, and the same value is injected on web and native
|
|
1027
|
+
// so fill behaviour cannot diverge between the Player and the export.
|
|
1028
|
+
//
|
|
1029
|
+
// 1.11.1: fix — the canonical default `themeTokens.colors` now match the
|
|
1030
|
+
// product's advertised default brand (primary #3b82f6 blue, secondary
|
|
1031
|
+
// #10b981 green) instead of the stale coral/slate that no other surface
|
|
1032
|
+
// used. The Theme Settings tab and Player chrome already defaulted to
|
|
1033
|
+
// these values, so a tenant that never customised its theme rendered
|
|
1034
|
+
// widgets in coral until a first save persisted the blue — a divergence
|
|
1035
|
+
// between the unsaved and saved-defaults render. Aligning the canonical
|
|
1036
|
+
// tokens removes it (no shape/signature change — default-value fix only).
|
|
1037
|
+
//
|
|
1038
|
+
// 1.12.0: additive (REQ-WDG-VALUEREF) — new `valueRef` propertySchema type.
|
|
1039
|
+
// A composite "single value from the datastore" picker whose persisted
|
|
1040
|
+
// value is an object `{ tableId, recordId, column }` (every other ref
|
|
1041
|
+
// type is a bare string). The Studio Properties Panel renders three
|
|
1042
|
+
// cascading dropdowns; tenant-copy remaps `tableId` and nulls `recordId`
|
|
1043
|
+
// (records are business data, never copied). No existing type changed
|
|
1044
|
+
// shape, so this is additive — minor bump on the pre-1.0 channel.
|
|
1045
|
+
version: "1.12.0",
|
|
956
1046
|
hooks: HOOKS,
|
|
957
1047
|
primitives: PRIMITIVES,
|
|
958
1048
|
manifestSchema: MANIFEST_SCHEMA,
|
|
@@ -984,4 +1074,10 @@ function requiredContextKeys() {
|
|
|
984
1074
|
return [...keys];
|
|
985
1075
|
}
|
|
986
1076
|
|
|
987
|
-
module.exports = {
|
|
1077
|
+
module.exports = {
|
|
1078
|
+
CONTRACT,
|
|
1079
|
+
isHookAllowed,
|
|
1080
|
+
requiredContextKeys,
|
|
1081
|
+
widgetTranslationPrefix,
|
|
1082
|
+
widgetTranslationKey,
|
|
1083
|
+
};
|
package/dist/contract.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
12
12
|
colors: Object.freeze({
|
|
13
|
-
primary: "#
|
|
13
|
+
primary: "#3b82f6",
|
|
14
14
|
onPrimary: "#ffffff",
|
|
15
|
-
secondary: "#
|
|
15
|
+
secondary: "#10b981",
|
|
16
16
|
onSecondary: "#ffffff",
|
|
17
17
|
surface: "#ffffff",
|
|
18
18
|
onSurface: "#111827",
|
|
@@ -70,6 +70,26 @@ const HOOKS = [
|
|
|
70
70
|
requiredContextSlice: ["user"],
|
|
71
71
|
scopes: null,
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
name: "useFill",
|
|
75
|
+
signature: "useFill()",
|
|
76
|
+
description:
|
|
77
|
+
"Returns true when the host has sized this widget to fill its layout " +
|
|
78
|
+
'slot\'s available height — a page-grid tile whose author chose "Fill ' +
|
|
79
|
+
'tile height", or a widget type that fills by default (containers and ' +
|
|
80
|
+
"media). When true, a widget that has a meaningful filled form (Image, " +
|
|
81
|
+
"Chart, Map, Video) should switch from its intrinsic height to a stretch " +
|
|
82
|
+
'style (flex: 1 / height: "100%"); widgets with no useful filled form ' +
|
|
83
|
+
"may ignore it. Defaults to false wherever the host has not opted the " +
|
|
84
|
+
"widget into filling, so calling it is always safe. The SAME value is " +
|
|
85
|
+
"injected by the web Player and the native export (CLAUDE.md §3), so a " +
|
|
86
|
+
"widget's fill behaviour is identical on both platforms.",
|
|
87
|
+
returnShape: {
|
|
88
|
+
"(returns)": "boolean",
|
|
89
|
+
},
|
|
90
|
+
requiredContextSlice: ["fill"],
|
|
91
|
+
scopes: null,
|
|
92
|
+
},
|
|
73
93
|
{
|
|
74
94
|
name: "useChildRenderer",
|
|
75
95
|
signature: "useChildRenderer()",
|
|
@@ -580,6 +600,13 @@ const MANIFEST_SCHEMA = {
|
|
|
580
600
|
" — NOT the React surface, so SDK imports/hooks are unavailable) }. Do NOT include triggerTableId or apiKeyId — those are tenant-local and bound after install.",
|
|
581
601
|
default: [],
|
|
582
602
|
},
|
|
603
|
+
translations: {
|
|
604
|
+
type: "object",
|
|
605
|
+
required: false,
|
|
606
|
+
description:
|
|
607
|
+
"Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤100 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
|
|
608
|
+
default: {},
|
|
609
|
+
},
|
|
583
610
|
};
|
|
584
611
|
|
|
585
612
|
const WIDGET_CONTEXT_SHAPE = {
|
|
@@ -594,6 +621,19 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
594
621
|
required: true,
|
|
595
622
|
fields: { id: "manifest.id", version: "manifest.version" },
|
|
596
623
|
},
|
|
624
|
+
// REQ-LAY-08 — optional host layout hint backing useFill(). True when the
|
|
625
|
+
// host sized this widget to fill its layout slot's available height (a
|
|
626
|
+
// page-grid tile set to "Fill tile height", or a default-fill widget type).
|
|
627
|
+
// Optional: a host that never fills a widget simply omits it and useFill()
|
|
628
|
+
// returns false, so existing hosts need no change.
|
|
629
|
+
fill: {
|
|
630
|
+
description:
|
|
631
|
+
"Optional host layout hint (boolean). True when the host sized this " +
|
|
632
|
+
"widget to fill its layout slot's available height (page-grid tile " +
|
|
633
|
+
"fill). Backs useFill(); defaults to false when absent.",
|
|
634
|
+
required: false,
|
|
635
|
+
fields: {},
|
|
636
|
+
},
|
|
597
637
|
user: {
|
|
598
638
|
description:
|
|
599
639
|
"Signed-in user, host-provided VERBATIM (snake_case: { id, email, display_name, roles, group_ids }). Not a data-client.",
|
|
@@ -873,6 +913,20 @@ function deepFreeze(value) {
|
|
|
873
913
|
return Object.freeze(value);
|
|
874
914
|
}
|
|
875
915
|
|
|
916
|
+
// REQ-L10N-WIDGET — a widget's manifest `translations` are merged into the
|
|
917
|
+
// tenant dictionary under a per-widget namespace so they never collide with
|
|
918
|
+
// app keys or another widget's keys. The prefix is DERIVED from the widget id
|
|
919
|
+
// (it "follows the widget"), so authors call `t("greeting")` and the host
|
|
920
|
+
// resolves `widget.<id>.greeting`. This is the ONE definition of the key
|
|
921
|
+
// format: the SDK `useI18n` hook (lookup) and the backend seeder (write) both
|
|
922
|
+
// call it, so the wire key and the lookup key can never drift.
|
|
923
|
+
function widgetTranslationPrefix(id) {
|
|
924
|
+
return `widget.${id}.`;
|
|
925
|
+
}
|
|
926
|
+
function widgetTranslationKey(id, key) {
|
|
927
|
+
return `widget.${id}.${key}`;
|
|
928
|
+
}
|
|
929
|
+
|
|
876
930
|
const CONTRACT = deepFreeze({
|
|
877
931
|
// 1.7.0: additive — new useDatastoreSchema(tableId) hook + the
|
|
878
932
|
// datastore.schema host-context slice it reads (resolves a table's column
|
|
@@ -905,7 +959,43 @@ const CONTRACT = deepFreeze({
|
|
|
905
959
|
// snake_case. Hooks now unwrap the list envelope (res.data ?? []). The
|
|
906
960
|
// SDK stays duck-typed — it imports none of the four data SDKs. Minor
|
|
907
961
|
// bump on the contract's pre-1.0 versioning (the breaking channel).
|
|
908
|
-
|
|
962
|
+
//
|
|
963
|
+
// 1.10.0: additive (REQ-L10N-WIDGET) — manifests may declare an optional
|
|
964
|
+
// `translations` map ({ <key>: { en, <locale>? } }, en required). The
|
|
965
|
+
// host seeds them into the tenant l10n dictionary under a per-widget
|
|
966
|
+
// namespace at install; `useI18n().t(key)` now auto-prefixes the lookup
|
|
967
|
+
// with `widget.<ctx.widget.id>.` (then falls back to the raw key, so
|
|
968
|
+
// existing widgets and shared app keys keep resolving). New exported
|
|
969
|
+
// helpers widgetTranslationPrefix / widgetTranslationKey are the single
|
|
970
|
+
// source of the key format, shared by the hook and the backend seeder.
|
|
971
|
+
//
|
|
972
|
+
// 1.11.0: additive (REQ-LAY-08) — new useFill() hook + the optional `fill`
|
|
973
|
+
// host-context slice it reads. The host sets ctx.fill=true when it has
|
|
974
|
+
// sized a widget to fill its layout slot's available height (a page-grid
|
|
975
|
+
// tile whose author chose "Fill tile height", or a default-fill widget
|
|
976
|
+
// type — containers + media). Widgets that can stretch (Image, Chart,
|
|
977
|
+
// Map, Video) switch to flex:1/height:"100%" when useFill() is true; all
|
|
978
|
+
// others ignore it. The slice is OPTIONAL (defaults false) so existing
|
|
979
|
+
// hosts need no change, and the same value is injected on web and native
|
|
980
|
+
// so fill behaviour cannot diverge between the Player and the export.
|
|
981
|
+
//
|
|
982
|
+
// 1.11.1: fix — the canonical default `themeTokens.colors` now match the
|
|
983
|
+
// product's advertised default brand (primary #3b82f6 blue, secondary
|
|
984
|
+
// #10b981 green) instead of the stale coral/slate that no other surface
|
|
985
|
+
// used. The Theme Settings tab and Player chrome already defaulted to
|
|
986
|
+
// these values, so a tenant that never customised its theme rendered
|
|
987
|
+
// widgets in coral until a first save persisted the blue — a divergence
|
|
988
|
+
// between the unsaved and saved-defaults render. Aligning the canonical
|
|
989
|
+
// tokens removes it (no shape/signature change — default-value fix only).
|
|
990
|
+
//
|
|
991
|
+
// 1.12.0: additive (REQ-WDG-VALUEREF) — new `valueRef` propertySchema type.
|
|
992
|
+
// A composite "single value from the datastore" picker whose persisted
|
|
993
|
+
// value is an object `{ tableId, recordId, column }` (every other ref
|
|
994
|
+
// type is a bare string). The Studio Properties Panel renders three
|
|
995
|
+
// cascading dropdowns; tenant-copy remaps `tableId` and nulls `recordId`
|
|
996
|
+
// (records are business data, never copied). No existing type changed
|
|
997
|
+
// shape, so this is additive — minor bump on the pre-1.0 channel.
|
|
998
|
+
version: "1.12.0",
|
|
909
999
|
hooks: HOOKS,
|
|
910
1000
|
primitives: PRIMITIVES,
|
|
911
1001
|
manifestSchema: MANIFEST_SCHEMA,
|
|
@@ -937,4 +1027,10 @@ function requiredContextKeys() {
|
|
|
937
1027
|
return [...keys];
|
|
938
1028
|
}
|
|
939
1029
|
|
|
940
|
-
export {
|
|
1030
|
+
export {
|
|
1031
|
+
CONTRACT,
|
|
1032
|
+
isHookAllowed,
|
|
1033
|
+
requiredContextKeys,
|
|
1034
|
+
widgetTranslationPrefix,
|
|
1035
|
+
widgetTranslationKey,
|
|
1036
|
+
};
|
package/dist/hooks.js
CHANGED
|
@@ -26,6 +26,10 @@ import React, {
|
|
|
26
26
|
useRef,
|
|
27
27
|
useState,
|
|
28
28
|
} from "react";
|
|
29
|
+
// REQ-L10N-WIDGET: the per-widget translation key format lives in ONE place
|
|
30
|
+
// (contract.js). useI18n (lookup) and the backend seeder (write) both call it
|
|
31
|
+
// so the namespaced key can never drift between the two sides.
|
|
32
|
+
import { widgetTranslationKey } from "./contract.js";
|
|
29
33
|
|
|
30
34
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
31
35
|
const HostWidgetContext = createContext(null);
|
|
@@ -97,6 +101,25 @@ export function useUser() {
|
|
|
97
101
|
return ctx.user;
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Returns `true` when the host has sized this widget to fill the available
|
|
106
|
+
* height of its layout slot — today that means a page-grid tile whose author
|
|
107
|
+
* chose "Fill tile height" (or a widget type that fills by default, like
|
|
108
|
+
* containers and media). When `true`, a widget that has a meaningful filled
|
|
109
|
+
* form (Image, Chart, Map, Video, …) should switch from its intrinsic height
|
|
110
|
+
* to a stretch style (`flex: 1` / `height: "100%"`); widgets with no useful
|
|
111
|
+
* filled form may ignore it. Defaults to `false` everywhere the host has not
|
|
112
|
+
* opted the widget into filling, so calling it is always safe.
|
|
113
|
+
*
|
|
114
|
+
* The SAME value is injected by the web Player host and the native export
|
|
115
|
+
* host (CLAUDE.md §3), so a widget's fill behaviour is identical on both
|
|
116
|
+
* platforms — there is one source file and one `fill` flag driving it.
|
|
117
|
+
*/
|
|
118
|
+
export function useFill() {
|
|
119
|
+
const ctx = useWidgetContextOrThrow("useFill");
|
|
120
|
+
return ctx.fill === true;
|
|
121
|
+
}
|
|
122
|
+
|
|
100
123
|
/**
|
|
101
124
|
* Returns the host's child-node renderer:
|
|
102
125
|
* { renderNode(node) }.
|
|
@@ -166,14 +189,39 @@ export function useI18n() {
|
|
|
166
189
|
const i18n = ctx.i18n || {};
|
|
167
190
|
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
168
191
|
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
192
|
+
// REQ-L10N-WIDGET: the widget's own manifest translations are stored in the
|
|
193
|
+
// tenant dictionary under `widget.<id>.<key>`. Derive the namespace from the
|
|
194
|
+
// host-provided widget id so the author calls `t("greeting")` and never
|
|
195
|
+
// types the prefix — and so the SAME hook gives this behaviour on web and in
|
|
196
|
+
// the exported app (both hosts set ctx.widget.id).
|
|
197
|
+
const widgetId =
|
|
198
|
+
ctx.widget && typeof ctx.widget.id === "string" && ctx.widget.id
|
|
199
|
+
? ctx.widget.id
|
|
200
|
+
: null;
|
|
169
201
|
const t = useCallback(
|
|
170
202
|
(key, fallback) => {
|
|
171
203
|
if (typeof key !== "string" || !key) {
|
|
172
204
|
return typeof fallback === "string" ? fallback : "";
|
|
173
205
|
}
|
|
174
206
|
if (hostT) {
|
|
175
|
-
// Try the
|
|
176
|
-
//
|
|
207
|
+
// 1) Try the widget-namespaced key first. A genuine hit is a string
|
|
208
|
+
// that is neither the bare key nor a `{{t:…}}` placeholder (the
|
|
209
|
+
// miss form both hosts emit). No fallback is passed here so a miss
|
|
210
|
+
// is unambiguous and we can fall through to the raw key.
|
|
211
|
+
if (widgetId) {
|
|
212
|
+
const namespaced = widgetTranslationKey(widgetId, key);
|
|
213
|
+
const scoped = hostT(namespaced);
|
|
214
|
+
if (
|
|
215
|
+
typeof scoped === "string" &&
|
|
216
|
+
scoped.length > 0 &&
|
|
217
|
+
scoped !== namespaced &&
|
|
218
|
+
!scoped.startsWith("{{t:")
|
|
219
|
+
) {
|
|
220
|
+
return scoped;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// 2) Fall back to the raw key (shared app keys + widgets with no
|
|
224
|
+
// manifest translations — unchanged from pre-1.10 behaviour).
|
|
177
225
|
const out = hostT(key, fallback);
|
|
178
226
|
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
179
227
|
return out;
|
|
@@ -183,7 +231,7 @@ export function useI18n() {
|
|
|
183
231
|
}
|
|
184
232
|
return typeof fallback === "string" ? fallback : key;
|
|
185
233
|
},
|
|
186
|
-
[hostT],
|
|
234
|
+
[hostT, widgetId],
|
|
187
235
|
);
|
|
188
236
|
return { t, locale };
|
|
189
237
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export type WidgetPropertyType =
|
|
|
29
29
|
| "tableRef"
|
|
30
30
|
| "columnRef"
|
|
31
31
|
| "recordBinding"
|
|
32
|
+
// REQ-WDG-VALUEREF: composite "single value from the datastore" picker.
|
|
33
|
+
// Persisted value is `{ tableId, recordId, column }`; the bound widget
|
|
34
|
+
// resolves the one cell.
|
|
35
|
+
| "valueRef"
|
|
32
36
|
// REQ-USERMGMT M4 / §4.8: Group picker that emits a bare
|
|
33
37
|
// AppUserGroup UUID into the page JSON.
|
|
34
38
|
| "groupRef"
|
|
@@ -56,6 +60,17 @@ export interface WidgetPropertyDef {
|
|
|
56
60
|
|
|
57
61
|
export type WidgetPropertySchema = Record<string, WidgetPropertyDef>;
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* REQ-WDG-VALUEREF: the persisted value of a `valueRef` property. Each
|
|
65
|
+
* field is optional while the author is mid-pick; a widget treats any
|
|
66
|
+
* missing piece as "no value".
|
|
67
|
+
*/
|
|
68
|
+
export interface ValueRefBinding {
|
|
69
|
+
tableId?: string;
|
|
70
|
+
recordId?: string;
|
|
71
|
+
column?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
export interface WidgetEventDescriptor {
|
|
60
75
|
name: string;
|
|
61
76
|
description?: string;
|
|
@@ -184,6 +199,17 @@ export interface WidgetManifest {
|
|
|
184
199
|
* isolated-vm action runner. See `WidgetManifestAction`.
|
|
185
200
|
*/
|
|
186
201
|
actions?: WidgetManifestAction[];
|
|
202
|
+
/**
|
|
203
|
+
* Optional translation strings the widget ships (REQ-L10N-WIDGET). Maps a
|
|
204
|
+
* relative key to its per-locale strings; `en` is required per key. At
|
|
205
|
+
* install the host merges these into the tenant's localization dictionary
|
|
206
|
+
* under a per-widget namespace (`widget.<id>.<key>`), so `useI18n().t(key)`
|
|
207
|
+
* resolves the namespaced key automatically — the author never types the
|
|
208
|
+
* prefix. Non-destructive (admin edits win; absent languages are not
|
|
209
|
+
* created) and persists across uninstalls. Caps: ≤100 keys, key matches
|
|
210
|
+
* /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
|
|
211
|
+
*/
|
|
212
|
+
translations?: Record<string, { en: string } & Record<string, string>>;
|
|
187
213
|
}
|
|
188
214
|
|
|
189
215
|
export interface ThemeTokens {
|
|
@@ -314,6 +340,13 @@ export interface PaymentsClient {
|
|
|
314
340
|
export interface WidgetContext<TProps = unknown> {
|
|
315
341
|
props: TProps;
|
|
316
342
|
widget: { id: string; instanceId: string; version: string };
|
|
343
|
+
/**
|
|
344
|
+
* REQ-LAY-08 — optional host layout hint backing `useFill()`. `true` when
|
|
345
|
+
* the host sized this widget to fill its layout slot's available height (a
|
|
346
|
+
* page-grid tile set to "Fill tile height", or a default-fill widget type).
|
|
347
|
+
* Absent / `false` everywhere the host has not opted the widget into filling.
|
|
348
|
+
*/
|
|
349
|
+
fill?: boolean;
|
|
317
350
|
/** Active end-user identity, snake_case verbatim. `id` is null when anonymous. */
|
|
318
351
|
user: {
|
|
319
352
|
id: string | null;
|
|
@@ -645,6 +678,17 @@ export function useUser(): {
|
|
|
645
678
|
group_ids: string[];
|
|
646
679
|
};
|
|
647
680
|
|
|
681
|
+
/**
|
|
682
|
+
* REQ-LAY-08 — returns `true` when the host has sized this widget to fill its
|
|
683
|
+
* layout slot's available height (a page-grid tile set to "Fill tile height",
|
|
684
|
+
* or a default-fill widget type — containers + media). Widgets that can
|
|
685
|
+
* stretch (Image, Chart, Map, Video, …) should switch to a fill style
|
|
686
|
+
* (`flex: 1` / `height: "100%"`) when this is `true`; others may ignore it.
|
|
687
|
+
* Defaults to `false`, so calling it is always safe, and the same value is
|
|
688
|
+
* injected on web and native so fill behaviour is identical on both platforms.
|
|
689
|
+
*/
|
|
690
|
+
export function useFill(): boolean;
|
|
691
|
+
|
|
648
692
|
/**
|
|
649
693
|
* The host-provided navigation surface. `goTo(pageId, params?)` navigates
|
|
650
694
|
* to an internal app page; `goBack()` pops the stack. Missing methods
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
package/dist/manifest.cjs
CHANGED
|
@@ -24,10 +24,86 @@ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
|
|
|
24
24
|
const ACTION_TIMEOUT_MIN_MS = 100;
|
|
25
25
|
const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
|
+
// REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
|
|
28
|
+
// The relative key pattern is deliberately tighter than the dictionary's
|
|
29
|
+
// own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
|
|
30
|
+
// must still satisfy the dictionary cap (128 chars / KEY_RE in
|
|
31
|
+
// backend/src/core/services/l10n.service.js). The locale pattern mirrors that
|
|
32
|
+
// service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
|
|
33
|
+
// would reject.
|
|
34
|
+
const TRANSLATIONS_MAX_KEYS = 100;
|
|
35
|
+
const TRANSLATION_VALUE_MAX_BYTES = 1024;
|
|
36
|
+
const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
37
|
+
const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
|
|
38
|
+
const TRANSLATION_FULL_KEY_MAX = 128;
|
|
39
|
+
|
|
27
40
|
function utf8ByteLength(s) {
|
|
28
41
|
return new TextEncoder().encode(s).length;
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
function validateManifestTranslations(translations, manifestId, errors) {
|
|
45
|
+
if (
|
|
46
|
+
translations === null ||
|
|
47
|
+
typeof translations !== "object" ||
|
|
48
|
+
Array.isArray(translations)
|
|
49
|
+
) {
|
|
50
|
+
errors.push(
|
|
51
|
+
"manifest.translations must be an object mapping key -> { en, <locale>? }",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const keys = Object.keys(translations);
|
|
56
|
+
if (keys.length > TRANSLATIONS_MAX_KEYS) {
|
|
57
|
+
errors.push(
|
|
58
|
+
`manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
if (!TRANSLATION_KEY_RE.test(key)) {
|
|
63
|
+
errors.push(
|
|
64
|
+
`manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isNonEmptyString(manifestId)) {
|
|
69
|
+
const fullKey = `widget.${manifestId}.${key}`;
|
|
70
|
+
if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
|
|
71
|
+
errors.push(
|
|
72
|
+
`manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const entry = translations[key];
|
|
77
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
78
|
+
errors.push(
|
|
79
|
+
`manifest.translations["${key}"] must be an object of locale -> string`,
|
|
80
|
+
);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!isNonEmptyString(entry.en)) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`manifest.translations["${key}"].en is required and must be a non-empty string`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
for (const [locale, value] of Object.entries(entry)) {
|
|
89
|
+
if (!TRANSLATION_LOCALE_RE.test(locale)) {
|
|
90
|
+
errors.push(
|
|
91
|
+
`manifest.translations["${key}"] has invalid locale "${locale}"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (typeof value !== "string") {
|
|
95
|
+
errors.push(
|
|
96
|
+
`manifest.translations["${key}"]["${locale}"] must be a string`,
|
|
97
|
+
);
|
|
98
|
+
} else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
|
|
99
|
+
errors.push(
|
|
100
|
+
`manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
31
107
|
function validateManifestActions(actions, errors) {
|
|
32
108
|
if (!Array.isArray(actions)) {
|
|
33
109
|
errors.push("manifest.actions must be an array (omit it or use [] for none)");
|
|
@@ -208,10 +284,16 @@ function validateManifest(m) {
|
|
|
208
284
|
validateManifestActions(manifest.actions, errors);
|
|
209
285
|
}
|
|
210
286
|
|
|
287
|
+
// `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
|
|
288
|
+
if (manifest.translations !== undefined) {
|
|
289
|
+
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
290
|
+
}
|
|
291
|
+
|
|
211
292
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
212
293
|
}
|
|
213
294
|
|
|
214
295
|
module.exports = {
|
|
215
296
|
validateManifest,
|
|
297
|
+
validateManifestTranslations,
|
|
216
298
|
canonicalCategory,
|
|
217
299
|
};
|
package/dist/manifest.js
CHANGED
|
@@ -24,10 +24,86 @@ const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
|
|
|
24
24
|
const ACTION_TIMEOUT_MIN_MS = 100;
|
|
25
25
|
const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
|
|
26
26
|
|
|
27
|
+
// REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
|
|
28
|
+
// The relative key pattern is deliberately tighter than the dictionary's
|
|
29
|
+
// own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
|
|
30
|
+
// must still satisfy the dictionary cap (128 chars / KEY_RE in
|
|
31
|
+
// backend/src/core/services/l10n.service.js). The locale pattern mirrors that
|
|
32
|
+
// service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
|
|
33
|
+
// would reject.
|
|
34
|
+
const TRANSLATIONS_MAX_KEYS = 100;
|
|
35
|
+
const TRANSLATION_VALUE_MAX_BYTES = 1024;
|
|
36
|
+
const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
37
|
+
const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
|
|
38
|
+
const TRANSLATION_FULL_KEY_MAX = 128;
|
|
39
|
+
|
|
27
40
|
function utf8ByteLength(s) {
|
|
28
41
|
return new TextEncoder().encode(s).length;
|
|
29
42
|
}
|
|
30
43
|
|
|
44
|
+
function validateManifestTranslations(translations, manifestId, errors) {
|
|
45
|
+
if (
|
|
46
|
+
translations === null ||
|
|
47
|
+
typeof translations !== "object" ||
|
|
48
|
+
Array.isArray(translations)
|
|
49
|
+
) {
|
|
50
|
+
errors.push(
|
|
51
|
+
"manifest.translations must be an object mapping key -> { en, <locale>? }",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const keys = Object.keys(translations);
|
|
56
|
+
if (keys.length > TRANSLATIONS_MAX_KEYS) {
|
|
57
|
+
errors.push(
|
|
58
|
+
`manifest.translations may declare at most ${TRANSLATIONS_MAX_KEYS} keys`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
if (!TRANSLATION_KEY_RE.test(key)) {
|
|
63
|
+
errors.push(
|
|
64
|
+
`manifest.translations key "${key}" must match /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/`,
|
|
65
|
+
);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isNonEmptyString(manifestId)) {
|
|
69
|
+
const fullKey = `widget.${manifestId}.${key}`;
|
|
70
|
+
if (fullKey.length > TRANSLATION_FULL_KEY_MAX) {
|
|
71
|
+
errors.push(
|
|
72
|
+
`manifest.translations key "${key}" is too long once namespaced (${fullKey.length} > ${TRANSLATION_FULL_KEY_MAX} chars)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const entry = translations[key];
|
|
77
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
78
|
+
errors.push(
|
|
79
|
+
`manifest.translations["${key}"] must be an object of locale -> string`,
|
|
80
|
+
);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!isNonEmptyString(entry.en)) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`manifest.translations["${key}"].en is required and must be a non-empty string`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
for (const [locale, value] of Object.entries(entry)) {
|
|
89
|
+
if (!TRANSLATION_LOCALE_RE.test(locale)) {
|
|
90
|
+
errors.push(
|
|
91
|
+
`manifest.translations["${key}"] has invalid locale "${locale}"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (typeof value !== "string") {
|
|
95
|
+
errors.push(
|
|
96
|
+
`manifest.translations["${key}"]["${locale}"] must be a string`,
|
|
97
|
+
);
|
|
98
|
+
} else if (utf8ByteLength(value) > TRANSLATION_VALUE_MAX_BYTES) {
|
|
99
|
+
errors.push(
|
|
100
|
+
`manifest.translations["${key}"]["${locale}"] exceeds 1 KB`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
31
107
|
function validateManifestActions(actions, errors) {
|
|
32
108
|
if (!Array.isArray(actions)) {
|
|
33
109
|
errors.push("manifest.actions must be an array (omit it or use [] for none)");
|
|
@@ -208,10 +284,16 @@ function validateManifest(m) {
|
|
|
208
284
|
validateManifestActions(manifest.actions, errors);
|
|
209
285
|
}
|
|
210
286
|
|
|
287
|
+
// `translations` is optional (additive in SDK 1.10.0 / REQ-L10N-WIDGET).
|
|
288
|
+
if (manifest.translations !== undefined) {
|
|
289
|
+
validateManifestTranslations(manifest.translations, manifest.id, errors);
|
|
290
|
+
}
|
|
291
|
+
|
|
211
292
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
212
293
|
}
|
|
213
294
|
|
|
214
295
|
export {
|
|
215
296
|
validateManifest,
|
|
297
|
+
validateManifestTranslations,
|
|
216
298
|
canonicalCategory,
|
|
217
299
|
};
|
package/dist/property-schema.js
CHANGED
|
@@ -6,6 +6,14 @@ const VALID_TYPES = new Set([
|
|
|
6
6
|
"color", "icon", "image",
|
|
7
7
|
"select", "multiselect",
|
|
8
8
|
"tableRef", "columnRef", "recordBinding",
|
|
9
|
+
// REQ-WDG-VALUEREF: `valueRef` is a composite "single value from the
|
|
10
|
+
// datastore" picker. Unlike the bare-string refs above, its persisted
|
|
11
|
+
// value is an OBJECT `{ tableId, recordId, column }` — the author picks a
|
|
12
|
+
// table, then a record, then a column, and the bound widget resolves the
|
|
13
|
+
// one cell. The Studio Properties Panel renders three cascading dropdowns
|
|
14
|
+
// (`ValueRefEditor`). tenant-copy remaps `tableId` and NULLS `recordId`
|
|
15
|
+
// (records are business data, never copied) — see tenant-copy.service.js.
|
|
16
|
+
"valueRef",
|
|
9
17
|
// REQ-USERMGMT M4 / §4.8: `groupRef` is a Group picker that emits a bare
|
|
10
18
|
// AppUserGroup UUID into the page JSON. Renders via `GroupSelector` in
|
|
11
19
|
// the Studio Properties Panel. REQ-GEN-07 compliance: no typed UUIDs —
|
|
@@ -134,6 +142,23 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
134
142
|
return value;
|
|
135
143
|
}
|
|
136
144
|
return value.map((item, i) => coerceLeaf(def.items, item, `${path}[${i}]`, errors));
|
|
145
|
+
case "valueRef": {
|
|
146
|
+
// REQ-WDG-VALUEREF: a `{ tableId, recordId, column }` binding. Each
|
|
147
|
+
// sub-field is an optional string (a half-configured binding is valid
|
|
148
|
+
// while the author is still picking); the bound widget treats any
|
|
149
|
+
// missing piece as "no value" and shows its fallback.
|
|
150
|
+
if (!isPlainObject(value)) {
|
|
151
|
+
errors.push(`${path}: expected object`);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
for (const sub of ["tableId", "recordId", "column"]) {
|
|
155
|
+
const v = value[sub];
|
|
156
|
+
if (v !== undefined && v !== null && typeof v !== "string") {
|
|
157
|
+
errors.push(`${path}.${sub}: expected string`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
137
162
|
case "object": {
|
|
138
163
|
if (!isPlainObject(value)) {
|
|
139
164
|
errors.push(`${path}: expected object`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "node scripts/build.js",
|
|
38
|
-
"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-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js"
|
|
38
|
+
"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-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/linter-users-scope.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=18"
|