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
10 changes: 10 additions & 0 deletions docs/plugin-compat-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,16 @@ Jetpack closes the panel on a press outside it, and a press on the tile is one.

**Test**: `tests/vitest/admin-bar-notes.test.ts`, against Jetpack's markup and a stand-in for its toggle.

### WooCommerce order row clicks

WooCommerce's `wc-orders.js` binds a delegated click handler to the body cells of the order list (`tbody td` and `tbody th`, minus `.check-column`), on both the legacy CPT table (`.post-type-shop_order .wp-list-table`, `edit.php?post_type=shop_order`) and the HPOS one (`.woocommerce_page_wc-orders .wp-list-table.orders`, `admin.php?page=wc-orders`). A click that lands on anything but a link, a button or a `.no-link` element (the date, the status badge, the total, the cell's padding) reads the row's `a.order-view` and runs `window.location = href`, so in classic admin the whole row acts as a link. Inside OpenStation that raw in-iframe navigation replaced the Orders list window in place, discarding the list.

**Fix**: `resolveOrderRowLink()` in `src/chromeless-bridge.js` claims those clicks first, in the capture phase. A click inside a body cell (`tbody td` / `tbody th`) of an order table resolves to the row's `a.order-view` and is routed through `os-iframe-admin-link`, so the order opens in its own window beside the list. It yields, and WooCommerce's own handling applies, when text is selected or the click lands on a control: `button`, `input`, `select`, `textarea`, `label`, `.check-column`, `.order-preview`, `.no-link` or `.wc_actions`. Header and footer cells are never claimed, so a click on a column title still sorts. Only a claimed row click calls `stopPropagation()` and `stopImmediatePropagation()`, which keeps WooCommerce's bubble-phase handler from navigating in place as well; ordinary link clicks still reach the page's own listeners.

The match also covers `table.wc-orders-list-table`, the class WooCommerce's HPOS list table gives every order type's list, so the list screens of other HPOS order types (an extension's, such as Subscriptions) get the same claim wherever their rows carry an `a.order-view` link, even though WooCommerce does not make those rows clickable.

**Test**: `tests/vitest/chromeless-bridge-links.test.ts`.

### Elementor 4's legacy menus

Elementor 4 keeps its old **Elementor** menu and the **Templates** menu registered for their URLs, under the new `elementor-home` menu, and hides both from the classic sidebar with CSS the dock can't see. **Fix**: `includes/compat/elementor.php` hides those two slugs through `openstation_dock_placement` once `elementor-home` exists. The same file prints the navigation ping in Elementor's editor head, which never fires `admin_head`. **Test**: `tests/phpunit/tests/openStationBuildDockItems.php`.
Expand Down
55 changes: 55 additions & 0 deletions src/chromeless-bridge.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check warning on line 1 in src/chromeless-bridge.js

View workflow job for this annotation

GitHub Actions / Lint · Types · Vitest

This file is 4134 lines — past the 1000-line comfort zone. Be smart, build robust software: modules of ~300–600 lines are easier to read, test, review and reuse. Consider splitting this one along its natural seams — future you will say thanks
* OpenStation — chromeless bridge (iframe side).
*
* Plain ES5-flavoured JavaScript on purpose: this runs inside a real
Expand Down Expand Up @@ -1343,14 +1343,65 @@
}
}

/**
* Find the primary order-view link when clicking on an order list table row.
*
* WooCommerce's `wc-orders.js` binds a row-level click handler on
* `.woocommerce_page_wc-orders` and `.post-type-shop_order` table cells
* that calls `window.location = href` when the user clicks anywhere on
* the row (date, status, total, cell whitespace) rather than on an
* anchor directly.
*
* Inside OpenStation, that raw `window.location` assignment replaces the
* Orders list window's content in place. Intercepting the row click here
* routes the order's edit URL through the parent shell instead, so it
* opens in its own window beside the list.
*
* @param {Element|EventTarget} target The clicked DOM element.
* @return {HTMLAnchorElement|null} The order link, or null if not applicable.
*/
function resolveOrderRowLink( target ) {
if ( ! target || ! target.closest ) {
return null;
}
var doc = target.ownerDocument || document;
var defaultView = doc.defaultView;
if ( defaultView && defaultView.getSelection && defaultView.getSelection().toString().length > 0 ) {
return null;
}
if ( target.closest( 'button, input, select, textarea, label, .check-column, .order-preview, .no-link, .wc_actions' ) ) {
return null;
}
var cell = target.closest(
'.post-type-shop_order .wp-list-table tbody td, ' +
'.post-type-shop_order .wp-list-table tbody th, ' +
'.woocommerce_page_wc-orders .wp-list-table tbody td, ' +
'.woocommerce_page_wc-orders .wp-list-table tbody th, ' +
'table.wc-orders-list-table tbody td, ' +
'table.wc-orders-list-table tbody th'
);
if ( ! cell ) {
return null;
}
var row = cell.closest( 'tr' );
return row ? row.querySelector( 'a.order-view' ) : null;
}

