feat(sandbox): page the Blocks, Widgets and PHP Snippets tables

All three Sandbox screens rendered every artifact in one table, and each row
carries that artifact's source: the snippets table ships every snippet's whole
PHP into the DOM, widgets ship compiled PHP, blocks ship block.json plus
render.php. With 26 snippets the page carried 26 code blocks; paged, page two
carries six.

The stores also asked for a flat posts_per_page 200 with no_found_rows, so a
site past its 200th artifact lost the rest with nothing on screen saying so.

Two bugs a live smoke caught on real data, neither visible to php -l or to a
stubbed test:

- ORDER BY post_modified is not a total order. 26 seeded snippets shared two
  timestamps, and across two pages one row appeared twice while another was
  unreachable: 25 of 26 visible. Fixed with an ID tiebreaker, re-measured at
  26 of 26.
- The out-of-range clamp was dead code. WP_Query::set_found_posts() bails when
  a page comes back empty, so such a page reports found_posts = 0 and a clamp
  testing "total > 0" can never fire in the case it was written for. Confirmed
  against the database: LIMIT 490,5 on a one-row table returns 0. It now
  re-queries page 1, which does carry the total, then lands on the last real
  page.

- class-sandbox-list-query.php: one paged query for all three artifact post
  types, returning a page of IDs plus the real total.
- class-admin-pager.php: the windowed pager, lifted out of page-marketplace.php
  rather than copied three more times. Marketplace now renders through it too,
  so there is one pager to style. An ellipsis no longer stands in for a single
  page, which the inherited version could do.
- The three stores gain list_*_page(); the flat list_*() are untouched, since
  the MCP tools want the whole list in one call.
- Each view shows "Showing 1-20 of 57" above the table and the pager below.

