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
33 changes: 33 additions & 0 deletions .changeset/spec-action-open-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/spec": minor
---

Add `openIn` to `ActionSchema` — a declarative new-tab control for static `type:'url'` actions.

Counterpart to objectui issue #2043, which added a first-class `openIn?: 'self' | 'new-tab'`
field to its public `ActionSchema` and honors it in `ActionRunner.executeUrl` (read with
priority over the legacy `params.newTab` / external-URL heuristic). Until now
`@objectstack/spec`'s `ActionSchema` was a plain `z.object(...)` that **stripped** unknown
keys, so `openIn` written via `defineAction({...})` was silently dropped at build and never
reached objectui's runtime. Authors (e.g. plan-management) therefore couldn't use it.

```ts
defineAction({
name: 'print_a3',
label: '打印总表(A3)',
type: 'url',
target: '/print/a3?id=${record.id}',
openIn: 'new-tab', // now preserved end-to-end
});
```

- `openIn: 'new-tab'` — open a **static** `target` URL in a new tab. No handler, no pre-open.
- `openIn: 'self'` — navigate in place.
- omitted — external/absolute URLs open in a new tab; relative URLs navigate in place.

Kept distinct from the existing `opensInNewTab` / `newTabUrl` (those pre-open an
`about:blank` tab synchronously for **async** SSO-redirect handlers — not merged). It is a
static execution option and must stay OUT of `params` (which is user-input-collection only).

Consuming projects must upgrade `@objectstack/spec` to this version for the declarative
new-tab path to work end-to-end.
5 changes: 5 additions & 0 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@
"status": "live",
"note": "objectui."
},
"openIn": {
"status": "live",
"evidence": "objectui ActionRunner.executeUrl (objectui issue #2043)",
"note": "Declarative new-tab control for STATIC type:'url' targets. ActionRunner.executeUrl reads action.openIn with priority over the legacy params.newTab/external-URL heuristic; action-button/icon/menu/group + basic/elements renderers forward it. Distinct from opensInNewTab/newTabUrl (async SSO pre-open)."
},
"timeout": {
"status": "dead",
"evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer"
Expand Down
61 changes: 61 additions & 0 deletions packages/spec/src/ui/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,67 @@ describe('ActionSchema', () => {
});
});

// ============================================================================
// openIn — declarative new-tab control for static type:'url' targets (#2043)
// ============================================================================

describe('ActionSchema - openIn', () => {
it('should accept openIn: "new-tab" on a static url action', () => {
const result = ActionSchema.parse({
name: 'print_a3',
label: 'Print A3',
type: 'url',
target: '/print/a3?id=${record.id}',
openIn: 'new-tab',
});
expect(result.openIn).toBe('new-tab');
});

it('should accept openIn: "self"', () => {
const result = ActionSchema.parse({
name: 'open_inline',
label: 'Open Inline',
type: 'url',
target: '/somewhere',
openIn: 'self',
});
expect(result.openIn).toBe('self');
});

it('should leave openIn undefined when omitted', () => {
const result = ActionSchema.parse({
name: 'plain_url',
label: 'Plain URL',
type: 'url',
target: 'https://example.com',
});
expect(result.openIn).toBeUndefined();
});

it('should preserve openIn through defineAction (not stripped at build)', () => {
// Regression guard for #2043 counterpart: a plain z.object() stripped
// unknown keys, so openIn never reached objectui. It must survive parse.
const result = ActionSchema.parse({
name: 'print_a3',
label: '打印总表(A3)',
type: 'url',
target: '/print/a3?id=${record.id}',
openIn: 'new-tab',
});
expect(result.openIn).toBe('new-tab');
});

it('should reject an invalid openIn value', () => {
expect(() => ActionSchema.parse({
name: 'bad_openin',
label: 'Bad',
type: 'url',
target: '/x',
openIn: 'newtab',
})).toThrow();
});
});

