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
14 changes: 9 additions & 5 deletions lib/experimental/dashboard-widgets/widget-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ function gutenberg_resolve_widget_action_href( $href, $dir_name ) {
* plus optional `download` / `openInNewTab` / `icon` / `relevance`. Drops
* incomplete or unsafe entries; dropped hrefs are reported through
* `_doing_it_wrong()`. With `$dir_name`, resolves widget-local file hrefs
* first. A malformed `icon` or `relevance` drops the key, never the action.
* first. A malformed `icon` or `relevance` drops the key, never the action;
* a `download` filename that sanitizes to nothing becomes `true`, the
* download under its original name.
*
* This is the registration gate for manifest-sourced widget types. Definitions
* registered only on the client do not pass through it; any future CPT/API
Expand Down Expand Up @@ -224,10 +226,12 @@ function gutenberg_sanitize_widget_actions( $actions, $dir_name = '' ) {
if ( is_bool( $action['download'] ) ) {
$entry['download'] = $action['download'];
} else {
$filename = sanitize_file_name( (string) $action['download'] );
if ( $filename ) {
$entry['download'] = $filename;
}
/*
* A filename that sanitizes to nothing keeps the download
* under the original name; only `false` means navigation.
*/
$filename = sanitize_file_name( (string) $action['download'] );
$entry['download'] = '' !== $filename ? $filename : true;
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/widget-dashboard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
- Remove unused dependency `@wordpress/primitives` ([#82103](https://github.com/WordPress/gutenberg/pull/82103)).
- Remove tsconfig project references to packages that are not dependencies ([#82106](https://github.com/WordPress/gutenberg/pull/82106)).

### Bug Fixes

- Keep the plain anchor for a `download: ''` link action
([#82073](https://github.com/WordPress/gutenberg/pull/82073)).

## 0.6.0 (2026-08-26)

### New Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type {
} from '@wordpress/widget-primitives';

/**
* Resolves the in-app route path of a link action. `download` and
* `openInNewTab` targets always keep the plain anchor: both mean a new
* document, which a client-side navigation cannot deliver.
* Resolves the in-app route path of a link action. `download` (any value
* but `false`) and `openInNewTab` keep the plain anchor: both mean a new
* document.
*
* @param {WidgetHostLinks | undefined} links The host's `links` capability.
* @param {WidgetAction} action The action whose target to resolve.
Expand All @@ -16,7 +16,10 @@ export function getActionRoute(
links: WidgetHostLinks | undefined,
action: WidgetAction
): string | null {
if ( ! links || action.download || action.openInNewTab ) {
const isDownload =
action.download !== undefined && action.download !== false;

if ( ! links || isDownload || action.openInNewTab ) {
return null;
}

Expand Down
55 changes: 55 additions & 0 deletions packages/widget-dashboard/src/test/get-action-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {
WidgetAction,
WidgetHostLinks,
} from '@wordpress/widget-primitives';
import { getActionRoute } from '../components/widget-actions/get-action-route';

const MATCHED_HREF = 'admin.php?page=dashboard&p=/reports';

const links: WidgetHostLinks = {
match: ( href ) => ( href === MATCHED_HREF ? '/reports' : null ),
Link: () => null,
};

const action: WidgetAction = {
id: 'report',
label: 'See report',
href: MATCHED_HREF,
};

describe( 'getActionRoute', () => {
it( 'resolves a plain navigation the host recognizes', () => {
expect( getActionRoute( links, action ) ).toBe( '/reports' );
} );

it( 'answers null without a links capability', () => {
expect( getActionRoute( undefined, action ) ).toBeNull();
} );

it( 'answers null for a href the host does not recognize', () => {
expect(
getActionRoute( links, { ...action, href: 'https://example.com' } )
).toBeNull();
} );

it.each( [ true, '', 'report.csv' ] )(
'keeps the plain anchor for download %p',
( download ) => {
expect(
getActionRoute( links, { ...action, download } )
).toBeNull();
}
);

it( 'treats download false as a navigation', () => {
expect( getActionRoute( links, { ...action, download: false } ) ).toBe(
'/reports'
);
} );

it( 'keeps the plain anchor for a new-tab target', () => {
expect(
getActionRoute( links, { ...action, openInNewTab: true } )
).toBeNull();
} );
} );
67 changes: 64 additions & 3 deletions packages/widget-dashboard/src/test/host-links.jsdom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,29 @@ describe( 'host links across the chrome compositions', () => {
icon: <svg />,
href: MATCHED_HREF,
},
{
id: 'snapshot',
label: 'Download snapshot',
relevance: 'high',
href: MATCHED_HREF,
download: '',
},
{
id: 'export',
label: 'Export data',
relevance: 'medium',
icon: <svg />,
href: 'files/export.csv',
href: MATCHED_HREF,
download: 'export.csv',
},
{
id: 'docs',
label: 'Documentation',
relevance: 'medium',
icon: <svg />,
href: MATCHED_HREF,
openInNewTab: true,
},
];

it( 'mounts the host link for a matched high action', () => {
Expand Down Expand Up @@ -113,15 +128,43 @@ describe( 'host links across the chrome compositions', () => {
expect( receivedRef ).toContain( true );
} );

/*
* These targets match; only the modifiers keep the plain anchor.
*/
it( 'keeps the plain anchor and the download for a download action', () => {
const { links } = createHost();
renderWithHost( <WidgetFooter actions={ footerActions } />, links );

const link = screen.getByRole( 'link', { name: 'Export data' } );
expect( link ).not.toHaveAttribute( 'data-host-link' );
expect( link ).toHaveAttribute( 'href', MATCHED_HREF );
expect( link ).toHaveAttribute( 'download', 'export.csv' );
} );

it( 'keeps the plain anchor for an empty-string download', () => {
const { links } = createHost();
renderWithHost( <WidgetFooter actions={ footerActions } />, links );

const link = screen.getByRole( 'link', {
name: 'Download snapshot',
} );
expect( link ).not.toHaveAttribute( 'data-host-link' );
expect( link ).toHaveAttribute( 'href', MATCHED_HREF );
expect( link ).toHaveAttribute( 'download', '' );
} );

it( 'keeps the plain anchor for a new-tab action', () => {
const { links } = createHost();
renderWithHost( <WidgetFooter actions={ footerActions } />, links );

const link = screen.getByRole( 'link', {
name: 'Documentation (opens in a new tab)',
} );
expect( link ).not.toHaveAttribute( 'data-host-link' );
expect( link ).toHaveAttribute( 'href', MATCHED_HREF );
expect( link ).toHaveAttribute( 'target', '_blank' );
} );

/*
* The tooltip anchors to the element the host link forwards its ref
* to; a link that drops it never opens the tooltip on hover.
Expand All @@ -143,20 +186,29 @@ describe( 'host links across the chrome compositions', () => {
} );

describe( 'WidgetActions menu', () => {
/*
* Every target matches; only the modifiers keep the plain anchor.
*/
const menuActions: WidgetAction[] = [
{ id: 'report', label: 'See report', href: MATCHED_HREF },
{
id: 'external',
label: 'External guide',
href: 'https://example.com/guide',
href: MATCHED_HREF,
openInNewTab: true,
},
{
id: 'export',
label: 'Export data',
href: 'files/export.csv',
href: MATCHED_HREF,
download: 'export.csv',
},
{
id: 'snapshot',
label: 'Download snapshot',
href: MATCHED_HREF,
download: '',
},
];

it( 'routes only the matched item through the host link', async () => {
Expand All @@ -177,13 +229,22 @@ describe( 'host links across the chrome compositions', () => {
name: /External guide/,
} );
expect( external ).not.toHaveAttribute( 'data-host-link' );
expect( external ).toHaveAttribute( 'href', MATCHED_HREF );
expect( external ).toHaveAttribute( 'target', '_blank' );

const download = screen.getByRole( 'menuitem', {
name: 'Export data',
} );
expect( download ).not.toHaveAttribute( 'data-host-link' );
expect( download ).toHaveAttribute( 'href', MATCHED_HREF );
expect( download ).toHaveAttribute( 'download', 'export.csv' );

const snapshot = screen.getByRole( 'menuitem', {
name: 'Download snapshot',
} );
expect( snapshot ).not.toHaveAttribute( 'data-host-link' );
expect( snapshot ).toHaveAttribute( 'href', MATCHED_HREF );
expect( snapshot ).toHaveAttribute( 'download', '' );
} );

/*
Expand Down
3 changes: 3 additions & 0 deletions packages/widget-primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
route's query when the href has one; docblock, README, Storybook page
and seam diagram say so
([#82066](https://github.com/WordPress/gutenberg/pull/82066)).
- `WidgetAction.download`: any value but `false` downloads; `true` or `''`
keep the original filename
([#82073](https://github.com/WordPress/gutenberg/pull/82073)).

## 0.6.0 (2026-08-26)

Expand Down
2 changes: 1 addition & 1 deletion packages/widget-primitives/src/stories/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The fulfillment is named by the key that carries it, not by a separate discrimin

Two more keys belong to the link, not to the envelope, so they only mean something alongside an `href`:

- `download`: download instead of navigate; a string sets the filename.
- `download`: downloads instead of navigating unless `false`; a string sets the filename, `true` or `''` keeps the original.
- `openInNewTab`: open in a new tab.

```ts
Expand Down
5 changes: 3 additions & 2 deletions packages/widget-primitives/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ export interface WidgetAction {
href: string;

/**
* Link only. When set, the destination downloads instead of navigating.
* A string supplies the suggested filename.
* Link only. When present and not `false`, downloads instead of
* navigating. A string sets the filename; `true` or `''` keeps the
* original.
*/
download?: string | boolean;

Expand Down
48 changes: 48 additions & 0 deletions phpunit/experimental/widget-types-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,54 @@ public function test_sanitize_widget_actions_envelope_extras() {
);
}

/**
* Every download value but `false` keeps the download: a usable filename
* survives sanitization, anything else falls back to the original name.
*/
public function test_sanitize_widget_actions_keeps_download_intent() {
$href = 'https://wordpress.org/export.csv';

$actions = gutenberg_sanitize_widget_actions(
array(
array(
'id' => 'empty',
'label' => 'Empty string',
'href' => $href,
'download' => '',
),
array(
'id' => 'zero',
'label' => 'Zero',
'href' => $href,
'download' => '0',
),
array(
'id' => 'unusable',
'label' => 'Unusable name',
'href' => $href,
'download' => '???',
),
array(
'id' => 'flag',
'label' => 'Boolean true',
'href' => $href,
'download' => true,
),
array(
'id' => 'off',
'label' => 'Boolean false',
'href' => $href,
'download' => false,
),
)
);

$this->assertSame(
array( true, '0', true, true, false ),
array_column( $actions, 'download' )
);
}

/**
* An empty or non-array actions list normalizes to null.
*/
Expand Down
Loading