Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/23168-page-action-row-name-leg-url-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
---

feat(spec)!: a page action row's url `target` is judged on the executor the console's action runner actually runs, so a row with no executor key whose `name` is `url` or `navigation` has its `target` judged by the url scheme set

Clause-②: no (narrowing)

<!-- adr-0087: registered ui-page-action-row-name-leg-url-target-judged -->

**BREAKING**: an accept-set narrowing on one key of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.

The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). The console's action runner dispatches a row on `type || actionType || name`, and every row forwards its executor key (`actionType` on a node, `type` on a member) as `type` beside its `name`. So a row with no executor key runs the handler its `name` names, and `name: 'url'` or `name: 'navigation'` runs the url navigator. The rows judged `target` by the url scheme set only when the executor KEY read `'url'`, so such a row parsed with a target the click refuses.

The rows now judge `target` against the row's effective executor: the executor key, or, when that key is omitted or empty, the `name`, exactly as the runner resolves it. When it resolves to the url navigator, `target` is judged by the same url scheme set as an `actionType: 'url'` row, with no second grammar. A declared executor key still outranks the `name`. `name` is not refused as an executor spelling, and a row whose `name` names any other handler (`navigate_edit`, `convert_lead`, …) is untouched. An `actionType: 'url'` row's refusal is unchanged.

### FROM → TO

| before | what to write instead |
| --- | --- |
| `{ name: 'url', target: 'javascript:…' }` with no `actionType` (or no `type` on a member), or `data:…`, `tel:…` or any other scheme | an `https://` page, a `mailto:` address, or an in-app path that starts with `/` or `./`; or, for a button meant to run something, the executor that does the work (`actionType: 'script'`, `'flow'`, `'api'`) |
| the same with `name: 'navigation'` | the same; the `name` is the runner's url alias, so the target is judged as a url |
| `{ name: 'url', target: 'forecast' }`, `'../forecast'`, `'?tab=2'` | the rooted in-app path, `'/forecast'`, or a `./` path; or declare the executor the row was meant to run |
| `{ name: 'url', target: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path |
| `{ name: 'url', target: '' }` | the address the button was meant to open |

**The one-line fix: give every page action row that has no executor key and is named `url` or `navigation` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` as its `target`, or declare the executor key it was meant to run.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-name-leg-url-target-judged`; the rewrite is by hand, because which address a refused target meant is the author's call.

**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `{ name: 'url', target: 'javascript:alert(1)' }` on an `action:button` gets: ``An `action:button`'s `target` (`name: 'url'` and no `actionType`, so the runner runs the url navigator) opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged.

**Measured producers: none.** No page action row in this repository, in objectui (at its pin `1fb1ced36243` and its main `866763bd`) or in hotcrm (at `0f84c9c`) is named `url` or `navigation`.
16 changes: 8 additions & 8 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ActionButtonPropsSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone |
| **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
| **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map |
| **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself |
Expand All @@ -38,7 +38,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled |
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs; an object is forwarded as the static parameter values |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For `actionType: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down Expand Up @@ -86,7 +86,7 @@ const result = ActionButtonPropsSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` |
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id |
| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) |
Expand All @@ -96,7 +96,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn |
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For `type: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand All @@ -123,7 +123,7 @@ const result = ActionButtonPropsSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` |
| **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button`. With no `actionType` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
| **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id |
| **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) |
| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` |
Expand All @@ -132,7 +132,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled |
| **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog |
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user; an object is forwarded as the static parameter values |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For `actionType: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `actionType`. For the url navigator (`actionType: 'url'`, or no `actionType` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down Expand Up @@ -176,7 +176,7 @@ const result = ActionButtonPropsSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` |
| **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type`. With no `type` the runner dispatches on the name instead, so a `name` of `url` or `navigation` runs the url navigator and its `target` is judged as a url target |
| **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id |
| **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) |
| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) |
Expand All @@ -186,7 +186,7 @@ const result = ActionButtonPropsSchema.parse(data);
| **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn |
| **params** | `any` | optional | Action parameters, forwarded to the runner: an array is the list of inputs to collect from the user before the action runs. Only a `type: 'api'` member takes any other value, forwarded as its request payload (use `bodyExtra` for that); on every other member a non-array `params` is refused — author an action with static parameter values as its own `action:button` node |
| **description** | `string` | optional | Action description, forwarded to the runner — the parameter dialog shows it under its title |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For `type: 'url'`: an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **target** | `string` | optional | Executor target, forwarded to the runner: the URL, script name, flow name or API endpoint, per `type`. For the url navigator (`type: 'url'`, or no `type` and a `name` of `url` or `navigation`): an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ — every other scheme and a protocol-relative //host are refused |
| **openIn** | `Enum<'self' \| 'new-tab'>` | optional | For a `url` action: `self` navigates in place, `new-tab` opens a new browser tab |
| **method** | `string` | optional | HTTP method for an `api` action, forwarded to the runner |
| **bodyExtra** | `any` | optional | Static request-body fields for an `api` action, forwarded to the runner |
Expand Down
35 changes: 35 additions & 0 deletions packages/lint/src/validate-component-props-url-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,38 @@ describe('the props gate refuses a url `target` outside the shared set on the pa
expect(validateComponentProps(stackWith(node('https://example.com/docs')))).toEqual([]);
});
});

