From 5fcc754ca17f556602a9549778461100a6af0070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 28 Jun 2026 13:20:22 -0700 Subject: [PATCH] feat(spec): add ActionSchema.openIn for declarative url new-tab (objectui #2043) @objectstack/spec's ActionSchema was a plain z.object() that stripped unknown keys, so the openIn field authored via defineAction({...}) was silently dropped at build and never reached objectui's ActionRunner. Add openIn?: 'self' | 'new-tab' next to target. objectui (issue #2043) already honors it in ActionRunner.executeUrl with priority over the legacy params.newTab/external-URL heuristic; renderers forward it. Kept distinct from opensInNewTab/newTabUrl (async SSO pre-open). It is a static execution option and must stay out of params. - packages/spec/src/ui/action.zod.ts: add openIn enum field - action.test.ts: 5 cases incl. not-stripped-at-build regression guard - liveness/action.json: classify openIn live (objectui consumer) - skills/objectstack-ui/SKILL.md: document openIn vs opensInNewTab split + warn against expressing new-tab via params - changeset: @objectstack/spec minor --- .changeset/spec-action-open-in.md | 33 ++++++++++++++++ packages/spec/liveness/action.json | 5 +++ packages/spec/src/ui/action.test.ts | 61 +++++++++++++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 18 +++++++++ skills/objectstack-ui/SKILL.md | 48 +++++++++++++++++++---- 5 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 .changeset/spec-action-open-in.md diff --git a/.changeset/spec-action-open-in.md b/.changeset/spec-action-open-in.md new file mode 100644 index 0000000000..c08dad3468 --- /dev/null +++ b/.changeset/spec-action-open-in.md @@ -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. diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 3a6abd1be9..fc646a91d3 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -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" diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index b8c025db65..e6596622f7 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -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({ diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 84a70d66d2..d840258e69 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -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). * diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 6afcbc4dad..3577a9271d 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -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', @@ -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