From e89a1e9feae7cf6d7dc10915d762c720fd219bda Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 00:05:59 +0000 Subject: [PATCH] fix(plugin-dashboard): a url header action goes through the action runner, and an omitted actionType runs as url (objectui#12275) The header action click kept a url grammar of its own: `location.assign` for `^https?://` or a leading `//`, `history.pushState` for anything else. So a protocol-relative `//host` was opened, `mailto:` threw, a bare relative value was pushed as a route, and an omitted `actionType` was dropped with a warning although `headerActionDefs` reads it as `url`. The click now dispatches the def `headerActionDefs` builds, every type alike, so the runner's `classifyNavigationTarget` decides what a url target may open (ruling A+ on objectui#12191). With no navigation handler the runner hands an admitted in-app path back as `redirect`; the click follows that hand-off with the same history push it used before. A url header action raises no success toast, as before. Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude --- .../12275-dashboard-header-action-runner.md | 17 ++ .../src/DashboardRenderer.tsx | 65 +++-- ...ardRenderer.headerActionUrl-12275.test.tsx | 236 ++++++++++++++++++ .../DashboardRenderer.headerActions.test.tsx | 21 +- 4 files changed, 298 insertions(+), 41 deletions(-) create mode 100644 .changeset/12275-dashboard-header-action-runner.md create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActionUrl-12275.test.tsx diff --git a/.changeset/12275-dashboard-header-action-runner.md b/.changeset/12275-dashboard-header-action-runner.md new file mode 100644 index 0000000000..5cf8a688bb --- /dev/null +++ b/.changeset/12275-dashboard-header-action-runner.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +A dashboard header action of type `url`, or of no declared type, now goes through the action runner, like every other header action (objectui#12275). + +The click used to navigate by a url check of its own and never reached the runner's `classifyNavigationTarget`, which decides what a click may open (ruling A+ on objectui#12191). So: + +- **A protocol-relative `//host` target is refused at the click.** It used to be opened with `location.assign`. A bare relative value such as `thanks` is refused too; it used to be pushed as a route. Under the console the runner's error toast reports the refusal; with no action provider the dashboard logs it. +- **A `mailto:` target opens the mail client in place.** It used to throw in `history.pushState`. +- **A header action with no `actionType` runs as a `url` action.** The contract and the dashboard's own action list read an omitted type as `url`, but the click dropped it with a "missing actionType" warning, so the button did nothing. +- **An `https://` target opens the way every runner `url` action opens one**: through the host's navigation handler, which in the console opens it in a new tab. It used to replace the current page. +- **An in-app path such as `/apps/...` still navigates in the app**: through the host's router under the console, and, with no action provider, by the same history push as before. + +Following a `url` header action raises no success toast, as before. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index 3a7628c590..bcd4a5fd37 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -451,15 +451,25 @@ const DashboardRendererInner = forwardRef(() => { const actions = schema.header?.actions ?? []; return actions.map((a) => { const label = pickLocalized(a.label, language) || undefined; + const type = (a.actionType as ActionDef['type']) || 'url'; return { name: a.actionUrl || label, - type: (a.actionType as ActionDef['type']) || 'url', + type, target: a.actionUrl, label, + // Following a link is not a feedback-worthy action, so a `url` header + // action raises no success toast, as it raised none before it went + // through the runner (the html-tier anchor suppresses it the same + // way). The error toast stays: a refused target is reported. + ...(type === 'url' ? { toast: { showOnSuccess: false } } : {}), }; }); }, [schema.header?.actions, language]); @@ -1208,32 +1218,39 @@ const DashboardRendererInner = forwardRef { const Icon = resolveLucideIcon(action.icon); const handleClick = async () => { - const { actionType, actionUrl } = action; - if (!actionType || !actionUrl) { - console.warn('[DashboardRenderer] Header action missing actionType/actionUrl:', action); + // Every header action goes through the ActionRunner, which + // owns the type registry. This used to allow-list `modal` / + // `script` only, so a `flow` header action (and `api` / `form` + // / `navigation`) fell through to a warn and never dispatched — + // a screen flow could not even be launched from a dashboard + // (framework#3528). The runner reports an unknown type itself, + // so there is nothing to second-guess here. + // + // `url` included (objectui#12275). It used to navigate here, + // by a url grammar of its own: a protocol-relative `//host` + // was assigned, `mailto:` threw in `pushState`, a bare + // relative value was pushed as a route, and an omitted + // `actionType` was dropped with a warning although the def + // above reads it as `url`. The runner's `classifyNavigationTarget` + // is the one answer to what a click may open (ruling A+ on + // objectui#12191). ⛔ No scheme test of its own here. + // + // `actionUrl` is the name the def registered under. + const result = await executeAction(action.actionUrl); + if (!result?.success) { + console.warn('[DashboardRenderer] action failed', result?.error); return; } - if (actionType === 'url') { - if (/^https?:\/\//.test(actionUrl) || actionUrl.startsWith('//')) { - window.location.assign(actionUrl); - } else { - // SPA-friendly navigation: use history API + popstate so React Router picks it up. - window.history.pushState({}, '', actionUrl); - window.dispatchEvent(new PopStateEvent('popstate')); - } - return; + // With no navigation handler (no `ActionProvider` above, so + // no host router), the runner does not navigate to an + // admitted in-app path: it hands it back as `redirect`. + // Follow that hand-off for a `url` action, SPA-style, as this + // click did before: history API + popstate so a router that + // listens picks it up. `pushState` cannot leave the origin. + if (result.redirect && headerActionDefs[i]?.type === 'url') { + window.history.pushState({}, '', result.redirect); + window.dispatchEvent(new PopStateEvent('popstate')); } - // Everything that is not a raw navigation goes through the - // ActionRunner, which owns the type registry. This used to - // allow-list `modal` / `script` only, so a `flow` header action - // (and `api` / `form` / `navigation`) fell through to a warn and - // never dispatched — a screen flow could not even be launched - // from a dashboard (framework#3528). The runner reports an - // unknown type itself, so there is nothing to second-guess here. - // `actionUrl` is non-empty here (the guard above returns - // otherwise), and it is the name the def registered under. - const result = await executeAction(actionUrl); - if (!result?.success) console.warn('[DashboardRenderer] action failed', result?.error); }; return (