Skip to content

Commit ecf193f

Browse files
authored
feat(spec): add ActionSchema.openIn for declarative url new-tab (objectui #2043) (#2413)
1 parent 13dbcf2 commit ecf193f

5 files changed

Lines changed: 157 additions & 8 deletions

File tree

.changeset/spec-action-open-in.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Add `openIn` to `ActionSchema` — a declarative new-tab control for static `type:'url'` actions.
6+
7+
Counterpart to objectui issue #2043, which added a first-class `openIn?: 'self' | 'new-tab'`
8+
field to its public `ActionSchema` and honors it in `ActionRunner.executeUrl` (read with
9+
priority over the legacy `params.newTab` / external-URL heuristic). Until now
10+
`@objectstack/spec`'s `ActionSchema` was a plain `z.object(...)` that **stripped** unknown
11+
keys, so `openIn` written via `defineAction({...})` was silently dropped at build and never
12+
reached objectui's runtime. Authors (e.g. plan-management) therefore couldn't use it.
13+
14+
```ts
15+
defineAction({
16+
name: 'print_a3',
17+
label: '打印总表(A3)',
18+
type: 'url',
19+
target: '/print/a3?id=${record.id}',
20+
openIn: 'new-tab', // now preserved end-to-end
21+
});
22+
```
23+
24+
- `openIn: 'new-tab'` — open a **static** `target` URL in a new tab. No handler, no pre-open.
25+
- `openIn: 'self'` — navigate in place.
26+
- omitted — external/absolute URLs open in a new tab; relative URLs navigate in place.
27+
28+
Kept distinct from the existing `opensInNewTab` / `newTabUrl` (those pre-open an
29+
`about:blank` tab synchronously for **async** SSO-redirect handlers — not merged). It is a
30+
static execution option and must stay OUT of `params` (which is user-input-collection only).
31+
32+
Consuming projects must upgrade `@objectstack/spec` to this version for the declarative
33+
new-tab path to work end-to-end.

packages/spec/liveness/action.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@
142142
"status": "live",
143143
"note": "objectui."
144144
},
145+
"openIn": {
146+
"status": "live",
147+
"evidence": "objectui ActionRunner.executeUrl (objectui issue #2043)",
148+
"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)."
149+
},
145150
"timeout": {
146151
"status": "dead",
147152
"evidence": "action-level timeout DEAD — server uses body.timeoutMs; no UI consumer"

packages/spec/src/ui/action.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,67 @@ describe('ActionSchema', () => {
510510
});
511511
});
512512