// The name leg. The runner dispatches on `type || actionType || name`, so a
// row with no executor key whose `name` is `url` or `navigation` runs the url
// navigator; the rows judge its `target` by the same set, and the gate reports
// it at the same path. A `name` that names any other handler is untouched.

/** [row, the node for a given name and target, the finding's path, the refusal's subject for a `name`]. */
const NAME_LEG_ROWS: ReadonlyArray<readonly [string, (name: string, target: string) => AnyRec, string, (name: string) => string]> = [
['action:button', (name, target) => ({ type: 'action:button', properties: { label: 'Open', name, target } }),
`${BASE}.target`, (name) => `An \`action:button\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`],
['action:icon', (name, target) => ({ type: 'action:icon', properties: { label: 'Open', name, target } }),
`${BASE}.target`, (name) => `An \`action:icon\`'s \`target\` (\`name: '${name}'\` and no \`actionType\`, so the runner runs the url navigator)`],
['action:group member', (name, target) => ({ type: 'action:group', properties: { actions: [{ label: 'Open', name, target }] } }),
`${BASE}.actions.0.target`, (name) => `An \`action:group\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`],
['action:menu member', (name, target) => ({ type: 'action:menu', properties: { actions: [{ label: 'Open', name, target }] } }),
`${BASE}.actions.0.target`, (name) => `An \`action:menu\` member's \`target\` (\`name: '${name}'\` and no \`type\`, so the runner runs the url navigator)`],
];

describe("the props gate judges a name-leg row's `target` on the executor the runner runs", () => {
it.each(NAME_LEG_ROWS)("REFUSAL: %s named `url` or `navigation`, with no executor key, is one finding at its `target`", (_row, node, path, subject) => {
for (const name of ['url', 'navigation']) {
const findings = validateComponentProps(stackWith(node(name, 'javascript:alert(1)')));
expect(findings.map((f) => [f.rule, f.path]), name).toEqual([[COMPONENT_PROPS_INVALID, path]]);
expect(findings[0].message, name).toContain(`${subject(name)}${JAVASCRIPT_REST}`);
}
});

it.each(NAME_LEG_ROWS)('ACCEPTED CONTROL: %s named `url` with an admitted target reports nothing', (_row, node) => {
expect(validateComponentProps(stackWith(node('url', 'https://example.com/docs')))).toEqual([]);
});

it.each(NAME_LEG_ROWS)('CONTROL: %s named after another handler reports nothing at its `target`', (_row, node) => {
expect(validateComponentProps(stackWith(node('navigate_edit', 'convert_lead')))).toEqual([]);
});
});
Loading
Loading