Live-verified on msrplugins.test: page 1 and page 2 render with working links,
row actions and code blocks intact, Prev linking back to the clean URL, and the
Marketplace unchanged at 36 results across 2 pages.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Mian Shahzad Raza
2026-08-23 21:32:41 +05:00
co-authored by Claude Opus 5
parent f5ad66ed42
commit b7cfe45d58
11 changed files with 568 additions and 62 deletions
+40
View File
@@ -4786,3 +4786,43 @@ body[data-emcp-auth="app-password"] [data-authfor="oauth"] { display: none; }
@media screen and (max-width: 782px) {
.emcp-toasts { right: 12px; bottom: 12px; width: calc(100vw - 24px); }
}
/* -------------------------------------------------------------------------
* Pagination (EMCP_Tools_Admin_Pager)
*
* Shared by the Marketplace grid and the three Sandbox artifact tables. The
* disabled Prev/Next placeholders keep the control the same width at either
* end of the run, so it does not shift as you page through.
* ---------------------------------------------------------------------- */
.emcp-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
margin: 24px 0 4px;
}
.emcp-pager__num {
min-width: 34px;
text-align: center;
justify-content: center;
}
.emcp-pager__gap {
padding: 0 4px;
color: #787c82;
}
.emcp-pager .button.disabled {
pointer-events: none;
opacity: .5;
}
/* The "Showing 1-20 of 57" line above a paged table. */
.emcp-pager-count {
margin: 12px 0 4px;
font-size: 12px;
color: #787c82;
}
+200
View File
@@ -0,0 +1,200 @@
<?php
/**
* The admin pager, shared by every paged list screen.
*
* This started as sixty lines inline in the Marketplace view. The Sandbox
* screens (Blocks / Widgets / PHP Snippets) needed the same control, so the
* window maths and the markup moved here rather than being copied three more
* times. The Marketplace now renders through this class too, so there is one
* pager to style and one place where its behaviour is defined.
*
* `window()` is pure on purpose: which numbers appear around the current page
* is the only part with logic worth pinning in a test.
*
* @package EMCP_Tools
* @since 3.15.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Windowed pagination control for admin list screens.
*
* @since 3.15.0
*/
class EMCP_Tools_Admin_Pager {
/**
* Page counts up to this many are shown in full, with no ellipsis.
*
* @since 3.15.0
* @var int
*/
const FULL_RUN = 7;
/**
* Marks an ellipsis in a `window()` result. Zero is never a page number,
* so it cannot collide with one.
*
* @since 3.15.0
* @var int
*/
const GAP = 0;
/**
* Reads the requested page number from the query string.
*
* @since 3.15.0
*
* @param string $key Query argument holding the page number.
* @return int 1 or higher.
*/
public static function current( string $key = 'paged' ): int {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination, no state change.
$raw = isset( $_GET[ $key ] ) ? wp_unslash( $_GET[ $key ] ) : '';
return max( 1, absint( is_scalar( $raw ) ? $raw : 0 ) );
}
/**
* The page numbers to show, with self::GAP standing in for an ellipsis.
*
* Short runs are listed in full. Longer ones keep the first page, the last
* page, and the current page with a neighbour either side, so the control
* stays a fixed width however many pages there are.
*
* @since 3.15.0
*
* @param int $current Current page (1-based).
* @param int $pages Total pages.
* @return int[] Page numbers, with self::GAP for each ellipsis.
*/
public static function window( int $current, int $pages ): array {
$pages = max( 1, $pages );
$current = min( max( 1, $current ), $pages );
if ( $pages <= self::FULL_RUN ) {
return range( 1, $pages );
}
// The pages worth keeping: both ends, and the current one with a
// neighbour either side.
$keep = array( 1, $pages );
for ( $n = $current - 1; $n <= $current + 1; $n++ ) {
if ( $n >= 1 && $n <= $pages ) {
$keep[] = $n;
}
}
$keep = array_unique( $keep );
sort( $keep );
// Close the holes between them. A hole of exactly one page becomes that
// page: an ellipsis is wider than the single number it would hide.
$out = array();
foreach ( $keep as $i => $n ) {
if ( $i > 0 ) {
$hole = $n - $keep[ $i - 1 ];
if ( 2 === $hole ) {
$out[] = $n - 1;
} elseif ( $hole > 2 ) {
$out[] = self::GAP;
}
}
$out[] = $n;
}
return $out;
}
/**
* Renders the pager, or an empty string when there is only one page.
*
* @since 3.15.0
*
* @param int $current Current page (1-based).
* @param int $pages Total pages.
* @param callable $href Given a page number, returns its URL.
* @return string Escaped HTML.
*/
public static function render( int $current, int $pages, callable $href ): string {
$pages = max( 1, $pages );
if ( $pages < 2 ) {
return '';
}
$current = min( max( 1, $current ), $pages );
$out = '<nav class="emcp-pager" aria-label="' . esc_attr__( 'Pagination', 'emcp-tools' ) . '">';
$out .= self::edge( $current > 1, $href, $current - 1, '&larr; ' . esc_html__( 'Prev', 'emcp-tools' ) );
foreach ( self::window( $current, $pages ) as $n ) {
if ( self::GAP === $n ) {
$out .= '<span class="emcp-pager__gap" aria-hidden="true">&hellip;</span>';
} elseif ( $n === $current ) {
$out .= '<span class="button button-primary emcp-pager__num" aria-current="page">' . esc_html( (string) $n ) . '</span>';
} else {
$out .= '<a class="button emcp-pager__num" href="' . esc_url( (string) $href( $n ) ) . '">' . esc_html( (string) $n ) . '</a>';
}
}
$out .= self::edge( $current < $pages, $href, $current + 1, esc_html__( 'Next', 'emcp-tools' ) . ' &rarr;' );
$out .= '</nav>';
return $out;
}
/**
* A Prev/Next control: a link when there is somewhere to go, otherwise a
* disabled placeholder so the control does not change width at the ends.
*
* @since 3.15.0
*
* @param bool $enabled Whether the target page exists.
* @param callable $href Given a page number, returns its URL.
* @param int $target Page to link to.
* @param string $label Pre-escaped label.
* @return string
*/
private static function edge( bool $enabled, callable $href, int $target, string $label ): string {
if ( ! $enabled ) {
return '<span class="button disabled emcp-pager__edge">' . $label . '</span>';
}
return '<a class="button emcp-pager__edge" href="' . esc_url( (string) $href( $target ) ) . '">' . $label . '</a>';
}
/**
* The "Showing 1-20 of 57" line that sits above a paged table.
*
* Returns a bare count when everything fits on one page: a range reading
* "1-4 of 4" is noise.
*
* @since 3.15.0
*
* @param int $page Current page (1-based).
* @param int $per_page Rows per page.
* @param int $shown Rows on this page.
* @param int $total Rows in total.
* @return string Escaped text.
*/
public static function summary( int $page, int $per_page, int $shown, int $total ): string {
if ( $total <= 0 ) {
return '';
}
if ( $shown >= $total ) {
/* translators: %d: number of items. */
return esc_html( sprintf( _n( '%d item', '%d items', $total, 'emcp-tools' ), $total ) );
}
$first = ( max( 1, $page ) - 1 ) * max( 1, $per_page ) + 1;
return esc_html(
sprintf(
/* translators: 1: first item number, 2: last item number, 3: total items. */
__( 'Showing %1$d-%2$d of %3$d', 'emcp-tools' ),
$first,
$first + $shown - 1,
$total
)
);
}
}
+6 -58
View File
@@ -239,60 +239,12 @@ $emcp_per_page = 24;
<?php endforeach; ?>
</div>
<?php if ( $emcp_pages > 1 ) : ?>
<nav class="emcp-mk-pager" aria-label="<?php esc_attr_e( 'Pagination', 'emcp-tools' ); ?>">
<?php if ( $emcp_cur_page > 1 ) : ?>
<a class="button emcp-mk-pager__edge" href="<?php echo esc_url( $emcp_page_href( $emcp_cur_page - 1 ) ); ?>">&larr; <?php esc_html_e( 'Prev', 'emcp-tools' ); ?></a>
<?php else : ?>
<span class="button disabled emcp-mk-pager__edge">&larr; <?php esc_html_e( 'Prev', 'emcp-tools' ); ?></span>
<?php endif; ?>
<?php
// Windowed page numbers: first, last, current ±1, ellipses.
$emcp_nums = array();
if ( $emcp_pages <= 7 ) {
for ( $i = 1; $i <= $emcp_pages; $i++ ) {
$emcp_nums[] = $i;
}
} else {
$emcp_nums[] = 1;
$emcp_lo = max( 2, $emcp_cur_page - 1 );
$emcp_hi = min( $emcp_pages - 1, $emcp_cur_page + 1 );
if ( $emcp_lo > 2 ) {
$emcp_nums[] = 0;
}
for ( $i = $emcp_lo; $i <= $emcp_hi; $i++ ) {
$emcp_nums[] = $i;
}
if ( $emcp_hi < $emcp_pages - 1 ) {
$emcp_nums[] = 0;
}
$emcp_nums[] = $emcp_pages;
}
foreach ( $emcp_nums as $emcp_n ) :
if ( 0 === $emcp_n ) :
?>
<span class="emcp-mk-pager__gap" aria-hidden="true">…</span>
<?php
elseif ( $emcp_n === $emcp_cur_page ) :
?>
<span class="button button-primary emcp-mk-pager__num" aria-current="page"><?php echo esc_html( (string) $emcp_n ); ?></span>
<?php
else :
?>
<a class="button emcp-mk-pager__num" href="<?php echo esc_url( $emcp_page_href( $emcp_n ) ); ?>"><?php echo esc_html( (string) $emcp_n ); ?></a>
<?php
endif;
endforeach;
?>
<?php if ( $emcp_cur_page < $emcp_pages ) : ?>
<a class="button emcp-mk-pager__edge" href="<?php echo esc_url( $emcp_page_href( $emcp_cur_page + 1 ) ); ?>"><?php esc_html_e( 'Next', 'emcp-tools' ); ?> &rarr;</a>
<?php else : ?>
<span class="button disabled emcp-mk-pager__edge"><?php esc_html_e( 'Next', 'emcp-tools' ); ?> &rarr;</span>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php
// The pager is shared with the Sandbox screens, so the window maths and
// markup live in EMCP_Tools_Admin_Pager rather than in this view.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by render().
echo EMCP_Tools_Admin_Pager::render( $emcp_cur_page, $emcp_pages, $emcp_page_href );
?>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -321,8 +273,4 @@ $emcp_per_page = 24;
.emcp-mk-card__ver { flex: none; width: 15px; height: 15px; display: block; }
.emcp-mk-card__foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 12px; color: #787c82; margin-top: 4px; }
.emcp-mk-card__cta { margin-top: 8px; }
.emcp-mk-pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; margin: 24px 0 4px; }
.emcp-mk-pager__num { min-width: 34px; text-align: center; justify-content: center; }
.emcp-mk-pager__gap { padding: 0 4px; color: #787c82; }
.emcp-mk-pager .button.disabled { pointer-events: none; opacity: .5; }
</style>
+26 -1
View File
@@ -68,7 +68,24 @@ $emcp_tools_bb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
<?php else : ?>
<?php $emcp_tools_bb_list = EMCP_Tools_Block_Store::instance()->list_blocks( 'any' ); ?>
<?php
// A page at a time. Every row below carries that block's block.json and
// render.php, so an unpaged table grows with the site, not the screen.
$emcp_tools_bb_pg = EMCP_Tools_Block_Store::instance()->list_blocks_page( 'any', EMCP_Tools_Admin_Pager::current() );
$emcp_tools_bb_list = $emcp_tools_bb_pg['items'];
// `paged`, not `page`: wp-admin already uses that one for the menu slug.
$emcp_tools_bb_href = static function ( $emcp_n ) {
$args = array(
'page' => 'emcp-tools-widgets',
'view' => 'blocks',
);
if ( $emcp_n > 1 ) {
$args['paged'] = (int) $emcp_n;
}
return add_query_arg( $args, admin_url( 'admin.php' ) );
};
?>
<div class="notice notice-warning inline" style="margin: 12px 0;">
<p>
@@ -100,6 +117,9 @@ $emcp_tools_bb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
<?php else : ?>
<p class="description emcp-pager-count">
<?php echo EMCP_Tools_Admin_Pager::summary( $emcp_tools_bb_pg['page'], $emcp_tools_bb_pg['per_page'], count( $emcp_tools_bb_list ), $emcp_tools_bb_pg['total'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by summary(). ?>
</p>
<table class="widefat striped elementor-mcp-blocks-table" data-nonce="<?php echo esc_attr( wp_create_nonce( 'emcp_tools_blocks' ) ); ?>" style="margin-top: 16px;">
<thead>
<tr>
@@ -167,6 +187,11 @@ $emcp_tools_bb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
</tbody>
</table>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by render().
echo EMCP_Tools_Admin_Pager::render( $emcp_tools_bb_pg['page'], $emcp_tools_bb_pg['pages'], $emcp_tools_bb_href );
?>
<script>
( function () {
var table = document.querySelector( '.elementor-mcp-blocks-table' );
+36 -1
View File
@@ -41,8 +41,35 @@ $emcp_tools_sn_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
<?php
// ===== PHP Snippets (free, capability-gated) =====
$emcp_tools_sn_can = class_exists( 'EMCP_Tools_PHP_Snippet_Store' ) && EMCP_Tools_PHP_Snippet_Store::can_edit();
$emcp_tools_sn_list = class_exists( 'EMCP_Tools_PHP_Snippet_Store' ) ? EMCP_Tools_PHP_Snippet_Store::list_snippets( 'any' ) : array();
$emcp_tools_sn_nonce = wp_create_nonce( 'emcp_tools_php_snippets' );
// A page at a time. Every row below carries that snippet's whole source, so an
// unpaged table grows with the site rather than with the screen.
$emcp_tools_sn_pg = class_exists( 'EMCP_Tools_PHP_Snippet_Store' )
? EMCP_Tools_PHP_Snippet_Store::list_snippets_page( 'any', EMCP_Tools_Admin_Pager::current() )
: array(
'items' => array(),
'total' => 0,
'page' => 1,
'pages' => 1,
'per_page' => EMCP_Tools_Sandbox_List_Query::PER_PAGE,
);
$emcp_tools_sn_list = $emcp_tools_sn_pg['items'];
/**
* Links a page number back to this view, keeping the Sandbox route intact.
* `paged`, not `page`: wp-admin already uses that one for the menu slug.
*/
$emcp_tools_sn_href = static function ( $emcp_n ) {
$args = array(
'page' => 'emcp-tools-widgets',
'view' => 'snippets',
);
if ( $emcp_n > 1 ) {
$args['paged'] = (int) $emcp_n;
}
return add_query_arg( $args, admin_url( 'admin.php' ) );
};
?>
<div class="elementor-mcp-pro-prompts elementor-mcp-php-snippets" data-nonce="<?php echo esc_attr( $emcp_tools_sn_nonce ); ?>" style="margin-top: 28px;">
<div class="elementor-mcp-pro-prompts-header">
@@ -127,6 +154,9 @@ $emcp_tools_sn_nonce = wp_create_nonce( 'emcp_tools_php_snippets' );
<?php if ( empty( $emcp_tools_sn_list ) ) : ?>
<p class="description"><?php esc_html_e( 'No snippets yet. Add one above, or ask your AI agent to draft one with the create-php-snippet tool.', 'emcp-tools' ); ?></p>
<?php else : ?>
<p class="description emcp-pager-count">
<?php echo EMCP_Tools_Admin_Pager::summary( $emcp_tools_sn_pg['page'], $emcp_tools_sn_pg['per_page'], count( $emcp_tools_sn_list ), $emcp_tools_sn_pg['total'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by summary(). ?>
</p>
<table class="widefat striped elementor-mcp-snippets-table" style="margin-top: 8px;">
<thead>
<tr>
@@ -230,6 +260,11 @@ $emcp_tools_sn_nonce = wp_create_nonce( 'emcp_tools_php_snippets' );
<?php endforeach; ?>
</tbody>
</table>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by render().
echo EMCP_Tools_Admin_Pager::render( $emcp_tools_sn_pg['page'], $emcp_tools_sn_pg['pages'], $emcp_tools_sn_href );
?>
<?php endif; ?>
<script>
+26 -1
View File
@@ -70,7 +70,24 @@ $emcp_tools_wb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
<?php else : ?>
<?php $emcp_tools_wb_list = EMCP_Tools_Widget_Store::list_widgets( 'any' ); ?>
<?php
// A page at a time. Every row below carries that widget's compiled PHP,
// so an unpaged table grows with the site rather than with the screen.
$emcp_tools_wb_pg = EMCP_Tools_Widget_Store::list_widgets_page( 'any', EMCP_Tools_Admin_Pager::current() );
$emcp_tools_wb_list = $emcp_tools_wb_pg['items'];
// `paged`, not `page`: wp-admin already uses that one for the menu slug.
$emcp_tools_wb_href = static function ( $emcp_n ) {
$args = array(
'page' => 'emcp-tools-widgets',
'view' => 'widgets',
);
if ( $emcp_n > 1 ) {
$args['paged'] = (int) $emcp_n;
}
return add_query_arg( $args, admin_url( 'admin.php' ) );
};
?>
<div class="notice notice-warning inline" style="margin: 12px 0;">
<p>
@@ -102,6 +119,9 @@ $emcp_tools_wb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
<?php else : ?>
<p class="description emcp-pager-count">
<?php echo EMCP_Tools_Admin_Pager::summary( $emcp_tools_wb_pg['page'], $emcp_tools_wb_pg['per_page'], count( $emcp_tools_wb_list ), $emcp_tools_wb_pg['total'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by summary(). ?>
</p>
<table class="widefat striped elementor-mcp-widgets-table" data-nonce="<?php echo esc_attr( wp_create_nonce( 'emcp_tools_widgets' ) ); ?>" style="margin-top: 16px;">
<thead>
<tr>
@@ -168,6 +188,11 @@ $emcp_tools_wb_import_error = isset( $_GET['import_error'] ) ? sanitize_text_fie
</tbody>
</table>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped by render().
echo EMCP_Tools_Admin_Pager::render( $emcp_tools_wb_pg['page'], $emcp_tools_wb_pg['pages'], $emcp_tools_wb_href );
?>
<script>
( function () {
var table = document.querySelector( '.elementor-mcp-widgets-table' );
+4
View File
@@ -207,6 +207,9 @@ class EMCP_Tools_Bootstrap {
// Central sandbox storage location (wp-content/emcp-sandbox). Every store
// resolves paths through this, so it must load before them.
require_once EMCP_TOOLS_DIR . 'includes/sandbox/class-sandbox-paths.php';
// Paged listing shared by all three artifact stores. Loaded alongside
// them, not with the admin, because the stores name it in a default.
require_once EMCP_TOOLS_DIR . 'includes/sandbox/class-sandbox-list-query.php';
require_once EMCP_TOOLS_DIR . 'includes/class-widget-store.php';
require_once EMCP_TOOLS_DIR . 'includes/class-widget-loader.php';
// Sandbox Bundle — portable cloud-ready format for blocks/widgets/snippets.
@@ -501,6 +504,7 @@ class EMCP_Tools_Bootstrap {
* @since 2.1.0
*/
private static function load_admin(): void {
require_once EMCP_TOOLS_DIR . 'includes/admin/class-admin-pager.php';
require_once EMCP_TOOLS_DIR . 'includes/admin/class-admin.php';
require_once EMCP_TOOLS_DIR . 'includes/admin/class-mcpb-builder.php';
+34
View File
@@ -512,6 +512,40 @@ class EMCP_Tools_PHP_Snippet_Store {
return $out;
}
/**
* One page of snippets, plus the totals needed to draw a pager.
*
* list_snippets() stays as it is for the MCP tools, which want the whole
* list in a single call. The admin table renders each row's full source,
* so it takes a page at a time instead.
*
* @since 3.15.0
*
* @param string $status 'active', 'draft', or 'any'.
* @param int $page 1-based page number.
* @param int $per_page Rows per page.
* @return array{items:array[],total:int,page:int,pages:int,per_page:int}
*/
public static function list_snippets_page( string $status = 'any', int $page = 1, int $per_page = EMCP_Tools_Sandbox_List_Query::PER_PAGE ): array {
$result = EMCP_Tools_Sandbox_List_Query::page( self::POST_TYPE, $status, $page, $per_page );
$items = array();
foreach ( $result['ids'] as $emcp_id ) {
$summary = self::summary( $emcp_id );
if ( ! is_wp_error( $summary ) ) {
$items[] = $summary;
}
}
return array(
'items' => $items,
'total' => $result['total'],
'page' => $result['page'],
'pages' => $result['pages'],
'per_page' => $result['per_page'],
);
}
// -------------------------------------------------------------------------
// Manifest
// -------------------------------------------------------------------------
+34
View File
@@ -723,6 +723,40 @@ class EMCP_Tools_Widget_Store {
return $out;
}
/**
* One page of widgets, plus the totals needed to draw a pager.
*
* list_widgets() stays as it is for the MCP tools, which want the whole
* list in a single call. The admin table renders each row's compiled PHP,
* so it takes a page at a time instead.
*
* @since 3.15.0
*
* @param string $status 'active', 'draft', or 'any'.
* @param int $page 1-based page number.
* @param int $per_page Rows per page.
* @return array{items:array[],total:int,page:int,pages:int,per_page:int}
*/
public static function list_widgets_page( string $status = 'any', int $page = 1, int $per_page = EMCP_Tools_Sandbox_List_Query::PER_PAGE ): array {
$result = EMCP_Tools_Sandbox_List_Query::page( self::POST_TYPE, $status, $page, $per_page );
$items = array();
foreach ( $result['ids'] as $emcp_id ) {
$summary = self::summary( $emcp_id );
if ( ! is_wp_error( $summary ) ) {
$items[] = $summary;
}
}
return array(
'items' => $items,
'total' => $result['total'],
'page' => $result['page'],
'pages' => $result['pages'],
'per_page' => $result['per_page'],
);
}
// -------------------------------------------------------------------------
// Manifest
// -------------------------------------------------------------------------
@@ -0,0 +1,161 @@
<?php
/**
* One paged query behind every sandbox artifact list.
*
* The three sandbox stores (blocks, widgets, PHP snippets) are the same shape:
* a private CPT, ordered by modified date, filtered by status. All three asked
* for a flat 200 rows with `no_found_rows`, which had two consequences. The
* admin screens rendered every artifact in a single table (and each row of the
* snippet and widget tables carries that artifact's whole source), and a site
* past its 200th artifact silently lost the rest with nothing on screen saying
* so.
*
* Owning the query here fixes both once: callers get one page of IDs plus the
* real total, and each store maps those IDs through its own summary shape.
*
* @package EMCP_Tools
* @since 3.15.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Paged listing for the sandbox artifact post types.
*
* @since 3.15.0
*/
class EMCP_Tools_Sandbox_List_Query {
/**
* Rows per page on the sandbox management screens.
*
* @since 3.15.0
* @var int
*/
const PER_PAGE = 20;
/**
* Upper bound on `per_page`, so a hand-edited URL cannot ask for the whole
* table back and undo the point of paging.
*
* @since 3.15.0
* @var int
*/
const MAX_PER_PAGE = 100;
/**
* Returns one page of artifact IDs plus the totals needed to draw a pager.
*
* A requested page beyond the end lands on the last page that exists rather
* than on an empty table: that is what a bookmarked URL looks like once rows
* have been deleted, and an empty screen reads as data loss.
*
* @since 3.15.0
*
* @param string $post_type Artifact post type.
* @param string $status 'active', 'draft', or 'any'.
* @param int $page 1-based page number.
* @param int $per_page Rows per page.
* @return array{ids:int[],total:int,page:int,pages:int,per_page:int}
*/
public static function page( string $post_type, string $status = 'any', int $page = 1, int $per_page = self::PER_PAGE ): array {
$per_page = max( 1, min( self::MAX_PER_PAGE, $per_page ) );
$page = max( 1, $page );
$post_status = self::post_status( $status );
$result = self::query( $post_type, $post_status, $page, $per_page );
// WP_Query::set_found_posts() bails when the page came back empty, so an
// out-of-range page reports a total of zero and cannot say where the list
// really ends. Page 1 does carry the total, so ask it, then land on the
// last page that exists.
if ( empty( $result['ids'] ) && $page > 1 ) {
$page = 1;
$result = self::query( $post_type, $post_status, 1, $per_page );
if ( $result['pages'] > 1 ) {
$page = $result['pages'];
$result = self::query( $post_type, $post_status, $page, $per_page );
}
}
return array(
'ids' => $result['ids'],
'total' => $result['total'],
'page' => $page,
'pages' => $result['pages'],
'per_page' => $per_page,
);
}
/**
* Maps the stores' status vocabulary onto post statuses.
*
* Drafts are the whole point of the sandbox (AI writes them, a human
* activates them), so 'any' has to mean both.
*
* @since 3.15.0
*
* @param string $status 'active', 'draft', or anything else for both.
* @return string
*/
public static function post_status( string $status ): string {
if ( 'active' === $status ) {
return 'publish';
}
if ( 'draft' === $status ) {
return 'draft';
}
return 'any';
}
/**
* Runs the query for one page.
*
* @since 3.15.0
*
* @param string $post_type Artifact post type.
* @param string $post_status Resolved post status.
* @param int $page 1-based page number.
* @param int $per_page Rows per page.
* @return array{ids:int[],total:int,pages:int}
*/
private static function query( string $post_type, string $post_status, int $page, int $per_page ): array {
$query = new WP_Query(
array(
'post_type' => $post_type,
'post_status' => $post_status,
'posts_per_page' => $per_page,
'paged' => $page,
// Modified date alone is not a total order: artifacts saved in the
// same second tie, and MySQL may order the tie differently for
// `LIMIT 0,20` than for `LIMIT 20,20`. That shows one row on two
// pages and hides another entirely. ID is unique, so it settles it.
'orderby' => array(
'modified' => 'DESC',
'ID' => 'DESC',
),
'fields' => 'ids',
// The whole point is the total, so found_rows stays on here.
'no_found_rows' => false,
)
);
$ids = array();
foreach ( (array) $query->posts as $post ) {
// 'fields' => 'ids' gives plain integers, but a filtered query (or a
// test double) can still hand back objects.
$ids[] = is_object( $post ) ? (int) $post->ID : (int) $post;
}
$total = (int) $query->found_posts;
return array(
'ids' => $ids,
'total' => $total,
'pages' => max( 1, (int) ceil( $total / $per_page ) ),
);
}
}
+1 -1
Submodule pro updated: 9df5f6572c...f0f4351eac