513+
// ============================================================================
514+
// openIn — declarative new-tab control for static type:'url' targets (#2043)
515+
// ============================================================================
516+
517+
describe('ActionSchema - openIn', () => {
518+
it('should accept openIn: "new-tab" on a static url action', () => {
519+
const result = ActionSchema.parse({
520+
name: 'print_a3',
521+
label: 'Print A3',
522+
type: 'url',
523+
target: '/print/a3?id=${record.id}',
524+
openIn: 'new-tab',
525+
});
526+
expect(result.openIn).toBe('new-tab');
527+
});
528+
529+
it('should accept openIn: "self"', () => {
530+
const result = ActionSchema.parse({
531+
name: 'open_inline',
532+
label: 'Open Inline',
533+
type: 'url',
534+
target: '/somewhere',
535+
openIn: 'self',
536+
});
537+
expect(result.openIn).toBe('self');
538+
});
539+
540+
it('should leave openIn undefined when omitted', () => {
541+
const result = ActionSchema.parse({
542+
name: 'plain_url',
543+
label: 'Plain URL',
544+
type: 'url',
545+
target: 'https://example.com',
546+
});
547+
expect(result.openIn).toBeUndefined();
548+
});
549+
550+
it('should preserve openIn through defineAction (not stripped at build)', () => {
551+
// Regression guard for #2043 counterpart: a plain z.object() stripped
552+
// unknown keys, so openIn never reached objectui. It must survive parse.
553+
const result = ActionSchema.parse({
554+
name: 'print_a3',
555+
label: '打印总表(A3)',
556+
type: 'url',
557+
target: '/print/a3?id=${record.id}',
558+
openIn: 'new-tab',
559+
});
560+
expect(result.openIn).toBe('new-tab');
561+
});
562+
563+
it('should reject an invalid openIn value', () => {
564+
expect(() => ActionSchema.parse({
565+
name: 'bad_openin',
566+
label: 'Bad',
567+
type: 'url',
568+
target: '/x',
569+
openIn: 'newtab',
570+
})).toThrow();
571+
});
572+
});
573+
513574
describe('Action Factory', () => {
514575
it('should create action with default values via factory', () => {
515576
const action = Action.create({

packages/spec/src/ui/action.zod.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,24 @@ export const ActionSchema = lazySchema(() => z.object({
283283
*/
284284
target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint. Supports ${param.X} and ${ctx.X} interpolation.'),
285285

286+
/**
287+
* For `type:'url'` — where to open `target`. A simple, declarative new-tab
288+
* control for STATIC urls (no handler, no synchronous pre-open). objectui's
289+
* ActionRunner.executeUrl reads `openIn` with priority over the legacy
290+
* `params.newTab`/external-URL heuristic.
291+
*
292+
* - `'new-tab'` — opens `target` in a new browser tab.
293+
* - `'self'` — navigates in place.
294+
* - omitted — external/absolute URLs open in a new tab; relative URLs
295+
* navigate in place.
296+
*
297+
* Distinct from `opensInNewTab`/`newTabUrl`, which pre-open an about:blank
298+
* tab synchronously for ASYNC SSO-redirect handlers — do NOT use `openIn`
299+
* for those. This is a STATIC execution option: keep it OUT of `params`
300+
* (which is user-input-collection only).
301+
*/
302+
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)."),
303+
286304
/**
287305
* Action Body (L1 expression or L2 sandboxed JS).
288306
*

skills/objectstack-ui/SKILL.md

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,20 +1359,44 @@ export const AddToCampaignAction = defineAction({
13591359
});
13601360
```
13611361

1362-
### Opening in a New Tab (`opensInNewTab` / `newTabUrl`)
1362+
### Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`)
13631363

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

1368-
For external deep-links / SSO with no server round-trip, add `newTabUrl` — a
1369-
direct URL template (supports the `{recordId}` placeholder). It is valid **only**
1370-
alongside `opensInNewTab: true`, and the target endpoint must enforce its own
1371-
auth (the new tab carries no in-app session context).
1366+
#### `openIn: 'new-tab'` — simplest case (static `target`)
1367+
1368+
When you have a **static** `target` URL (relative or absolute) you just want
1369+
opened in a new tab, set `openIn: 'new-tab'` on a `type: 'url'` action. No
1370+
handler, no synchronous pre-open. `openIn: 'self'` forces in-place navigation;
1371+
omit it and external/absolute URLs open in a new tab while relative URLs
1372+
navigate in place. objectui's `ActionRunner.executeUrl` reads `openIn` with
1373+
priority over the legacy heuristic.
13721374

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

1378+
export const PrintA3Action = defineAction({
1379+
name: 'print_a3',
1380+
label: '打印总表(A3)',
1381+
type: 'url',
1382+
target: '/print/a3?id=${record.id}', // static template; interpolated at click
1383+
openIn: 'new-tab',
1384+
locations: ['list_toolbar'],
1385+
});
1386+
```
1387+
1388+
#### `opensInNewTab` + `newTabUrl` — async / computed redirect (SSO)
1389+
1390+
For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like
1391+
handlers), set `opensInNewTab: true` (#1787). The renderer pre-opens the tab
1392+
**synchronously** on click so popup blockers don't fire, then navigates it to
1393+
the handler's returned `redirectUrl`. For external deep-links with no server
1394+
round-trip, add `newTabUrl` — a direct URL template (supports the `{recordId}`
1395+
placeholder). It is valid **only** alongside `opensInNewTab: true`, and the
1396+
target endpoint must enforce its own auth (the new tab carries no in-app session
1397+
context).
1398+
1399+
```typescript
13761400
export const OpenInvoicePdfAction = defineAction({
13771401
name: 'open_invoice_pdf',
13781402
label: 'Open PDF',
@@ -1384,6 +1408,14 @@ export const OpenInvoicePdfAction = defineAction({
13841408
});
13851409
```
13861410

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

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

0 commit comments

Comments
 (0)