describe('Action Factory', () => {
it('should create action with default values via factory', () => {
const action = Action.create({
Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,24 @@ export const ActionSchema = lazySchema(() => z.object({
*/
target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint. Supports ${param.X} and ${ctx.X} interpolation.'),

/**
* For `type:'url'` — where to open `target`. A simple, declarative new-tab
* control for STATIC urls (no handler, no synchronous pre-open). objectui's
* ActionRunner.executeUrl reads `openIn` with priority over the legacy
* `params.newTab`/external-URL heuristic.
*
* - `'new-tab'` — opens `target` in a new browser tab.
* - `'self'` — navigates in place.
* - omitted — external/absolute URLs open in a new tab; relative URLs
* navigate in place.
*
* Distinct from `opensInNewTab`/`newTabUrl`, which pre-open an about:blank
* tab synchronously for ASYNC SSO-redirect handlers — do NOT use `openIn`
* for those. This is a STATIC execution option: keep it OUT of `params`
* (which is user-input-collection only).
*/
openIn: z.enum(['self', 'new-tab']).optional().describe("For type:'url' — where to open `target`. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of `params` (which is user-input-collection only)."),

/**
* Action Body (L1 expression or L2 sandboxed JS).
*
Expand Down
48 changes: 40 additions & 8 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1359,20 +1359,44 @@ export const AddToCampaignAction = defineAction({
});
```

### Opening in a New Tab (`opensInNewTab` / `newTabUrl`)
### Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`)

For actions that should land in a new browser tab, set `opensInNewTab: true`
(#1787). The renderer pre-opens the tab **synchronously** on click so popup
blockers don't fire, then navigates it to the handler's returned `redirectUrl`.
There are **two** mechanisms here. Pick by whether the URL is static or computed:

For external deep-links / SSO with no server round-trip, add `newTabUrl` — a
direct URL template (supports the `{recordId}` placeholder). It is valid **only**
alongside `opensInNewTab: true`, and the target endpoint must enforce its own
auth (the new tab carries no in-app session context).
#### `openIn: 'new-tab'` — simplest case (static `target`)

When you have a **static** `target` URL (relative or absolute) you just want
opened in a new tab, set `openIn: 'new-tab'` on a `type: 'url'` action. No
handler, no synchronous pre-open. `openIn: 'self'` forces in-place navigation;
omit it and external/absolute URLs open in a new tab while relative URLs
navigate in place. objectui's `ActionRunner.executeUrl` reads `openIn` with
priority over the legacy heuristic.

```typescript
import { defineAction } from '@objectstack/spec/ui';

export const PrintA3Action = defineAction({
name: 'print_a3',
label: '打印总表(A3)',
type: 'url',
target: '/print/a3?id=${record.id}', // static template; interpolated at click
openIn: 'new-tab',
locations: ['list_toolbar'],
});
```

#### `opensInNewTab` + `newTabUrl` — async / computed redirect (SSO)

For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like
handlers), set `opensInNewTab: true` (#1787). The renderer pre-opens the tab
**synchronously** on click so popup blockers don't fire, then navigates it to
the handler's returned `redirectUrl`. For external deep-links with no server
round-trip, add `newTabUrl` — a direct URL template (supports the `{recordId}`
placeholder). It is valid **only** alongside `opensInNewTab: true`, and the
target endpoint must enforce its own auth (the new tab carries no in-app session
context).

```typescript
export const OpenInvoicePdfAction = defineAction({
name: 'open_invoice_pdf',
label: 'Open PDF',
Expand All @@ -1384,6 +1408,14 @@ export const OpenInvoicePdfAction = defineAction({
});
```

> ⚠️ **Never express new-tab behavior via `params`.** `params` is exclusively
> `ActionParam[]` for collecting **user input**. Writing an object form like
> `params: { newTab: true }` fails the zod build outright; the array form
> `params: [{ name: 'newTab', type: 'checkbox' }]` *builds* but mis-renders as a
> user-facing checkbox in the param-collection dialog. Use `openIn` (static) or
> `opensInNewTab`/`newTabUrl` (async) instead — these are static execution
> options, not inputs.

### Action Parameter Patterns

Prefer **field-backed** params (`{ field: 'email' }`) over inline declarations
Expand Down