service = $service;
}
/**
* The submenu slug.
*
* @return string
*/
public static function page_slug() {
return 'adminkeep-order';
}
/**
* Where a page starts: the 0-based offset of its first row.
*
* One definition, shared with the REST route, so the two paginations cannot drift.
*
* @param int $paged 1-based page number.
* @return int
*/
public static function page_offset( $paged ) {
return ( max( 1, (int) $paged ) - 1 ) * self::PER_PAGE;
}
/**
* The nonce action for saving one particular subject.
*
* Scoped to the subject, not shared across the screen: a nonce minted on one post type's
* sort screen must not validate a save aimed at another. One definition, used by the form
* that mints it and the handler that verifies it, so the two cannot drift.
*
* @param string $subject Post type slug.
* @return string
*/
public static function nonce_action( $subject ) {
return self::NONCE . '_' . $subject;
}
/**
* Render the screen.
*
* @param string $post_type Post type.
* @param int $paged 1-based page number.
* @return void
*/
public function render( $post_type, $paged = 1 ) {
$paged = max( 1, (int) $paged );
$ids = $this->service->ordered_post_ids( $post_type );
$total = count( $ids );
$offset = self::page_offset( $paged );
$page = array_slice( $ids, $offset, self::PER_PAGE );
// One round of queries for the page's posts instead of one per row: every cell read
// below — title, status, permalink — goes through get_post(), and permalinks may need
// the posts' terms too (a %category% permalink structure), hence the term prime.
if ( ! empty( $page ) ) {
_prime_post_caches( $page, true, false );
}
echo '
';
printf( '
%s%s
', Brand::heading_mark( 'order' ), esc_html( $this->heading( $post_type ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The mark is escaped where it is built.
echo '';
printf(
'
',
esc_html__( 'Drag rows to reorder. Changes save when you press Save order.', 'adminkeep' ),
esc_url( $this->list_url( $post_type ) ),
esc_html(
/* translators: %s: post type name. */
sprintf( __( '← Back to %s', 'adminkeep' ), $this->subject_label( $post_type ) )
)
);
$this->render_save_notice();
echo '';
echo '
';
}
/**
* The post-save notice, read from the redirect Order::handle_save() issued.
*
* An unmoved page re-renders pixel-identical to what was submitted — that is the fixed-point
* property working — so without this line a successful save is indistinguishable from a save
* that was thrown away. A save that skipped rows (per-row 'edit_post' denials, carried here
* as adminkeep_denied) is a warning, not a success: the stored order no longer matches the screen
* the user arranged.
*
* Reading $_GET without a nonce is correct here: both parameters only choose between two
* static, non-destructive notice strings.
*
* @return void
*/
private function render_save_notice() {
if ( ! isset( $_GET['updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
$denied = isset( $_GET['adminkeep_denied'] ) ? absint( wp_unslash( $_GET['adminkeep_denied'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( $denied > 0 ) {
printf(
'
%s
',
esc_html(
sprintf(
/* translators: %s: number of rows left unmoved. */
_n(
'Order saved, but %s item was left where it was — you are not allowed to edit it.',
'Order saved, but %s items were left where they were — you are not allowed to edit them.',
$denied,
'adminkeep'
),
number_format_i18n( $denied )
)
)
);
return;
}
printf(
'
%s
',
esc_html__( 'Order saved.', 'adminkeep' )
);
}
/**
* One row: a drag handle, the position, and the position this row was rendered at.
*
* The position is shown as text and carried in a hidden input; dragging is the only way to
* move a row. The sort script renumbers both together.
*
* THE HIDDEN BASELINE IS WHAT MAKES A MOVE LAND WHERE IT WAS DROPPED. The form posts every
* visible row, so without it every slot on the page arrives as a request from whichever row
* is already sitting in it. OrderService::apply_moves() breaks ties first-submitted-wins, and
* in DOM order the incumbent at the target always precedes an upward mover — so the mover was
* packed one slot past where it was dropped. Order::handle_save() compares each submitted
* position against this baseline and treats only the rows that DIFFER as requests, which
* leaves the arithmetic in apply_moves() untouched and correct.
*
* The sort script must never write to the baseline input. It is the server's own record of
* what the user was shown; a live value here would drift exactly the way the positions do,
* and the comparison would compare a value against itself.
*
* @param int $id Post id.
* @param int $position 1-based absolute position.
* @return void
*/
private function render_row( $id, $position ) {
printf( '
', wp_kses_post( $this->title_cell( $id ) ) );
// The handle sits on the row's right edge. A 2x3 dot grip, in currentColor so the CSS
// owns its colour.
echo '
'
. '
';
echo '
';
}
/**
* The title cell: the title linked to what the row looks like on the site — the permalink,
* or the preview for anything not yet published — plus a muted status for anything not
* published, and the id. Every piece is escaped here; the caller prints the result as-is.
*
* Frontend, not the edit screen: the question this screen raises is "where does this sit on
* my site", and that is what the link answers. It opens in a new tab so the sort in progress
* is not lost. Previews leak nothing — the screen already gates on a capability that lets
* the user see every row.
*
* @param int $id Post id.
* @return string Escaped HTML.
*/
private function title_cell( $id ) {
$status = get_post_status( $id );
$html = $this->linked_title( get_the_title( $id ), $this->post_view_link( $id, $status ) );
if ( $status && 'publish' !== $status ) {
$object = get_post_status_object( $status );
$label = $object && isset( $object->label ) ? $object->label : $status;
$html .= sprintf( ' %s', esc_html( $label ) );
}
return $html . $this->id_suffix( $id );
}
/**
* Where a post row's title points: the permalink for anything live, the preview otherwise.
*
* @param int $id Post id.
* @param string|false $status The post's status, as the caller already read it.
* @return string URL, or '' for none.
*/
private function post_view_link( $id, $status ) {
if ( ! in_array( $status, array( 'publish', 'private' ), true ) ) {
$preview = get_preview_post_link( $id );
if ( $preview ) {
return $preview;
}
}
return (string) get_permalink( $id );
}
/**
* The muted "ID: n" after a title, for the moment a number is what somebody needs.
*
* @param int $id Post id.
* @return string Escaped HTML.
*/
private function id_suffix( $id ) {
return sprintf(
' %s',
/* translators: %d: post id. */
esc_html( sprintf( __( 'ID: %d', 'adminkeep' ), absint( $id ) ) )
);
}
/**
* A title, wrapped in a link when there is one.
*
* @param string $title Raw title.
* @param string|null $link Edit URL, or empty for none.
* @return string Escaped HTML.
*/
private function linked_title( $title, $link ) {
if ( empty( $link ) ) {
return esc_html( $title );
}
// A new tab, so the unsaved sort (and its beforeunload guard) stays where it is.
return sprintf( '%s', esc_url( $link ), esc_html( $title ) );
}
/**
* The list screen this sort screen belongs to.
*
* @param string $post_type Post type.
* @return string
*/
private function list_url( $post_type ) {
return 'post' === $post_type
? admin_url( 'edit.php' )
: add_query_arg( 'post_type', $post_type, admin_url( 'edit.php' ) );
}
/**
* The post type's plural label, falling back to its slug.
*
* @param string $post_type Post type.
* @return string
*/
private function subject_label( $post_type ) {
$object = get_post_type_object( $post_type );
return $object && isset( $object->labels->name ) ? $object->labels->name : $post_type;
}
/**
* Screen heading.
*
* @param string $post_type Post type.
* @return string
*/
private function heading( $post_type ) {
/* translators: %s: post type name. */
return sprintf( __( 'Sort: %s', 'adminkeep' ), $this->subject_label( $post_type ) );
}
/**
* Paging links.
*
* @param int $total Total rows.
* @param int $paged Current page.
* @return void
*/
private function render_paging( $total, $paged ) {
$pages = (int) ceil( $total / self::PER_PAGE );
if ( $pages < 2 ) {
return;
}
echo '
';
// The notice params describe one past save; they must not ride along into every later
// page view via the pagination links. Stripping them from 'base' is not enough:
// paginate_links() independently re-reads the current URL (get_pagenum_link(), built
// from REQUEST_URI) and merges any query args not present in 'base' into every link via
// 'add_args' — which is exactly the two stripped ones. The URI is scrubbed for the
// duration of the call instead, and restored, so both of core's readings agree.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
$_SERVER['REQUEST_URI'] = remove_query_arg( array( 'updated', 'adminkeep_denied' ), $request_uri );
echo wp_kses_post(
paginate_links(
array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'total' => $pages,
'current' => $paged,
'type' => 'plain',
'prev_text' => '«',
'next_text' => '»',
)
)
);
$_SERVER['REQUEST_URI'] = $request_uri;
echo '