document.addEventListener( 'click', function ( e ) {
if ( e.defaultPrevented ) {
return;
}
if ( e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey ) {
return;
}
var isOrderRowClick = false;
var link = e.target && e.target.closest ? e.target.closest( 'a[href]' ) : null;
if ( ! link ) {
link = resolveOrderRowLink( e.target );
if ( link ) {
isOrderRowClick = true;
}
}
if ( ! link ) {
return;
}
Expand Down Expand Up @@ -1592,6 +1643,10 @@
* still works.
*/
e.preventDefault();
if ( isOrderRowClick ) {
e.stopPropagation();
e.stopImmediatePropagation();
}
try {
var absolute = new URL( rewritten || href, window.location.href ).toString();
/*
Expand Down
249 changes: 249 additions & 0 deletions tests/vitest/chromeless-bridge-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,255 @@ describe( 'chromeless bridge: which clicks reach the shell', () => {
history.replaceState( null, '', before );
}
} );

// WooCommerce's `wc-orders.js` binds a row-level click handler that
// calls `window.location = href` on clicks anywhere on the row
// (date, status, total, cell whitespace). In OpenStation, raw
// `window.location` wipes out the Orders list window in place. The
// bridge intercepts row clicks to route the order's edit URL through
// the parent shell into a new window.
test( 'WooCommerce order row clicks route to the order in a new window', () => {
document.body.innerHTML =
'<div class="woocommerce_page_wc-orders">' +
'<table class="wp-list-table widefat fixed striped table-view-list orders">' +
'<tbody>' +
'<tr id="order-1042">' +
'<td class="check-column"><input type="checkbox" name="id[]" value="1042"></td>' +
'<th class="order_number column-order_number has-row-actions column-primary" data-colname="Order" scope="row">' +
'<a href="#" class="order-preview" data-order-id="1042" title="Preview">Preview</a>' +
'<a href="/wp-admin/admin.php?page=wc-orders&action=edit&id=1042" class="order-view"><strong>#1042</strong> John Doe</a>' +
'<div class="order_date small-screen-only"><time datetime="2026-10-19">Oct 19, 2026</time></div>' +
'<div class="order_status small-screen-only"><mark class="order-status status-pending"><span>Pending payment</span></mark></div>' +
'<button type="button" class="toggle-row"><span class="screen-reader-text">Show more details</span></button>' +
'</th>' +
'<td class="order_date column-order_date"><time datetime="2026-10-19">Oct 19, 2026</time></td>' +
'<td class="order_status column-order_status"><mark class="order-status status-processing"><span>Processing</span></mark></td>' +
'<td class="order_total column-order_total"><span class="woocommerce-Price-amount amount">$99.00</span></td>' +
'<td class="wc_actions column-wc_actions"><a class="button wc-action-button" href="#"></a></td>' +
'</tr>' +
'</tbody>' +
'</table>' +
'</div>';

// 1. Click on the Date <time> element (not inside an <a>)
const time = document.querySelector( 'time' ) as HTMLElement;
time.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=1042'
);
expect( adminLinkMessages()[ 0 ].label ).toBe( '#1042 John Doe' );

// Clear messages
posted = [];

// 2. Click on the Status <mark> element
const status = document.querySelector( 'mark' ) as HTMLElement;
status.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=1042'
);

// Clear messages
posted = [];

// 3. Click on the Total <span> element
const total = document.querySelector( '.order_total span' ) as HTMLElement;
total.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );

// Clear messages
posted = [];

// 4. Click the checkbox - must NOT trigger navigation
const checkbox = document.querySelector( 'input[type="checkbox"]' ) as HTMLElement;
checkbox.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );

// 5. Click the preview button - must NOT trigger navigation
const previewBtn = document.querySelector( '.order-preview' ) as HTMLElement;
previewBtn.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );

// 5b. Click the action button inside .wc_actions - must NOT trigger navigation to the order
const actionBtn = document.querySelector( '.wc-action-button' ) as HTMLElement;
actionBtn.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );

// Clear messages
posted = [];

// 5c. Click the toggle-row button in the primary th - must NOT trigger navigation
const toggleBtn = document.querySelector( '.toggle-row' ) as HTMLElement;
toggleBtn.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );

// Clear messages
posted = [];

// 5d. Click whitespace in the primary order_number <th> cell - must route to the order
const thCell = document.querySelector( 'th.column-order_number' ) as HTMLElement;
thCell.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=1042'
);

// Clear messages
posted = [];

// 5e. Click small-screen elements inside the primary <th> cell - must route to the order
const smallScreenDate = document.querySelector( 'th .order_date time' ) as HTMLElement;
smallScreenDate.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=1042'
);

// Clear messages
posted = [];

const smallScreenStatus = document.querySelector( 'th .order_status mark' ) as HTMLElement;
smallScreenStatus.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=1042'
);

// Clear messages
posted = [];

// 6. Realistic HPOS table without .orders or .woocommerce_page_wc-orders on wrapper
document.body.innerHTML =
'<table class="wp-list-table widefat fixed striped wc-orders-list-table wc-orders-list-table-shop_order">' +
'<tbody>' +
'<tr id="order-2000">' +
'<th class="order_number column-order_number" scope="row"><a href="#" class="order-preview" data-order-id="2000">Preview</a><a href="/wp-admin/admin.php?page=wc-orders&action=edit&id=2000" class="order-view"><strong>#2000</strong> Jane Doe</a></th>' +
'<td class="order_date column-order_date">Oct 19, 2026</td>' +
'</tr>' +
'</tbody>' +
'</table>';

const hposCell = document.querySelector( '.order_date' ) as HTMLElement;
hposCell.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 1 );
expect( adminLinkMessages()[ 0 ].url ).toContain(
'/wp-admin/admin.php?page=wc-orders&action=edit&id=2000'
);

// Clear messages
posted = [];

// 7. Non-order table (e.g. posts list table) must NOT be routed as an order row even if it has an order-number-like cell
document.body.innerHTML =
'<table class="wp-list-table widefat fixed striped posts">' +
'<tbody>' +
'<tr id="post-50">' +
'<th class="column-order_number"><a href="/wp-admin/post.php?post=50&action=edit">Sample Post</a></th>' +
'<td class="title column-title"><a href="/wp-admin/post.php?post=50&action=edit">Sample Post</a></td>' +
'<td class="date column-date">Oct 19, 2026</td>' +
'</tr>' +
'</tbody>' +
'</table>';

const postCell = document.querySelector( '.column-date' ) as HTMLElement;
postCell.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );

// Clear messages
posted = [];

// 8. Clicking a non-link header cell (e.g. Status) must NOT trigger navigation or re-sort
document.body.innerHTML =
'<div class="woocommerce_page_wc-orders">' +
'<table class="wp-list-table widefat fixed striped table-view-list orders">' +
'<thead>' +
'<tr>' +
'<th class="column-order_number"><a href="/wp-admin/admin.php?page=wc-orders&orderby=order_number">Order</a></th>' +
'<th class="column-order_status">Status</th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
'<tr id="order-1042">' +
'<th class="order_number column-order_number"><a href="/wp-admin/admin.php?page=wc-orders&action=edit&id=1042" class="order-view">#1042</a></th>' +
'<td class="order_status column-order_status">Processing</td>' +
'</tr>' +
'</tbody>' +
'</table>' +
'</div>';

const headerStatus = document.querySelector( 'thead .column-order_status' ) as HTMLElement;
headerStatus.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);
expect( adminLinkMessages() ).toHaveLength( 0 );
} );

test( 'claimed order row navigation stops propagation to in-page listeners, but ordinary admin links do not', () => {
document.body.innerHTML =
'<div class="woocommerce_page_wc-orders">' +
'<table class="wp-list-table">' +
'<tbody>' +
'<tr>' +
'<td class="order_date"><time datetime="2026-10-19">Oct 19, 2026</time></td>' +
'<td><a href="/wp-admin/admin.php?page=wc-orders&action=edit&id=1042" class="order-view">#1042</a></td>' +
'</tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'<p><a href="/wp-admin/index.php" class="dashboard-link">Dashboard</a></p>';

let inPageListenerFired = false;
document.addEventListener( 'click', () => {
inPageListenerFired = true;
} );

// 1. Order row click must stop propagation so WooCommerce's handler does not fire
const time = document.querySelector( 'time' ) as HTMLElement;
time.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);

expect( adminLinkMessages() ).toHaveLength( 1 );
expect( inPageListenerFired ).toBe( false );

// Clear messages
posted = [];

// 2. Ordinary link click must NOT stop propagation so other in-page plugins can observe clicks
const normalLink = document.querySelector( '.dashboard-link' ) as HTMLElement;
normalLink.dispatchEvent(
new MouseEvent( 'click', { bubbles: true, cancelable: true } )
);

expect( adminLinkMessages() ).toHaveLength( 1 );
expect( inPageListenerFired ).toBe( true );
} );
} );

describe( 'chromeless bridge: the label a link ships to the shell', () => {
Expand Down
Loading