@colixsystems/widget-sdk 0.289.0 → 0.291.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 +12 -1
- package/dist/contract.cjs +55 -1
- package/dist/contract.js +55 -1
- package/dist/hooks.js +109 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +85 -0
- package/dist/linter.js +85 -0
- package/dist/property-schema.js +4 -0
- package/package.json +1 -1
- package/src/contract.js +55 -1
package/README.md
CHANGED
|
@@ -90,7 +90,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
90
90
|
|
|
91
91
|
## Status
|
|
92
92
|
|
|
93
|
-
`v0.
|
|
93
|
+
`v0.291.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**.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.291.0 (contract 1.238.0)
|
|
96
|
+
|
|
97
|
+
**New linter rule `grid-cell-too-narrow` (severity `error`, blocking).** A `<Grid minCellWidth={N}>` with `N` under 200 whose cell holds more than one `Text` is refused, naming the cell width and the default of 240: on cards carrying a title, a duration and a price it makes five narrow columns at desktop width, with broken words and the price wrapped onto two lines. A cell of a single `Text` (a chip, an avatar, an icon tile) passes at any width, and a Grid without `minCellWidth` passes. A width given as a `const` is followed; one computed at runtime is beyond the scan. See *Design & visual polish*.
|
|
98
|
+
|
|
99
|
+
### What's new in 0.290.0 (contract 1.238.0)
|
|
100
|
+
|
|
101
|
+
**New `useConnector(connectorRef)` hook — a widget can call one of the workspace's REST connectors and use the answer itself.** Bind a connector with a `{ type: "connectorRef" }` property and declare `connectors.call:*` in `requestedScopes`. `call(operation, { params, query, body })` names an operation the workspace listed for apps on that connector — never a method or a path — and resolves `{ status, content_type, body }` with the outside API's status and parsed body. The whole call has 250 ms, token exchange included: a slower outside API is cut off and the call rejects with `UPSTREAM_TIMEOUT`. The credential is added on the server and never reaches the app. A new `connectors` host client (`createConnectorsClient` in `@colixsystems/actions-client`), the `connectorRef` property type, the `connectors.call:*` scope and a `ConnectorError`. Everything is additive.
|
|
94
102
|
|
|
95
103
|
### What's new in 0.289.0 (contract 1.237.0)
|
|
96
104
|
|
|
@@ -3488,6 +3496,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
3488
3496
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
3489
3497
|
- **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
|
|
3490
3498
|
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers**: `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
|
|
3499
|
+
- **A Grid cell that holds more than one `Text` is at least 200 wide.** A cell that holds more than one Text is at least 200 wide: `<Grid minCellWidth={150}>` makes five narrow columns at desktop width, and a card carrying a title, a duration and a price breaks words and wraps the price onto two lines there. Leave `minCellWidth` off (the default is 240) or set it to 240 or more; a width under 200 is for cells of a single `Text` (chips, avatars, icon tiles), and the publish gate refuses (`grid-cell-too-narrow`) a narrower Grid whose cell holds more than one `Text`. An action inside a card is a `<Button>`, never a `Pressable` around text.
|
|
3491
3500
|
- **Compose forms — pair fields into rows, don't stack one per row.** Lay a form out with `<Form>` by default (above), which does the following for you; when it needs what `<Form>` cannot do (a multi-step wizard, a form beside a live preview, fields laid over a picture), lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. A search box is `<SearchField>`, which already carries its glyph and clear control. Any other icon goes inside the field through `leadingIcon` / `trailingIcon`; never wrap a `<TextField>` in a bordered row, since it draws its own border. Put a unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
|
|
3492
3501
|
- **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
|
|
3493
3502
|
- **Drag and drop — show what is being dragged.** Use `<SortableList>` for a list the user reorders and `<Board>` for cards moved between columns by default: they do everything this item describes, on both hosts, and give a user who cannot drag the same moves from the handle. When the request needs what they cannot do (free-form positioning on a canvas, a drag between two widgets), build your own as follows. A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
|
|
@@ -3900,6 +3909,8 @@ Pass `--manifest` to enable `style-field-unread` and the three editability rules
|
|
|
3900
3909
|
|
|
3901
3910
|
`flex-basis-percent` flags a literal percentage `flexBasis` — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
|
|
3902
3911
|
|
|
3912
|
+
`grid-cell-too-narrow` refuses a `<Grid minCellWidth={N}>` with `N` under 200 whose cell holds more than one `Text`, naming the cell width and the default of 240: a card with a title and a price breaks its words at that width. A cell of a single `Text` passes at any width, and a Grid without `minCellWidth` passes. An error.
|
|
3913
|
+
|
|
3903
3914
|
`row-action-not-gated` flags a widget that calls `update` or `delete` from `useDatastoreMutation` without a per-row permission check: no `_capabilities` read and no `useCanWrite(tableId, { recordId })`. The table-level `useCanWrite(tableId)` only answers "may create", so a list gated on it shows Edit / Delete on rows the server will refuse. A warning.
|
|
3904
3915
|
|
|
3905
3916
|
`no-html-in-content` flags an HTML tag inside a **string** — `"<strong>"`, `"<br>"`, `"<p>…</p>"`. There is no HTML renderer on either host, so the tag reaches the reader as literal text; author formatted text with `<MarkdownInput>` and render it with `<RichText>` instead. Only string and template content is scanned, so your own `<View>` / `<Text>` JSX can never trip it. A warning for a human author, blocking for the AI widget agent.
|
package/dist/contract.cjs
CHANGED
|
@@ -2299,6 +2299,46 @@ const HOOKS = [
|
|
|
2299
2299
|
requiredContextSlice: ["actions.run"],
|
|
2300
2300
|
scopes: ["actions.run:*"],
|
|
2301
2301
|
},
|
|
2302
|
+
{
|
|
2303
|
+
name: "useConnector",
|
|
2304
|
+
signature: "useConnector(connectorRef)",
|
|
2305
|
+
description:
|
|
2306
|
+
"sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
|
|
2307
|
+
"outside API) straight from the widget, and use the answer in the " +
|
|
2308
|
+
"widget. call(operation, { params, query, body }) names an OPERATION " +
|
|
2309
|
+
"the workspace listed for apps on that connector, never a method or a " +
|
|
2310
|
+
"path: the server builds the request from the listing and adds the " +
|
|
2311
|
+
"connector's credential, which never reaches the app. `params` fills " +
|
|
2312
|
+
"the operation's {name} path segments (one whole segment each, no " +
|
|
2313
|
+
"slashes); `query` may use only the names the operation lists; `body` " +
|
|
2314
|
+
"only where the operation takes one. call resolves with { status, " +
|
|
2315
|
+
"content_type, body } — the outside API's status (a non-2xx answer " +
|
|
2316
|
+
"still resolves: branch on status) and its parsed JSON or text body. " +
|
|
2317
|
+
"The whole call must finish within 250 ms, token exchange included: a " +
|
|
2318
|
+
"slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
|
|
2319
|
+
"May run on mount for a read operation; an operation that changes data " +
|
|
2320
|
+
"belongs in an event handler. `connectorRef` is a connector id an " +
|
|
2321
|
+
"author bound through a `connectorRef` property — when it is missing, " +
|
|
2322
|
+
"or the host brokers no connectors client, `available` is false and " +
|
|
2323
|
+
"call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
|
|
2324
|
+
"NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
|
|
2325
|
+
"OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
|
|
2326
|
+
"connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
|
|
2327
|
+
"(409) will NOT clear on a retry, so show an 'isn't available' state; " +
|
|
2328
|
+
"RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
|
|
2329
|
+
"argument the operation does not take; UPSTREAM_ERROR (502) and " +
|
|
2330
|
+
"UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
|
|
2331
|
+
"TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
|
|
2332
|
+
"show an app user a raw error. Reads ctx.connectors.",
|
|
2333
|
+
returnShape: {
|
|
2334
|
+
available: "boolean // false when no connector is bound or the host brokers none",
|
|
2335
|
+
call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
|
|
2336
|
+
calling: "boolean // true while any call is in flight",
|
|
2337
|
+
error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
|
|
2338
|
+
},
|
|
2339
|
+
requiredContextSlice: ["connectors.call"],
|
|
2340
|
+
scopes: ["connectors.call:*"],
|
|
2341
|
+
},
|
|
2302
2342
|
{
|
|
2303
2343
|
name: "useDatastoreMutation",
|
|
2304
2344
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -4342,6 +4382,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
4342
4382
|
required: false,
|
|
4343
4383
|
fields: { run: "function", status: "function" },
|
|
4344
4384
|
},
|
|
4385
|
+
connectors: {
|
|
4386
|
+
description:
|
|
4387
|
+
"sc-15659 — injected connectors client from @colixsystems/actions-client. " +
|
|
4388
|
+
"{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
|
|
4389
|
+
"Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
|
|
4390
|
+
"connector, as the signed-in caller; the credential is added server-side. Errors carry " +
|
|
4391
|
+
"the server's `code` and `status`. Absent on a host that brokers none, where " +
|
|
4392
|
+
"useConnector() degrades to available: false rather than throwing.",
|
|
4393
|
+
required: false,
|
|
4394
|
+
fields: { call: "function" },
|
|
4395
|
+
},
|
|
4345
4396
|
linkPreviews: {
|
|
4346
4397
|
description:
|
|
4347
4398
|
"Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
|
|
@@ -6751,7 +6802,10 @@ const CONTRACT = deepFreeze({
|
|
|
6751
6802
|
// PaymentError codes.
|
|
6752
6803
|
// 1.237.0: widening — `manifest.translations` may declare up to 500 keys
|
|
6753
6804
|
// (was 200).
|
|
6754
|
-
|
|
6805
|
+
// 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
|
|
6806
|
+
// `connectors` host client + the `connectorRef` propertySchema type + the
|
|
6807
|
+
// `connectors.call:*` scope. A widget calls a REST connector's listed operations.
|
|
6808
|
+
version: "1.238.0",
|
|
6755
6809
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6756
6810
|
hooks: HOOKS,
|
|
6757
6811
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -2295,6 +2295,46 @@ const HOOKS = [
|
|
|
2295
2295
|
requiredContextSlice: ["actions.run"],
|
|
2296
2296
|
scopes: ["actions.run:*"],
|
|
2297
2297
|
},
|
|
2298
|
+
{
|
|
2299
|
+
name: "useConnector",
|
|
2300
|
+
signature: "useConnector(connectorRef)",
|
|
2301
|
+
description:
|
|
2302
|
+
"sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
|
|
2303
|
+
"outside API) straight from the widget, and use the answer in the " +
|
|
2304
|
+
"widget. call(operation, { params, query, body }) names an OPERATION " +
|
|
2305
|
+
"the workspace listed for apps on that connector, never a method or a " +
|
|
2306
|
+
"path: the server builds the request from the listing and adds the " +
|
|
2307
|
+
"connector's credential, which never reaches the app. `params` fills " +
|
|
2308
|
+
"the operation's {name} path segments (one whole segment each, no " +
|
|
2309
|
+
"slashes); `query` may use only the names the operation lists; `body` " +
|
|
2310
|
+
"only where the operation takes one. call resolves with { status, " +
|
|
2311
|
+
"content_type, body } — the outside API's status (a non-2xx answer " +
|
|
2312
|
+
"still resolves: branch on status) and its parsed JSON or text body. " +
|
|
2313
|
+
"The whole call must finish within 250 ms, token exchange included: a " +
|
|
2314
|
+
"slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
|
|
2315
|
+
"May run on mount for a read operation; an operation that changes data " +
|
|
2316
|
+
"belongs in an event handler. `connectorRef` is a connector id an " +
|
|
2317
|
+
"author bound through a `connectorRef` property — when it is missing, " +
|
|
2318
|
+
"or the host brokers no connectors client, `available` is false and " +
|
|
2319
|
+
"call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
|
|
2320
|
+
"NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
|
|
2321
|
+
"OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
|
|
2322
|
+
"connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
|
|
2323
|
+
"(409) will NOT clear on a retry, so show an 'isn't available' state; " +
|
|
2324
|
+
"RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
|
|
2325
|
+
"argument the operation does not take; UPSTREAM_ERROR (502) and " +
|
|
2326
|
+
"UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
|
|
2327
|
+
"TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
|
|
2328
|
+
"show an app user a raw error. Reads ctx.connectors.",
|
|
2329
|
+
returnShape: {
|
|
2330
|
+
available: "boolean // false when no connector is bound or the host brokers none",
|
|
2331
|
+
call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
|
|
2332
|
+
calling: "boolean // true while any call is in flight",
|
|
2333
|
+
error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
|
|
2334
|
+
},
|
|
2335
|
+
requiredContextSlice: ["connectors.call"],
|
|
2336
|
+
scopes: ["connectors.call:*"],
|
|
2337
|
+
},
|
|
2298
2338
|
{
|
|
2299
2339
|
name: "useDatastoreMutation",
|
|
2300
2340
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -4338,6 +4378,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
4338
4378
|
required: false,
|
|
4339
4379
|
fields: { run: "function", status: "function" },
|
|
4340
4380
|
},
|
|
4381
|
+
connectors: {
|
|
4382
|
+
description:
|
|
4383
|
+
"sc-15659 — injected connectors client from @colixsystems/actions-client. " +
|
|
4384
|
+
"{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
|
|
4385
|
+
"Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
|
|
4386
|
+
"connector, as the signed-in caller; the credential is added server-side. Errors carry " +
|
|
4387
|
+
"the server's `code` and `status`. Absent on a host that brokers none, where " +
|
|
4388
|
+
"useConnector() degrades to available: false rather than throwing.",
|
|
4389
|
+
required: false,
|
|
4390
|
+
fields: { call: "function" },
|
|
4391
|
+
},
|
|
4341
4392
|
linkPreviews: {
|
|
4342
4393
|
description:
|
|
4343
4394
|
"Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
|
|
@@ -6747,7 +6798,10 @@ const CONTRACT = deepFreeze({
|
|
|
6747
6798
|
// PaymentError codes.
|
|
6748
6799
|
// 1.237.0: widening — `manifest.translations` may declare up to 500 keys
|
|
6749
6800
|
// (was 200).
|
|
6750
|
-
|
|
6801
|
+
// 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
|
|
6802
|
+
// `connectors` host client + the `connectorRef` propertySchema type + the
|
|
6803
|
+
// `connectors.call:*` scope. A widget calls a REST connector's listed operations.
|
|
6804
|
+
version: "1.238.0",
|
|
6751
6805
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6752
6806
|
hooks: HOOKS,
|
|
6753
6807
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -4118,6 +4118,115 @@ export function useAction(actionRef) {
|
|
|
4118
4118
|
};
|
|
4119
4119
|
}
|
|
4120
4120
|
|
|
4121
|
+
/**
|
|
4122
|
+
* sc-15659 — what useConnector() rejects with. `code` is one of:
|
|
4123
|
+
* - "UNAVAILABLE" — no connector bound, or the host brokers no
|
|
4124
|
+
* connectors client (render the unbound state)
|
|
4125
|
+
* - "OPERATION_NOT_ALLOWED" — 403 (the workspace did not list this operation)
|
|
4126
|
+
* - "INVALID_CONNECTOR_PARAMS" — 400 (an argument the operation does not take)
|
|
4127
|
+
* - "CONNECTOR_NOT_FOUND" — 404 (gone, or no longer callable from the app)
|
|
4128
|
+
* - "CONNECTOR_SETUP_INCOMPLETE" — 409 (the workspace has not finished its sign-in)
|
|
4129
|
+
* - "RATE_LIMITED" — 429 (too many calls — retry shortly)
|
|
4130
|
+
* - "AUTH_REQUIRED" / "TOKEN_EXPIRED" / "TOKEN_INVALID" — 401 (no app session)
|
|
4131
|
+
* - "UPSTREAM_ERROR" / "UPSTREAM_TIMEOUT" — 502 / 504 (the outside API failed)
|
|
4132
|
+
* - "CONNECTOR_FAILED" — no answer came
|
|
4133
|
+
*
|
|
4134
|
+
* The connectors client owns these codes; any other server code passes through.
|
|
4135
|
+
*/
|
|
4136
|
+
export class ConnectorError extends Error {
|
|
4137
|
+
constructor(code, message, opts) {
|
|
4138
|
+
super(message);
|
|
4139
|
+
this.name = "ConnectorError";
|
|
4140
|
+
this.code = code;
|
|
4141
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
function toConnectorError(err) {
|
|
4146
|
+
if (err instanceof ConnectorError) return err;
|
|
4147
|
+
const code = err && typeof err.code === "string" && err.code ? err.code : "CONNECTOR_FAILED";
|
|
4148
|
+
const message = (err && err.message) || "The connector call failed";
|
|
4149
|
+
return new ConnectorError(code, message, { cause: err });
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
/**
|
|
4153
|
+
* sc-15659 — call one of the workspace's REST connectors through an operation
|
|
4154
|
+
* the workspace listed for apps. Returns `{ available, call, calling, error }`.
|
|
4155
|
+
*
|
|
4156
|
+
* call(operation, { params?, query?, body? }) → Promise<{ status, content_type, body }>
|
|
4157
|
+
*
|
|
4158
|
+
* The widget owns what it does with the answer. A non-2xx upstream status
|
|
4159
|
+
* still resolves; a platform refusal rejects with a ConnectorError. The
|
|
4160
|
+
* connector's credential is added on the server and never reaches the app.
|
|
4161
|
+
*
|
|
4162
|
+
* `connectorRef` is the connector id an author bound through a
|
|
4163
|
+
* `connectorRef` property. When it is absent, or the host brokers no
|
|
4164
|
+
* connectors client, `available` is false and `call` rejects UNAVAILABLE
|
|
4165
|
+
* rather than throwing at render — render a visible unbound state.
|
|
4166
|
+
*
|
|
4167
|
+
* Routes through the injected `@colixsystems/actions-client` at
|
|
4168
|
+
* `ctx.connectors`, so the Player and the Expo export call identically.
|
|
4169
|
+
*/
|
|
4170
|
+
export function useConnector(connectorRef) {
|
|
4171
|
+
const ctx = useWidgetContextOrThrow("useConnector");
|
|
4172
|
+
const available =
|
|
4173
|
+
Boolean(connectorRef) && Boolean(ctx.connectors) && typeof ctx.connectors.call === "function";
|
|
4174
|
+
|
|
4175
|
+
const clientRef = useRef(null);
|
|
4176
|
+
clientRef.current = available ? ctx.connectors : null;
|
|
4177
|
+
const connectorIdRef = useRef(connectorRef);
|
|
4178
|
+
connectorIdRef.current = connectorRef;
|
|
4179
|
+
|
|
4180
|
+
const mountedRef = useRef(true);
|
|
4181
|
+
useEffect(() => {
|
|
4182
|
+
mountedRef.current = true;
|
|
4183
|
+
return () => {
|
|
4184
|
+
mountedRef.current = false;
|
|
4185
|
+
};
|
|
4186
|
+
}, []);
|
|
4187
|
+
|
|
4188
|
+
// A rebind starts clean: another connector's error never shows.
|
|
4189
|
+
const [outcome, setOutcome] = useState({ boundTo: connectorRef, pending: 0, error: null });
|
|
4190
|
+
|
|
4191
|
+
const call = useCallback(async (operation, args) => {
|
|
4192
|
+
const client = clientRef.current;
|
|
4193
|
+
const connectorId = connectorIdRef.current;
|
|
4194
|
+
if (!client || !connectorId) {
|
|
4195
|
+
throw new ConnectorError("UNAVAILABLE", "No connector is available for this host");
|
|
4196
|
+
}
|
|
4197
|
+
const settle = (error) => {
|
|
4198
|
+
if (!mountedRef.current) return;
|
|
4199
|
+
setOutcome((prior) =>
|
|
4200
|
+
prior.boundTo === connectorId
|
|
4201
|
+
? { boundTo: connectorId, pending: Math.max(0, prior.pending - 1), error }
|
|
4202
|
+
: prior,
|
|
4203
|
+
);
|
|
4204
|
+
};
|
|
4205
|
+
setOutcome((prior) =>
|
|
4206
|
+
prior.boundTo === connectorId
|
|
4207
|
+
? { ...prior, pending: prior.pending + 1, error: null }
|
|
4208
|
+
: { boundTo: connectorId, pending: 1, error: null },
|
|
4209
|
+
);
|
|
4210
|
+
try {
|
|
4211
|
+
const answer = await client.call(connectorId, operation, args);
|
|
4212
|
+
settle(null);
|
|
4213
|
+
return answer;
|
|
4214
|
+
} catch (err) {
|
|
4215
|
+
const e = toConnectorError(err);
|
|
4216
|
+
settle(e);
|
|
4217
|
+
throw e;
|
|
4218
|
+
}
|
|
4219
|
+
}, []);
|
|
4220
|
+
|
|
4221
|
+
const current = outcome.boundTo === connectorRef;
|
|
4222
|
+
return {
|
|
4223
|
+
available,
|
|
4224
|
+
call,
|
|
4225
|
+
calling: current && outcome.pending > 0,
|
|
4226
|
+
error: current ? outcome.error : null,
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4121
4230
|
/**
|
|
4122
4231
|
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
4123
4232
|
* returning a Promise. Routes through the injected
|
package/dist/index.d.ts
CHANGED
|
@@ -910,6 +910,10 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
910
910
|
/** sc-14224 — whether a run would reach the script; optional on older hosts. */
|
|
911
911
|
status?(actionId: string): Promise<{ available: boolean; reason: string | null }>;
|
|
912
912
|
};
|
|
913
|
+
/** sc-15659 — injected @colixsystems/actions-client backing useConnector(). */
|
|
914
|
+
connectors?: {
|
|
915
|
+
call(connectorId: string, operation: string, args?: ConnectorCallArgs): Promise<ConnectorCallResult>;
|
|
916
|
+
};
|
|
913
917
|
/** Injected @colixsystems/link-preview-client backing useLinkPreview(). */
|
|
914
918
|
linkPreviews?: { get(url: string): Promise<LinkPreview> };
|
|
915
919
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
@@ -2105,6 +2109,40 @@ export interface ActionApi {
|
|
|
2105
2109
|
*/
|
|
2106
2110
|
export function useAction(actionRef: string | null | undefined): ActionApi;
|
|
2107
2111
|
|
|
2112
|
+
export interface ConnectorCallArgs {
|
|
2113
|
+
/** Values for the operation's `{name}` path segments; one whole segment each. */
|
|
2114
|
+
params?: Record<string, string | number>;
|
|
2115
|
+
/** Only the query names the operation lists. */
|
|
2116
|
+
query?: Record<string, string | number | boolean | Array<string | number | boolean>>;
|
|
2117
|
+
/** A JSON body, only for an operation that takes one. */
|
|
2118
|
+
body?: unknown;
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/** The outside API's answer: its status (non-2xx still resolves) and parsed body. */
|
|
2122
|
+
export interface ConnectorCallResult {
|
|
2123
|
+
status: number;
|
|
2124
|
+
content_type: string | null;
|
|
2125
|
+
body: unknown;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
export interface ConnectorApi {
|
|
2129
|
+
/** False when no connector is bound or the host brokers no connectors client. */
|
|
2130
|
+
available: boolean;
|
|
2131
|
+
/** Call one operation the workspace listed; rejects with ConnectorError. */
|
|
2132
|
+
call(operation: string, args?: ConnectorCallArgs): Promise<ConnectorCallResult>;
|
|
2133
|
+
/** True while any call is in flight. */
|
|
2134
|
+
calling: boolean;
|
|
2135
|
+
/** The last call's error; cleared by the next call or a rebind. */
|
|
2136
|
+
error: ConnectorError | null;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* sc-15659 — call one of the workspace's REST connectors through an operation
|
|
2141
|
+
* the workspace listed for apps. The widget needs `connectors.call:*` in
|
|
2142
|
+
* `requestedScopes`; the credential never reaches the app.
|
|
2143
|
+
*/
|
|
2144
|
+
export function useConnector(connectorRef: string | null | undefined): ConnectorApi;
|
|
2145
|
+
|
|
2108
2146
|
// REQ-RT-07 realtime subscription transport state.
|
|
2109
2147
|
export type DatastoreSubscriptionStatus =
|
|
2110
2148
|
| "connecting"
|
|
@@ -3441,6 +3479,30 @@ export class AgentError extends Error {
|
|
|
3441
3479
|
* `RATE_LIMITED` will. `AUTH_REQUIRED`, `TOKEN_EXPIRED` and `TOKEN_INVALID`
|
|
3442
3480
|
* mean the app user must sign in. Never show an app user the raw message.
|
|
3443
3481
|
*/
|
|
3482
|
+
/**
|
|
3483
|
+
* sc-15659 — structured error thrown by `useConnector().call`. Branch on
|
|
3484
|
+
* `code`: `OPERATION_NOT_ALLOWED`, `CONNECTOR_NOT_FOUND` and
|
|
3485
|
+
* `CONNECTOR_SETUP_INCOMPLETE` will not clear on a retry, `RATE_LIMITED` will.
|
|
3486
|
+
* Never show an app user the raw message.
|
|
3487
|
+
*/
|
|
3488
|
+
export class ConnectorError extends Error {
|
|
3489
|
+
code:
|
|
3490
|
+
| "UNAVAILABLE"
|
|
3491
|
+
| "INVALID_CONNECTOR_PARAMS"
|
|
3492
|
+
| "OPERATION_NOT_ALLOWED"
|
|
3493
|
+
| "CONNECTOR_NOT_FOUND"
|
|
3494
|
+
| "CONNECTOR_SETUP_INCOMPLETE"
|
|
3495
|
+
| "RATE_LIMITED"
|
|
3496
|
+
| "AUTH_REQUIRED"
|
|
3497
|
+
| "TOKEN_EXPIRED"
|
|
3498
|
+
| "TOKEN_INVALID"
|
|
3499
|
+
| "UPSTREAM_ERROR"
|
|
3500
|
+
| "UPSTREAM_TIMEOUT"
|
|
3501
|
+
| "CONNECTOR_FAILED"
|
|
3502
|
+
| (string & {});
|
|
3503
|
+
constructor(code: string, message: string, opts?: { cause?: unknown });
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3444
3506
|
export class ActionError extends Error {
|
|
3445
3507
|
code:
|
|
3446
3508
|
| "UNAVAILABLE"
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ export {
|
|
|
30
30
|
PermissionError,
|
|
31
31
|
AgentError,
|
|
32
32
|
ActionError,
|
|
33
|
+
ConnectorError,
|
|
33
34
|
useDatastoreQuery,
|
|
34
35
|
useDatastoreRecord,
|
|
35
36
|
useDatastoreSchema,
|
|
@@ -39,6 +40,7 @@ export {
|
|
|
39
40
|
useInterpretDraft,
|
|
40
41
|
useAgent,
|
|
41
42
|
useAction,
|
|
43
|
+
useConnector,
|
|
42
44
|
useAsset,
|
|
43
45
|
useAssetsByTag,
|
|
44
46
|
useBundledAsset,
|
package/dist/index.native.js
CHANGED
|
@@ -30,6 +30,7 @@ export {
|
|
|
30
30
|
PermissionError,
|
|
31
31
|
AgentError,
|
|
32
32
|
ActionError,
|
|
33
|
+
ConnectorError,
|
|
33
34
|
useDatastoreQuery,
|
|
34
35
|
useDatastoreRecord,
|
|
35
36
|
useDatastoreSchema,
|
|
@@ -39,6 +40,7 @@ export {
|
|
|
39
40
|
useInterpretDraft,
|
|
40
41
|
useAgent,
|
|
41
42
|
useAction,
|
|
43
|
+
useConnector,
|
|
42
44
|
useAsset,
|
|
43
45
|
useAssetsByTag,
|
|
44
46
|
useBundledAsset,
|
package/dist/linter.cjs
CHANGED
|
@@ -861,6 +861,16 @@ function _scopeRules(source, manifest) {
|
|
|
861
861
|
snippet: "",
|
|
862
862
|
});
|
|
863
863
|
}
|
|
864
|
+
// sc-15659 — DISCLOSURE, as for useAction: the server gate is the app-user
|
|
865
|
+
// JWT plus the connector's app opt-in and listed operations.
|
|
866
|
+
if (/\buseConnector\s*\(/.test(source) && !declared.has("connectors.call:*")) {
|
|
867
|
+
findings.push({
|
|
868
|
+
rule: "scope-required-for-useConnector",
|
|
869
|
+
label: "useConnector() requires `connectors.call:*` in manifest.requestedScopes",
|
|
870
|
+
line: 0,
|
|
871
|
+
snippet: "",
|
|
872
|
+
});
|
|
873
|
+
}
|
|
864
874
|
const lines = source.split(/\r?\n/);
|
|
865
875
|
let firedUsersWrite = false;
|
|
866
876
|
let firedUsersDelete = false;
|
|
@@ -2155,6 +2165,79 @@ function _fieldInBorderedRowRules(source) {
|
|
|
2155
2165
|
return findings;
|
|
2156
2166
|
}
|
|
2157
2167
|
|
|
2168
|
+
// sc-15754 — a Grid cell narrower than the default fits one Text, not a card.
|
|
2169
|
+
//
|
|
2170
|
+
// `<Grid minCellWidth={150}>` on cards carrying a title, a duration and a
|
|
2171
|
+
// price made five narrow columns at desktop width, with broken words and the
|
|
2172
|
+
// prices wrapped onto two lines; the agent cannot see that until it renders.
|
|
2173
|
+
// A cell of a single Text (a chip, an avatar, an icon tile) is fine at any
|
|
2174
|
+
// width, so only a cell holding more than one Text is refused. A width read
|
|
2175
|
+
// from a `const` is followed; one computed at runtime stays beyond the scan.
|
|
2176
|
+
const _GRID_DEFAULT_MIN_CELL_WIDTH = 240; // grid-logic.js DEFAULT_MIN_CELL_WIDTH
|
|
2177
|
+
const _GRID_NARROW_CELL_WIDTH = 200;
|
|
2178
|
+
const _MIN_CELL_WIDTH_ATTR_RE =
|
|
2179
|
+
/\bminCellWidth\s*=\s*\{\s*(\d+(?:\.\d+)?|[A-Za-z_$][\w$]*)\s*\}/;
|
|
2180
|
+
|
|
2181
|
+
function _gridMinCellWidth(structure, attrs) {
|
|
2182
|
+
const hit = _MIN_CELL_WIDTH_ATTR_RE.exec(attrs);
|
|
2183
|
+
if (!hit) return NaN;
|
|
2184
|
+
if (/^\d/.test(hit[1])) return Number(hit[1]);
|
|
2185
|
+
const declared = new RegExp(`\\bconst\\s+${hit[1]}\\s*=\\s*(\\d+(?:\\.\\d+)?)\\s*;`).exec(structure);
|
|
2186
|
+
return declared ? Number(declared[1]) : NaN;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function _gridCellWidthRules(source) {
|
|
2190
|
+
const findings = [];
|
|
2191
|
+
const structure = _stripNonCode(source);
|
|
2192
|
+
const index = _jsxScanIndex(structure);
|
|
2193
|
+
const sourceLines = source.split(/\r?\n/);
|
|
2194
|
+
const lineAt = _lineCounter(structure);
|
|
2195
|
+
const open = _openTagStack();
|
|
2196
|
+
_JSX_TOKEN_RE.lastIndex = 0;
|
|
2197
|
+
let token;
|
|
2198
|
+
while ((token = _JSX_TOKEN_RE.exec(structure))) {
|
|
2199
|
+
const [, closing, name] = token;
|
|
2200
|
+
const parent = open.top();
|
|
2201
|
+
if (!name) {
|
|
2202
|
+
if (structure[token.index + 1 + closing.length] !== ">") continue;
|
|
2203
|
+
// A fragment is transparent: its children are its parent's children.
|
|
2204
|
+
if (closing) open.pop();
|
|
2205
|
+
else open.push({ name: "", narrow: parent && parent.narrow, cell: parent && parent.cell });
|
|
2206
|
+
continue;
|
|
2207
|
+
}
|
|
2208
|
+
if (closing) {
|
|
2209
|
+
open.close(name);
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
const end = index.tagEnd(token.index);
|
|
2213
|
+
if (end === -1) break;
|
|
2214
|
+
const tag = { name, narrow: undefined, cell: parent && parent.cell };
|
|
2215
|
+
if (parent && parent.narrow !== undefined) tag.cell = { width: parent.narrow, at: token.index, texts: 0 };
|
|
2216
|
+
if (name === "Grid") {
|
|
2217
|
+
const width = _gridMinCellWidth(structure, structure.slice(token.index, end));
|
|
2218
|
+
if (width < _GRID_NARROW_CELL_WIDTH) tag.narrow = width;
|
|
2219
|
+
}
|
|
2220
|
+
if (name === "Text" && tag.cell && ++tag.cell.texts === 2) {
|
|
2221
|
+
const line = lineAt(tag.cell.at);
|
|
2222
|
+
findings.push({
|
|
2223
|
+
rule: "grid-cell-too-narrow",
|
|
2224
|
+
severity: "error",
|
|
2225
|
+
label:
|
|
2226
|
+
`<Grid minCellWidth={${tag.cell.width}}> makes each cell ${tag.cell.width}px wide, and a cell ` +
|
|
2227
|
+
`holding more than one Text breaks its words and wraps a price or a time onto two ` +
|
|
2228
|
+
`lines at that width. Leave minCellWidth off (the default is ${_GRID_DEFAULT_MIN_CELL_WIDTH}) ` +
|
|
2229
|
+
`or set it to ${_GRID_DEFAULT_MIN_CELL_WIDTH} or more; a width under ${_GRID_NARROW_CELL_WIDTH} ` +
|
|
2230
|
+
`fits only a cell of a single Text, such as a chip, an avatar or an icon tile.`,
|
|
2231
|
+
line,
|
|
2232
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
if (!_selfClosing(structure, end)) open.push(tag);
|
|
2236
|
+
_JSX_TOKEN_RE.lastIndex = end + 1;
|
|
2237
|
+
}
|
|
2238
|
+
return findings;
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2158
2241
|
// sc-4913 — a measured frame INCLUDES the element's own padding.
|
|
2159
2242
|
//
|
|
2160
2243
|
// `onLayout` reports the frame width, and padding sits inside that frame. A
|
|
@@ -2886,6 +2969,8 @@ function lintSource(source, options) {
|
|
|
2886
2969
|
findings.push(..._fullWidthMarginRules(source));
|
|
2887
2970
|
// sc-12232 — soft warning: a field inside a bordered row draws two borders.
|
|
2888
2971
|
findings.push(..._fieldInBorderedRowRules(source));
|
|
2972
|
+
// sc-15754 — a Grid cell under 200px holding more than one Text is refused.
|
|
2973
|
+
findings.push(..._gridCellWidthRules(source));
|
|
2889
2974
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2890
2975
|
// padding wraps the last grid column into an empty one.
|
|
2891
2976
|
findings.push(..._measuredPaddingRules(source));
|
package/dist/linter.js
CHANGED
|
@@ -856,6 +856,16 @@ function _scopeRules(source, manifest) {
|
|
|
856
856
|
snippet: "",
|
|
857
857
|
});
|
|
858
858
|
}
|
|
859
|
+
// sc-15659 — DISCLOSURE, as for useAction: the server gate is the app-user
|
|
860
|
+
// JWT plus the connector's app opt-in and listed operations.
|
|
861
|
+
if (/\buseConnector\s*\(/.test(source) && !declared.has("connectors.call:*")) {
|
|
862
|
+
findings.push({
|
|
863
|
+
rule: "scope-required-for-useConnector",
|
|
864
|
+
label: "useConnector() requires `connectors.call:*` in manifest.requestedScopes",
|
|
865
|
+
line: 0,
|
|
866
|
+
snippet: "",
|
|
867
|
+
});
|
|
868
|
+
}
|
|
859
869
|
const lines = source.split(/\r?\n/);
|
|
860
870
|
let firedUsersWrite = false;
|
|
861
871
|
let firedUsersDelete = false;
|
|
@@ -2150,6 +2160,79 @@ function _fieldInBorderedRowRules(source) {
|
|
|
2150
2160
|
return findings;
|
|
2151
2161
|
}
|
|
2152
2162
|
|
|
2163
|
+
// sc-15754 — a Grid cell narrower than the default fits one Text, not a card.
|
|
2164
|
+
//
|
|
2165
|
+
// `<Grid minCellWidth={150}>` on cards carrying a title, a duration and a
|
|
2166
|
+
// price made five narrow columns at desktop width, with broken words and the
|
|
2167
|
+
// prices wrapped onto two lines; the agent cannot see that until it renders.
|
|
2168
|
+
// A cell of a single Text (a chip, an avatar, an icon tile) is fine at any
|
|
2169
|
+
// width, so only a cell holding more than one Text is refused. A width read
|
|
2170
|
+
// from a `const` is followed; one computed at runtime stays beyond the scan.
|
|
2171
|
+
const _GRID_DEFAULT_MIN_CELL_WIDTH = 240; // grid-logic.js DEFAULT_MIN_CELL_WIDTH
|
|
2172
|
+
const _GRID_NARROW_CELL_WIDTH = 200;
|
|
2173
|
+
const _MIN_CELL_WIDTH_ATTR_RE =
|
|
2174
|
+
/\bminCellWidth\s*=\s*\{\s*(\d+(?:\.\d+)?|[A-Za-z_$][\w$]*)\s*\}/;
|
|
2175
|
+
|
|
2176
|
+
function _gridMinCellWidth(structure, attrs) {
|
|
2177
|
+
const hit = _MIN_CELL_WIDTH_ATTR_RE.exec(attrs);
|
|
2178
|
+
if (!hit) return NaN;
|
|
2179
|
+
if (/^\d/.test(hit[1])) return Number(hit[1]);
|
|
2180
|
+
const declared = new RegExp(`\\bconst\\s+${hit[1]}\\s*=\\s*(\\d+(?:\\.\\d+)?)\\s*;`).exec(structure);
|
|
2181
|
+
return declared ? Number(declared[1]) : NaN;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function _gridCellWidthRules(source) {
|
|
2185
|
+
const findings = [];
|
|
2186
|
+
const structure = _stripNonCode(source);
|
|
2187
|
+
const index = _jsxScanIndex(structure);
|
|
2188
|
+
const sourceLines = source.split(/\r?\n/);
|
|
2189
|
+
const lineAt = _lineCounter(structure);
|
|
2190
|
+
const open = _openTagStack();
|
|
2191
|
+
_JSX_TOKEN_RE.lastIndex = 0;
|
|
2192
|
+
let token;
|
|
2193
|
+
while ((token = _JSX_TOKEN_RE.exec(structure))) {
|
|
2194
|
+
const [, closing, name] = token;
|
|
2195
|
+
const parent = open.top();
|
|
2196
|
+
if (!name) {
|
|
2197
|
+
if (structure[token.index + 1 + closing.length] !== ">") continue;
|
|
2198
|
+
// A fragment is transparent: its children are its parent's children.
|
|
2199
|
+
if (closing) open.pop();
|
|
2200
|
+
else open.push({ name: "", narrow: parent && parent.narrow, cell: parent && parent.cell });
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
if (closing) {
|
|
2204
|
+
open.close(name);
|
|
2205
|
+
continue;
|
|
2206
|
+
}
|
|
2207
|
+
const end = index.tagEnd(token.index);
|
|
2208
|
+
if (end === -1) break;
|
|
2209
|
+
const tag = { name, narrow: undefined, cell: parent && parent.cell };
|
|
2210
|
+
if (parent && parent.narrow !== undefined) tag.cell = { width: parent.narrow, at: token.index, texts: 0 };
|
|
2211
|
+
if (name === "Grid") {
|
|
2212
|
+
const width = _gridMinCellWidth(structure, structure.slice(token.index, end));
|
|
2213
|
+
if (width < _GRID_NARROW_CELL_WIDTH) tag.narrow = width;
|
|
2214
|
+
}
|
|
2215
|
+
if (name === "Text" && tag.cell && ++tag.cell.texts === 2) {
|
|
2216
|
+
const line = lineAt(tag.cell.at);
|
|
2217
|
+
findings.push({
|
|
2218
|
+
rule: "grid-cell-too-narrow",
|
|
2219
|
+
severity: "error",
|
|
2220
|
+
label:
|
|
2221
|
+
`<Grid minCellWidth={${tag.cell.width}}> makes each cell ${tag.cell.width}px wide, and a cell ` +
|
|
2222
|
+
`holding more than one Text breaks its words and wraps a price or a time onto two ` +
|
|
2223
|
+
`lines at that width. Leave minCellWidth off (the default is ${_GRID_DEFAULT_MIN_CELL_WIDTH}) ` +
|
|
2224
|
+
`or set it to ${_GRID_DEFAULT_MIN_CELL_WIDTH} or more; a width under ${_GRID_NARROW_CELL_WIDTH} ` +
|
|
2225
|
+
`fits only a cell of a single Text, such as a chip, an avatar or an icon tile.`,
|
|
2226
|
+
line,
|
|
2227
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
if (!_selfClosing(structure, end)) open.push(tag);
|
|
2231
|
+
_JSX_TOKEN_RE.lastIndex = end + 1;
|
|
2232
|
+
}
|
|
2233
|
+
return findings;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2153
2236
|
// sc-4913 — a measured frame INCLUDES the element's own padding.
|
|
2154
2237
|
//
|
|
2155
2238
|
// `onLayout` reports the frame width, and padding sits inside that frame. A
|
|
@@ -2881,6 +2964,8 @@ export function lintSource(source, options) {
|
|
|
2881
2964
|
findings.push(..._fullWidthMarginRules(source));
|
|
2882
2965
|
// sc-12232 — soft warning: a field inside a bordered row draws two borders.
|
|
2883
2966
|
findings.push(..._fieldInBorderedRowRules(source));
|
|
2967
|
+
// sc-15754 — a Grid cell under 200px holding more than one Text is refused.
|
|
2968
|
+
findings.push(..._gridCellWidthRules(source));
|
|
2884
2969
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2885
2970
|
// padding wraps the last grid column into an empty one.
|
|
2886
2971
|
findings.push(..._measuredPaddingRules(source));
|
package/dist/property-schema.js
CHANGED
|
@@ -32,6 +32,9 @@ const VALID_TYPES = new Set([
|
|
|
32
32
|
// sc-13843: `actionRef` picks one of the workspace's Actions, a bare
|
|
33
33
|
// Action UUID a widget passes to `useAction(actionRef)`. Remapped like agentRef.
|
|
34
34
|
"actionRef",
|
|
35
|
+
// sc-15659: `connectorRef` picks one of the workspace's app-callable REST
|
|
36
|
+
// connectors, a bare id a widget passes to `useConnector`. Remapped likewise.
|
|
37
|
+
"connectorRef",
|
|
35
38
|
// REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
|
|
36
39
|
// UUID into the page JSON. Renders a page `<select>` in the Studio
|
|
37
40
|
// Properties Panel; a widget reads it and navigates via
|
|
@@ -164,6 +167,7 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
164
167
|
case "sectionRef":
|
|
165
168
|
case "agentRef":
|
|
166
169
|
case "actionRef":
|
|
170
|
+
case "connectorRef":
|
|
167
171
|
case "richText":
|
|
168
172
|
case "asset":
|
|
169
173
|
case "folderRef":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.291.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
package/src/contract.js
CHANGED
|
@@ -2295,6 +2295,46 @@ const HOOKS = [
|
|
|
2295
2295
|
requiredContextSlice: ["actions.run"],
|
|
2296
2296
|
scopes: ["actions.run:*"],
|
|
2297
2297
|
},
|
|
2298
|
+
{
|
|
2299
|
+
name: "useConnector",
|
|
2300
|
+
signature: "useConnector(connectorRef)",
|
|
2301
|
+
description:
|
|
2302
|
+
"sc-15659 — call one of the WORKSPACE's REST connectors (a configured " +
|
|
2303
|
+
"outside API) straight from the widget, and use the answer in the " +
|
|
2304
|
+
"widget. call(operation, { params, query, body }) names an OPERATION " +
|
|
2305
|
+
"the workspace listed for apps on that connector, never a method or a " +
|
|
2306
|
+
"path: the server builds the request from the listing and adds the " +
|
|
2307
|
+
"connector's credential, which never reaches the app. `params` fills " +
|
|
2308
|
+
"the operation's {name} path segments (one whole segment each, no " +
|
|
2309
|
+
"slashes); `query` may use only the names the operation lists; `body` " +
|
|
2310
|
+
"only where the operation takes one. call resolves with { status, " +
|
|
2311
|
+
"content_type, body } — the outside API's status (a non-2xx answer " +
|
|
2312
|
+
"still resolves: branch on status) and its parsed JSON or text body. " +
|
|
2313
|
+
"The whole call must finish within 250 ms, token exchange included: a " +
|
|
2314
|
+
"slower outside API is cut off and the call rejects with UPSTREAM_TIMEOUT. " +
|
|
2315
|
+
"May run on mount for a read operation; an operation that changes data " +
|
|
2316
|
+
"belongs in an event handler. `connectorRef` is a connector id an " +
|
|
2317
|
+
"author bound through a `connectorRef` property — when it is missing, " +
|
|
2318
|
+
"or the host brokers no connectors client, `available` is false and " +
|
|
2319
|
+
"call() rejects UNAVAILABLE; render a visible 'pick a connector' state, " +
|
|
2320
|
+
"NEVER a blank box. Only signed-in app users may call. ConnectorError.code: " +
|
|
2321
|
+
"OPERATION_NOT_ALLOWED (403), CONNECTOR_NOT_FOUND (404, also when the " +
|
|
2322
|
+
"connector is not callable from the app) and CONNECTOR_SETUP_INCOMPLETE " +
|
|
2323
|
+
"(409) will NOT clear on a retry, so show an 'isn't available' state; " +
|
|
2324
|
+
"RATE_LIMITED (429) will; INVALID_CONNECTOR_PARAMS (400) means an " +
|
|
2325
|
+
"argument the operation does not take; UPSTREAM_ERROR (502) and " +
|
|
2326
|
+
"UPSTREAM_TIMEOUT (504) mean the outside API failed; AUTH_REQUIRED, " +
|
|
2327
|
+
"TOKEN_EXPIRED and TOKEN_INVALID mean the app user must sign in. NEVER " +
|
|
2328
|
+
"show an app user a raw error. Reads ctx.connectors.",
|
|
2329
|
+
returnShape: {
|
|
2330
|
+
available: "boolean // false when no connector is bound or the host brokers none",
|
|
2331
|
+
call: "(operation, { params?, query?, body? }) => Promise<{ status, content_type, body }> // rejects with ConnectorError",
|
|
2332
|
+
calling: "boolean // true while any call is in flight",
|
|
2333
|
+
error: "ConnectorError | null // the last call's error; cleared by the next call or a rebind",
|
|
2334
|
+
},
|
|
2335
|
+
requiredContextSlice: ["connectors.call"],
|
|
2336
|
+
scopes: ["connectors.call:*"],
|
|
2337
|
+
},
|
|
2298
2338
|
{
|
|
2299
2339
|
name: "useDatastoreMutation",
|
|
2300
2340
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -4338,6 +4378,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
4338
4378
|
required: false,
|
|
4339
4379
|
fields: { run: "function", status: "function" },
|
|
4340
4380
|
},
|
|
4381
|
+
connectors: {
|
|
4382
|
+
description:
|
|
4383
|
+
"sc-15659 — injected connectors client from @colixsystems/actions-client. " +
|
|
4384
|
+
"{ call(connectorId, operation, { params, query, body }) -> Promise<{ status, content_type, body }> }. " +
|
|
4385
|
+
"Backs useConnector(). Calls one operation the workspace listed on an app-callable " +
|
|
4386
|
+
"connector, as the signed-in caller; the credential is added server-side. Errors carry " +
|
|
4387
|
+
"the server's `code` and `status`. Absent on a host that brokers none, where " +
|
|
4388
|
+
"useConnector() degrades to available: false rather than throwing.",
|
|
4389
|
+
required: false,
|
|
4390
|
+
fields: { call: "function" },
|
|
4391
|
+
},
|
|
4341
4392
|
linkPreviews: {
|
|
4342
4393
|
description:
|
|
4343
4394
|
"Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
|
|
@@ -6747,7 +6798,10 @@ const CONTRACT = deepFreeze({
|
|
|
6747
6798
|
// PaymentError codes.
|
|
6748
6799
|
// 1.237.0: widening — `manifest.translations` may declare up to 500 keys
|
|
6749
6800
|
// (was 200).
|
|
6750
|
-
|
|
6801
|
+
// 1.238.0: additive (sc-15659) — `useConnector(connectorRef)` + the
|
|
6802
|
+
// `connectors` host client + the `connectorRef` propertySchema type + the
|
|
6803
|
+
// `connectors.call:*` scope. A widget calls a REST connector's listed operations.
|
|
6804
|
+
version: "1.238.0",
|
|
6751
6805
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6752
6806
|
hooks: HOOKS,
|
|
6753
6807
|
primitives: PRIMITIVES,
|