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( '

%s %s

', 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 '
'; wp_nonce_field( self::nonce_action( $post_type ) ); printf( '', esc_attr( $post_type ) ); printf( '', absint( $paged ) ); // The carries the column widths (see order.css). The table is laid out // table-layout: fixed, and a fixed layout takes its widths from the FIRST row when there // is no colgroup — which, mid-drag, is jQuery UI's empty placeholder row, so every column // collapsed to a third. Widths on elements never depend on any row. // // data-offset is the page's true starting position, PHP-authoritative. The sort script's // renumber() must never derive this from a live DOM value — the first row's own position // is exactly the thing a drag mutates, so reading it back after any drag that moved the // top row would renumber the whole page from a stale, already-drifted baseline. See // assets/js/order.js for the failure this was written to avoid. printf( '', absint( $offset + 1 ) ); foreach ( $page as $index => $id ) { $this->render_row( $id, $offset + $index + 1 ); } echo '
'; echo '
'; submit_button( __( 'Save order', 'adminkeep' ), 'primary', 'submit', false ); echo '
'; $this->render_paging( $total, $paged ); echo '
'; 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( '', absint( $id ) ); printf( '%2$d' . '' . '', absint( $id ), absint( $position ) ); printf( '%s', 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 '
'; } }