From 120305ad1322f1dba7439c3182dd2bba508bf661 Mon Sep 17 00:00:00 2001 From: Igor Zinovyev Date: Fri, 14 Aug 2026 14:34:12 +0400 Subject: [PATCH 1/3] feat: ListQuery-driven listing for plural DataViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plural list mode previously rendered every entity with no pagination, search, ordering or filtering — PluralHandler::list() took no arguments at all — which pushed real listing pages toward hand-rolled WP_List_Table implementations outside the DataView system. ListQuery is a description of a list request (page, per_page, orderby/order, search + search fields, field filters), not an execution strategy. It executes over two paths: - Native: storages implementing the new QueryablePluralStorage interface translate it to their backend. DatabaseModuleStorage now does — schema fields are real columns, so filters and search become a prepared WHERE, ordering a whitelisted ORDER BY, pagination LIMIT/OFFSET; nothing outside the requested page reaches PHP. Handlers wrapping external sources can likewise override the new PluralHandler::query(). - In-memory: everything else falls back to ListQuery::apply() over the existing all()/list(), which doubles as the reference semantics the native paths must preserve (proven by shared parity scenarios in the tests). Adapter wrappers that extend PluralHandler without a PluralObject and only override list() inherit this fallback unchanged — no signature changes to list() or render_list_table(), so existing subclasses keep working. The DataView config grows a 'list' section (columns / sortable / searchable / filterable / per_page), and the RequestRouter renders the declared controls with core list-table markup: sortable column headers, a search box, dropdown filters from field 'options', pagination, and a no-items row inside the table so headers survive an empty search. Each layer is a protected method, overridable by custom routers. Co-Authored-By: Claude Fable 5 --- src/DataObject/ListQuery.php | 232 +++++++ src/DataObject/PluralObject.php | 39 ++ src/DataObject/QueryablePluralStorage.php | 37 ++ .../Storage/DatabaseModuleStorage.php | 165 ++++- src/DataView/DataViewConfig.php | 89 +++ src/DataView/Request.php | 11 + src/DataView/RequestRouter.php | 260 +++++++- src/RequestHandler/PluralHandler.php | 41 ++ src/RequestHandler/Result.php | 28 + tests/phpunit/list-query.php | 580 ++++++++++++++++++ 10 files changed, 1470 insertions(+), 12 deletions(-) create mode 100644 src/DataObject/ListQuery.php create mode 100644 src/DataObject/QueryablePluralStorage.php create mode 100644 tests/phpunit/list-query.php diff --git a/src/DataObject/ListQuery.php b/src/DataObject/ListQuery.php new file mode 100644 index 0000000..472f9e4 --- /dev/null +++ b/src/DataObject/ListQuery.php @@ -0,0 +1,232 @@ + value equality constraints. + * + * @var array + */ + public readonly array $filters; + + /** + * Create a new ListQuery, normalizing out-of-range values. + * + * @param int $page Page number (clamped to >= 1). + * @param int $per_page Items per page (clamped to >= 0; 0 = unpaginated). + * @param string $orderby Field to order by ('' = storage order). + * @param string $order 'asc' or 'desc' (anything else becomes 'asc'). + * @param string $search Search term. + * @param string[] $search_fields Fields to search in. + * @param array $filters Field => value equality filters. + */ + public function __construct( + int $page = 1, + int $per_page = 20, + string $orderby = '', + string $order = 'asc', + string $search = '', + array $search_fields = [], + array $filters = [] + ) { + $this->page = max( 1, $page ); + $this->per_page = max( 0, $per_page ); + $this->orderby = $orderby; + $this->order = strtolower( $order ) === 'desc' ? 'desc' : 'asc'; + $this->search = $search; + $this->search_fields = array_values( $search_fields ); + $this->filters = $filters; + } + + /** + * The row offset this query's page starts at. + * + * @return int Zero-based offset. + */ + public function offset(): int { + return $this->per_page > 0 ? ( $this->page - 1 ) * $this->per_page : 0; + } + + /** + * Whether a data row matches the search term and filters. + * + * @param array $row Field => value data row. + * @return bool True when the row survives search and filters. + */ + public function matches( array $row ): bool { + foreach ( $this->filters as $field => $value ) { + if ( ! array_key_exists( $field, $row ) ) { + return false; + } + $actual = $row[ $field ]; + if ( ! is_scalar( $actual ) && $actual !== null ) { + return false; + } + // String-loose equality: filter values usually arrive from + // URLs as strings while stored values may be int or bool. + if ( (string) $actual !== (string) $value ) { + return false; + } + } + + if ( $this->search === '' ) { + return true; + } + + $fields = $this->search_fields !== [] + ? $this->search_fields + : array_keys( $row ); + + foreach ( $fields as $field ) { + $value = $row[ $field ] ?? null; + if ( is_scalar( $value ) && stripos( (string) $value, $this->search ) !== false ) { + return true; + } + } + + return false; + } + + /** + * Count the items matching this query, ignoring pagination. + * + * @param array $items The items to count. + * @param callable $accessor Optional item => data-row accessor. + * @return int Number of matching items. + */ + public function count_matching( array $items, ?callable $accessor = null ): int { + $count = 0; + foreach ( $items as $item ) { + if ( $this->matches( $accessor !== null ? $accessor( $item ) : $item ) ) { + ++$count; + } + } + return $count; + } + + /** + * Apply the full query to a set of items in memory: + * filter + search, then order, then paginate. + * + * Items may be data rows themselves, or anything an accessor can + * turn into one (e.g. entities) — the returned array holds the + * surviving ORIGINAL items, in query order. + * + * @param array $items The items to query. + * @param callable $accessor Optional item => data-row accessor. + * @return array The matching page of items. + */ + public function apply( array $items, ?callable $accessor = null ): array { + $row = $accessor ?? static fn( $item ) => $item; + + $matched = array_values( array_filter( + $items, + fn( $item ) => $this->matches( $row( $item ) ) + ) ); + + if ( $this->orderby !== '' ) { + // usort() is stable in PHP 8, so equal keys keep storage order. + usort( $matched, function ( $a, $b ) use ( $row ) { + $result = $this->compare_values( + $row( $a )[ $this->orderby ] ?? null, + $row( $b )[ $this->orderby ] ?? null + ); + return $this->order === 'desc' ? -$result : $result; + } ); + } + + if ( $this->per_page > 0 ) { + $matched = array_slice( $matched, $this->offset(), $this->per_page ); + } + + return $matched; + } + + /** + * Compare two field values for ordering. + * + * Numeric pairs compare numerically, everything else compares as + * case-insensitive strings. Nulls sort before any value. + * + * @param mixed $a First value. + * @param mixed $b Second value. + * @return int Spaceship-style comparison result. + */ + protected function compare_values( mixed $a, mixed $b ): int { + if ( $a === null || $b === null ) { + return ( $a === null ? 0 : 1 ) <=> ( $b === null ? 0 : 1 ); + } + if ( is_numeric( $a ) && is_numeric( $b ) ) { + return $a <=> $b; + } + return strcasecmp( (string) $a, (string) $b ); + } +} diff --git a/src/DataObject/PluralObject.php b/src/DataObject/PluralObject.php index e204ec1..8bae69b 100644 --- a/src/DataObject/PluralObject.php +++ b/src/DataObject/PluralObject.php @@ -89,6 +89,45 @@ public function all(): array { return $results; } + /** + * Return the entities matching a list query, ordered and paginated. + * + * Delegates to the storage when it can execute the query natively; + * otherwise applies the query in memory over all(). + * + * @param ListQuery $query The list query. + * @return Entity[] The matching page of entities. + */ + public function query( ListQuery $query ): array { + if ( $this->storage instanceof QueryablePluralStorage ) { + $results = []; + foreach ( $this->storage->query( $query ) as $data ) { + $results[] = $this->hydrateEntity( $data['id'], $data ); + } + return $results; + } + + $results = []; + foreach ( $query->apply( $this->storage->all() ) as $data ) { + $results[] = $this->hydrateEntity( $data['id'], $data ); + } + return $results; + } + + /** + * Count the entities matching a list query, ignoring pagination. + * + * @param ListQuery $query The list query. + * @return int The matching entity count. + */ + public function count( ListQuery $query ): int { + if ( $this->storage instanceof QueryablePluralStorage ) { + return $this->storage->count( $query ); + } + + return $query->count_matching( $this->storage->all() ); + } + private function hydrateEntity( int $id, array $data ): Entity { $entity = new Entity( $data ); $entity->set_id( $id ); diff --git a/src/DataObject/QueryablePluralStorage.php b/src/DataObject/QueryablePluralStorage.php new file mode 100644 index 0000000..6d6b206 --- /dev/null +++ b/src/DataObject/QueryablePluralStorage.php @@ -0,0 +1,37 @@ +map_rows( $rows ); + } + + /** + * Execute a ListQuery natively as SQL against the table. + * + * @param ListQuery $query The list query. + * @return array Data rows (each including 'id') for the requested page. + */ + public function query( ListQuery $query ): array { + if ( ! $this->table ) { + return []; + } + + $sql = 'SELECT * FROM ' . $this->table->db->table_name + . $this->build_where( $query ) + . $this->build_order( $query ) + . $this->build_limit( $query ); + + $rows = $GLOBALS['wpdb']->get_results( $sql ); + + return $rows ? $this->map_rows( $rows ) : []; + } + + public function count( ListQuery $query ): int { + if ( ! $this->table ) { + return 0; + } + + $sql = 'SELECT COUNT(*) FROM ' . $this->table->db->table_name + . $this->build_where( $query ); + + return (int) $GLOBALS['wpdb']->get_var( $sql ); + } + + /** + * Cast result rows to arrays and expose the primary key as 'id'. + * + * @param array $rows Raw wpdb result rows. + * @return array Data rows. + */ + protected function map_rows( array $rows ): array { + $results = []; $primary_key = $this->table->db->primary_key; foreach ( $rows as $row ) { @@ -124,4 +172,115 @@ public function all(): array { return $results; } + + /** + * The queryable column whitelist: schema fields plus the primary key. + * + * Filter, search and order fields are validated against this list + * before being interpolated into SQL. + * + * @return string[] Column names. + */ + protected function schema_columns(): array { + $db = $this->table->db; + $columns = array_keys( (array) $db->schema->fields ); + + if ( ! in_array( $db->primary_key, $columns, true ) ) { + $columns[] = $db->primary_key; + } + + return $columns; + } + + /** + * Map the row-level 'id' alias back to the primary key column. + * + * @param string $field Field name from the query. + * @return string Column name. + */ + protected function normalize_column( string $field ): string { + return $field === 'id' ? $this->table->db->primary_key : $field; + } + + /** + * Build the WHERE clause for a query (also used by count()). + * + * Preserves ListQuery's in-memory reference semantics: a filter on + * a field rows don't have matches nothing, as does a search whose + * declared fields are all unknown. + * + * @param ListQuery $query The list query. + * @return string Leading-space WHERE clause, or empty string. + */ + protected function build_where( ListQuery $query ): string { + $wpdb = $GLOBALS['wpdb']; + $columns = $this->schema_columns(); + $clauses = []; + + foreach ( $query->filters as $field => $value ) { + $column = $this->normalize_column( (string) $field ); + if ( ! in_array( $column, $columns, true ) ) { + return ' WHERE 1 = 0'; + } + $clauses[] = $wpdb->prepare( "`{$column}` = %s", (string) $value ); + } + + if ( $query->search !== '' ) { + $fields = $query->search_fields !== [] + ? array_values( array_intersect( + array_map( [ $this, 'normalize_column' ], $query->search_fields ), + $columns + ) ) + : $columns; + + if ( $fields === [] ) { + return ' WHERE 1 = 0'; + } + + $like = '%' . $wpdb->esc_like( $query->search ) . '%'; + $searches = []; + foreach ( $fields as $field ) { + $searches[] = $wpdb->prepare( "`{$field}` LIKE %s", $like ); + } + $clauses[] = '( ' . implode( ' OR ', $searches ) . ' )'; + } + + return $clauses === [] ? '' : ' WHERE ' . implode( ' AND ', $clauses ); + } + + /** + * Build the ORDER BY clause. Unknown order fields preserve storage + * order, matching the in-memory fallback. + * + * @param ListQuery $query The list query. + * @return string Leading-space ORDER BY clause, or empty string. + */ + protected function build_order( ListQuery $query ): string { + if ( $query->orderby === '' ) { + return ''; + } + + $column = $this->normalize_column( $query->orderby ); + if ( ! in_array( $column, $this->schema_columns(), true ) ) { + return ''; + } + + $direction = $query->order === 'desc' ? 'DESC' : 'ASC'; + + return " ORDER BY `{$column}` {$direction}"; + } + + /** + * Build the LIMIT/OFFSET clause. + * + * @param ListQuery $query The list query. + * @return string Leading-space LIMIT clause, or empty string when unpaginated. + */ + protected function build_limit( ListQuery $query ): string { + if ( $query->per_page <= 0 ) { + return ''; + } + + return $GLOBALS['wpdb']->prepare( ' LIMIT %d OFFSET %d', $query->per_page, $query->offset() ); + } } diff --git a/src/DataView/DataViewConfig.php b/src/DataView/DataViewConfig.php index 4aa4cd0..087f765 100644 --- a/src/DataView/DataViewConfig.php +++ b/src/DataView/DataViewConfig.php @@ -27,6 +27,22 @@ class DataViewConfig { */ public readonly bool $notices; + /** + * List view configuration. + * + * Keys (all optional in the input config): + * - columns: fields shown as table columns (default: all fields) + * - sortable: fields whose column headers sort the list (default: none) + * - searchable: fields the search box matches against (default: none — + * no search box is rendered) + * - filterable: fields exposed as filters; fields whose config declares + * 'options' render as dropdowns (default: none) + * - per_page: page size, 0 disables pagination (default: 20) + * + * @var array + */ + public readonly array $list; + /** * Full field configurations including repeater sub-fields. * @@ -103,6 +119,17 @@ public function __construct( array $config ) { $config['ui'] ?? [] ); + $this->list = array_merge( + [ + 'columns' => array_keys( $this->fields ), + 'sortable' => [], + 'searchable' => [], + 'filterable' => [], + 'per_page' => 20, + ], + $config['list'] ?? [] + ); + $this->validate(); } @@ -266,6 +293,68 @@ protected function validate(): void { sprintf( 'Invalid mode "%s". Must be one of: plural, singular.', $this->mode ) ); } + + // 'id' is allowed alongside declared fields: entities always carry + // it and sorting by it is common. + $known = array_merge( [ 'id' ], array_keys( $this->fields ) ); + foreach ( [ 'columns', 'sortable', 'searchable', 'filterable' ] as $key ) { + foreach ( $this->list[ $key ] as $field ) { + if ( ! in_array( $field, $known, true ) ) { + throw new \InvalidArgumentException( + sprintf( 'Unknown field "%s" in list.%s.', (string) $field, $key ) + ); + } + } + } + + if ( ! is_int( $this->list['per_page'] ) || $this->list['per_page'] < 0 ) { + throw new \InvalidArgumentException( 'list.per_page must be a non-negative integer.' ); + } + } + + /** + * Get the fields shown as list table columns. + * + * @return string[] Field names. + */ + public function get_list_columns(): array { + return $this->list['columns']; + } + + /** + * Get the fields whose list columns are sortable. + * + * @return string[] Field names. + */ + public function get_sortable_fields(): array { + return $this->list['sortable']; + } + + /** + * Get the fields the list search box matches against. + * + * @return string[] Field names. + */ + public function get_searchable_fields(): array { + return $this->list['searchable']; + } + + /** + * Get the fields exposed as list filters. + * + * @return string[] Field names. + */ + public function get_filterable_fields(): array { + return $this->list['filterable']; + } + + /** + * Get the list page size. 0 means unpaginated. + * + * @return int Items per page. + */ + public function get_list_per_page(): int { + return $this->list['per_page']; } /** diff --git a/src/DataView/Request.php b/src/DataView/Request.php index 7304577..7b796ad 100644 --- a/src/DataView/Request.php +++ b/src/DataView/Request.php @@ -69,6 +69,17 @@ public function get_current_id(): ?int { return $id !== null ? (int) $id : null; } + /** + * Get an arbitrary request parameter. + * + * @param string $name Parameter name. + * @param mixed $default Value to return when the parameter is absent. + * @return mixed Parameter value or default. + */ + public function get_param( string $name, mixed $default = null ): mixed { + return $this->rest_request->get_param( $name ) ?? $default; + } + /** * Get the WordPress nonce from the current request. */ diff --git a/src/DataView/RequestRouter.php b/src/DataView/RequestRouter.php index eb3804f..2d1ce94 100644 --- a/src/DataView/RequestRouter.php +++ b/src/DataView/RequestRouter.php @@ -3,6 +3,7 @@ namespace Tangible\DataView; use Tangible\DataObject\DataSet; +use Tangible\DataObject\ListQuery; use Tangible\EditorLayout\Layout; use Tangible\EditorLayout\Section; use Tangible\EditorLayout\Sidebar; @@ -31,6 +32,17 @@ class RequestRouter { /** @var array Cached resolved labels. */ protected array $resolved_labels = []; + /** + * The query the current list view is rendering. + * + * Set by render_list() before any list markup renders, so helpers — + * including render_list_table() overrides in subclasses — can read + * the active search/sort/page state without a signature change. + * + * @var ListQuery|null + */ + protected ?ListQuery $current_list_query = null; + public function __construct( DataViewConfig $config, DataSet $dataset, @@ -69,6 +81,8 @@ protected function resolve_labels(): void { 'add_new_item' => sprintf( 'Add New %s', $singular ), 'edit_item' => sprintf( 'Edit %s', $singular ), 'settings' => sprintf( '%s Settings', $singular ), + 'search_items' => sprintf( 'Search %s', $plural ), + 'not_found' => 'No items found.', 'item_created' => 'Item created successfully.', 'item_updated' => 'Item updated successfully.', 'item_deleted' => 'Item deleted successfully.', @@ -221,13 +235,52 @@ protected function route_singular(): void { $this->render_settings_form(); } + /** + * Build the ListQuery for the current list request. + * + * Reads the WP-conventional list parameters (paged / orderby / order / + * s / filter_) and validates them against the config's list + * declarations, so only declared-sortable fields can order the list + * and only declared-filterable fields can filter it. + * + * @return ListQuery The query for the current request. + */ + protected function build_list_query(): ListQuery { + $orderby = sanitize_key( (string) $this->request->get_param( 'orderby', '' ) ); + if ( ! in_array( $orderby, $this->config->get_sortable_fields(), true ) ) { + $orderby = ''; + } + + $filters = []; + foreach ( $this->config->get_filterable_fields() as $field ) { + $value = $this->request->get_param( 'filter_' . $field ); + if ( $value !== null && $value !== '' ) { + $filters[ $field ] = sanitize_text_field( (string) $value ); + } + } + + return new ListQuery( + page: max( 1, (int) $this->request->get_param( 'paged', 1 ) ), + per_page: $this->config->get_list_per_page(), + orderby: $orderby, + order: strtolower( (string) $this->request->get_param( 'order', 'asc' ) ), + search: sanitize_text_field( (string) $this->request->get_param( 's', '' ) ), + search_fields: $this->config->get_searchable_fields(), + filters: $filters + ); + } + /** * Render the list view. */ protected function render_list(): void { /** @var PluralHandler $handler */ $handler = $this->handler; - $result = $handler->list(); + + $query = $this->build_list_query(); + $this->current_list_query = $query; + + $result = $handler->query( $query ); $entities = []; foreach ( $result->get_entities() as $entity ) { @@ -236,16 +289,200 @@ protected function render_list(): void { $entities[] = $data; } + $total = $result->get_total() ?? count( $entities ); + $this->render_page_header( $this->get_label( 'all_items' ), $this->url_builder->url( 'create' ) ); $this->render_notices(); - if ( empty( $entities ) ) { - echo '

No items found.

'; - } else { - echo $this->render_list_table( $entities ); - } + $this->render_list_controls( $query ); + + // The table always renders — headers (and their sort links) must + // survive an empty page, e.g. a search that matched nothing. + echo $this->render_list_table( $entities ); + $this->render_pagination( $total, $query ); $this->render_page_footer(); + + $this->current_list_query = null; + } + + /** + * Render the search box and filter controls above the list, wrapped + * in a GET form that round-trips the page and sort state. + * + * Renders nothing when the config declares neither searchable nor + * filterable fields, keeping zero-config list pages unchanged. + * + * @param ListQuery $query The current list query. + */ + protected function render_list_controls( ListQuery $query ): void { + $searchable = $this->config->get_searchable_fields(); + $filterable = $this->config->get_filterable_fields(); + + if ( $searchable === [] && $filterable === [] ) { + return; + } + + echo '
'; + echo ''; + if ( $query->orderby !== '' ) { + echo ''; + echo ''; + } + + if ( $searchable !== [] ) { + $input_id = $this->config->get_menu_page() . '-search-input'; + echo ''; + } + + if ( $filterable !== [] ) { + echo '
'; + foreach ( $filterable as $field ) { + $this->render_list_filter( $field, $query->filters[ $field ] ?? '' ); + } + echo ''; + echo '
'; + } + + echo '
'; + } + + /** + * Render a single filter control. + * + * Fields whose config declares 'options' (value => label) render as a + * dropdown with an "all" default; other filterable fields stay + * URL-driven only. + * + * @param string $field Field name. + * @param string $current Currently applied filter value. + */ + protected function render_list_filter( string $field, string $current ): void { + $field_config = $this->config->get_field_config( $field ); + $options = $field_config['options'] ?? null; + + if ( ! is_array( $options ) || $options === [] ) { + return; + } + + $name = 'filter_' . $field; + $label = ucfirst( str_replace( '_', ' ', $field ) ); + + echo ''; + echo ''; + } + + /** + * Build a list URL carrying the current query state, with overrides. + * + * Pass null as an override value to drop that parameter. + * + * @param array $overrides Parameter overrides. + * @return string List URL. + */ + protected function list_url( array $overrides = [] ): string { + $args = []; + $query = $this->current_list_query; + + if ( $query !== null ) { + if ( $query->search !== '' ) { + $args['s'] = $query->search; + } + if ( $query->orderby !== '' ) { + $args['orderby'] = $query->orderby; + $args['order'] = $query->order; + } + if ( $query->page > 1 ) { + $args['paged'] = $query->page; + } + foreach ( $query->filters as $field => $value ) { + $args[ 'filter_' . $field ] = $value; + } + } + + foreach ( $overrides as $key => $value ) { + if ( $value === null ) { + unset( $args[ $key ] ); + } else { + $args[ $key ] = $value; + } + } + + return $this->url_builder->url( 'list', null, $args ); + } + + /** + * Render a list column header cell, as a sort link when the field is + * declared sortable. + * + * Uses core list-table classes (sortable / sorted, asc / desc) so + * wp-admin styles the indicators. + * + * @param string $field Field name. + * @return string Header cell HTML. + */ + protected function render_column_header( string $field ): string { + $label = ucfirst( str_replace( '_', ' ', $field ) ); + + if ( ! in_array( $field, $this->config->get_sortable_fields(), true ) ) { + return '' . esc_html( $label ) . ''; + } + + $query = $this->current_list_query; + $is_current = $query !== null && $query->orderby === $field; + $next_order = $is_current && $query->order === 'asc' ? 'desc' : 'asc'; + $class = $is_current ? 'sorted ' . $query->order : 'sortable ' . $next_order; + + // Sorting resets to page 1: the old page number is meaningless + // under a new order. + $url = $this->list_url( [ 'orderby' => $field, 'order' => $next_order, 'paged' => null ] ); + + return '' + . '' + . '' . esc_html( $label ) . '' + . '' + . ''; + } + + /** + * Render the pagination tablenav under the list. + * + * @param int $total Unpaginated match count. + * @param ListQuery $query The current list query. + */ + protected function render_pagination( int $total, ListQuery $query ): void { + if ( $query->per_page <= 0 ) { + return; + } + + $total_pages = (int) ceil( $total / $query->per_page ); + + echo '
'; + echo '' . esc_html( sprintf( '%d items', $total ) ) . ''; + + if ( $total_pages > 1 ) { + $links = paginate_links( [ + 'base' => $this->list_url( [ 'paged' => '%#%' ] ), + 'format' => '', + 'current' => $query->page, + 'total' => $total_pages, + ] ); + if ( is_string( $links ) ) { + echo '' . $links . ''; + } + } + + echo '
'; } /** @@ -512,19 +749,24 @@ protected function build_default_layout( Layout $layout ): void { * @return string HTML table. */ protected function render_list_table( array $entities ): string { - $fields = array_keys( $this->config->fields ); + $fields = $this->config->get_list_columns(); $html = ''; // Header. $html .= ''; foreach ( $fields as $field ) { - $html .= ''; + $html .= $this->render_column_header( $field ); } - $html .= ''; + $html .= ''; $html .= ''; // Body. $html .= ''; + if ( $entities === [] ) { + $html .= ''; + } foreach ( $entities as $entity ) { $html .= ''; foreach ( $fields as $field ) { diff --git a/src/RequestHandler/PluralHandler.php b/src/RequestHandler/PluralHandler.php index 90840de..2b1090d 100644 --- a/src/RequestHandler/PluralHandler.php +++ b/src/RequestHandler/PluralHandler.php @@ -8,6 +8,7 @@ namespace Tangible\RequestHandler; use Tangible\DataObject\DataSet; +use Tangible\DataObject\ListQuery; use Tangible\DataObject\PluralObject; use Tangible\DataObject\PluralObject\Entity; @@ -108,6 +109,46 @@ public function list(): Result { ->set_is_success( true ); } + /** + * List the entities matching a query: filtered, ordered, paginated. + * + * The result carries the page of entities plus the unpaginated total + * (Result::get_total()). + * + * With a PluralObject the query executes through it (natively when + * the storage supports it). Handlers that override list() without a + * PluralObject — adapter wrappers around external data sources — + * inherit a fallback that applies the query in memory over their + * full list(); such handlers should override query() too when their + * source can execute it natively. + * + * @param ListQuery $query The list query. + * @return Result Success with the matching page of entities and total. + */ + public function query( ListQuery $query ): Result { + $result = new Result(); + + if ( isset( $this->object ) ) { + return $result + ->set_entities( $this->object->query( $query ) ) + ->set_total( $this->object->count( $query ) ) + ->set_is_success( true ); + } + + $list = $this->list(); + if ( ! $list->is_success() ) { + return $list; + } + + $entities = $list->get_entities(); + $accessor = static fn( Entity $entity ): array => $entity->get_data() + [ 'id' => $entity->get_id() ]; + + return $result + ->set_entities( $query->apply( $entities, $accessor ) ) + ->set_total( $query->count_matching( $entities, $accessor ) ) + ->set_is_success( true ); + } + /** * Create a new entity. * diff --git a/src/RequestHandler/Result.php b/src/RequestHandler/Result.php index b097950..63ac45e 100644 --- a/src/RequestHandler/Result.php +++ b/src/RequestHandler/Result.php @@ -61,6 +61,14 @@ class Result { */ protected array $data = []; + /** + * Total number of matching entities for paginated list operations, + * ignoring pagination. Null when the operation was not a query. + * + * @var int|null + */ + protected ?int $total = null; + /** * Check if the operation resulted in an error. * @@ -174,6 +182,26 @@ public function get_field_errors( string $field ): array { ); } + /** + * Set the total number of matching entities for a paginated query. + * + * @param int $total The unpaginated match count. + * @return Result The result instance for method chaining. + */ + public function set_total( int $total ): Result { + $this->total = $total; + return $this; + } + + /** + * Get the total number of matching entities for a paginated query. + * + * @return int|null The unpaginated match count, or null when not a query result. + */ + public function get_total(): ?int { + return $this->total; + } + /** * Set the data array for singular object operations. * diff --git a/tests/phpunit/list-query.php b/tests/phpunit/list-query.php new file mode 100644 index 0000000..ac89664 --- /dev/null +++ b/tests/phpunit/list-query.php @@ -0,0 +1,580 @@ +assertSame( 1, $query->page ); + $this->assertSame( 0, $query->per_page ); + $this->assertSame( 'asc', $query->order ); + } + + public function test_query_offset_derives_from_page_and_per_page(): void { + $this->assertSame( 20, ( new ListQuery( page: 3, per_page: 10 ) )->offset() ); + $this->assertSame( 0, ( new ListQuery( page: 3, per_page: 0 ) )->offset() ); + } + + /** + * ========================================================================== + * ListQuery: in-memory reference semantics + * ========================================================================== + */ + + private function sample_rows(): array { + return [ + [ 'id' => 1, 'title' => 'Alpha video', 'type' => 'video', 'weight' => 10 ], + [ 'id' => 2, 'title' => 'beta text', 'type' => 'text', 'weight' => 2 ], + [ 'id' => 3, 'title' => 'Gamma VIDEO guide', 'type' => 'video', 'weight' => 30 ], + [ 'id' => 4, 'title' => 'Delta text', 'type' => 'text', 'weight' => 2 ], + ]; + } + + public function test_filters_are_string_loose_equality(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, filters: [ 'weight' => '2' ] ); + + $this->assertSame( [ 2, 4 ], array_column( $query->apply( $rows ), 'id' ) ); + $this->assertSame( 2, $query->count_matching( $rows ) ); + } + + public function test_filter_on_missing_field_matches_nothing(): void { + $query = new ListQuery( per_page: 0, filters: [ 'nonexistent' => 'x' ] ); + + $this->assertSame( [], $query->apply( $this->sample_rows() ) ); + $this->assertSame( 0, $query->count_matching( $this->sample_rows() ) ); + } + + public function test_search_is_case_insensitive_over_declared_fields(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, search: 'video', search_fields: [ 'title' ] ); + + $this->assertSame( [ 1, 3 ], array_column( $query->apply( $rows ), 'id' ) ); + } + + public function test_search_without_declared_fields_matches_any_field(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, search: 'video' ); + + // 'video' appears in the type field of rows 1 and 3, and their titles. + $this->assertSame( [ 1, 3 ], array_column( $query->apply( $rows ), 'id' ) ); + } + + public function test_search_and_filters_combine_as_and(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( + per_page: 0, + search: 'guide', + search_fields: [ 'title' ], + filters: [ 'type' => 'video' ] + ); + + $this->assertSame( [ 3 ], array_column( $query->apply( $rows ), 'id' ) ); + } + + public function test_ordering_compares_numeric_and_string_appropriately(): void { + $rows = $this->sample_rows(); + + $by_weight = new ListQuery( per_page: 0, orderby: 'weight', order: 'desc' ); + $this->assertSame( [ 3, 1, 2, 4 ], array_column( $by_weight->apply( $rows ), 'id' ) ); + + // Case-insensitive string sort: 'Alpha' < 'beta' < 'Delta' < 'Gamma'. + $by_title = new ListQuery( per_page: 0, orderby: 'title', order: 'asc' ); + $this->assertSame( [ 1, 2, 4, 3 ], array_column( $by_title->apply( $rows ), 'id' ) ); + } + + public function test_equal_order_keys_keep_original_order(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( per_page: 0, orderby: 'type', order: 'asc' ); + + // 'text' rows 2 and 4 tie; stable sort keeps 2 before 4. + $this->assertSame( [ 2, 4, 1, 3 ], array_column( $query->apply( $rows ), 'id' ) ); + } + + public function test_pagination_slices_after_filter_and_sort(): void { + $rows = $this->sample_rows(); + $query = new ListQuery( page: 2, per_page: 2, orderby: 'weight', order: 'asc' ); + + $this->assertSame( [ 1, 3 ], array_column( $query->apply( $rows ), 'id' ) ); + // count_matching ignores pagination. + $this->assertSame( 4, $query->count_matching( $rows ) ); + } + + public function test_apply_accepts_an_accessor_for_non_row_items(): void { + $entities = []; + foreach ( $this->sample_rows() as $row ) { + $entity = new Entity( $row ); + $entity->set_id( $row['id'] ); + $entities[] = $entity; + } + + $query = new ListQuery( per_page: 0, filters: [ 'type' => 'video' ] ); + $accessor = static fn( Entity $e ): array => $e->get_data(); + + $matched = $query->apply( $entities, $accessor ); + + $this->assertCount( 2, $matched ); + $this->assertContainsOnlyInstancesOf( Entity::class, $matched ); + $this->assertSame( 2, $query->count_matching( $entities, $accessor ) ); + } + + /** + * ========================================================================== + * PluralObject: in-memory fallback over a non-queryable storage + * ========================================================================== + */ + + private function make_cpt_object( string $slug ): PluralObject { + $dataset = new DataSet(); + $dataset->add_string( 'title' ); + $dataset->add_string( 'category' ); + + $object = new PluralObject( $slug ); + $object->set_dataset( $dataset ); + $object->register( [ 'public' => false, 'show_ui' => true ] ); + + return $object; + } + + public function test_plural_object_query_falls_back_to_in_memory(): void { + $object = $this->make_cpt_object( 'lq_cpt_fallback' ); + + $object->create( [ 'title' => 'Charlie', 'category' => 'b' ] ); + $object->create( [ 'title' => 'Alice', 'category' => 'a' ] ); + $object->create( [ 'title' => 'Bob', 'category' => 'b' ] ); + + $query = new ListQuery( per_page: 2, orderby: 'title', order: 'asc', filters: [ 'category' => 'b' ] ); + + $titles = array_map( + static fn( Entity $e ) => $e->get( 'title' ), + $object->query( $query ) + ); + + $this->assertSame( [ 'Bob', 'Charlie' ], $titles ); + $this->assertSame( 2, $object->count( $query ) ); + } + + public function test_plural_object_delegates_to_queryable_storage(): void { + $storage = new class() implements QueryablePluralStorage { + public array $received = []; + public function register( string $slug, array $settings ): void {} + public function insert( array $data ): int { + return 1; } + public function update( int $id, array $data ): void {} + public function delete( int $id ): void {} + public function find( int $id ): ?array { + return null; } + public function all(): array { + $this->received[] = 'all'; + return []; + } + public function query( ListQuery $query ): array { + $this->received[] = 'query'; + return [ [ 'id' => 42, 'title' => 'native' ] ]; + } + public function count( ListQuery $query ): int { + $this->received[] = 'count'; + return 7; + } + }; + + $object = new PluralObject( 'lq_native', $storage ); + + $query = new ListQuery(); + $entities = $object->query( $query ); + + $this->assertSame( 42, $entities[0]->get_id() ); + $this->assertSame( 7, $object->count( $query ) ); + // The storage executed natively; all() was never consulted. + $this->assertSame( [ 'query', 'count' ], $storage->received ); + } + + /** + * ========================================================================== + * PluralHandler: query() with an object, and the adapter-wrapper fallback + * ========================================================================== + */ + + public function test_handler_query_returns_page_and_total(): void { + $object = $this->make_cpt_object( 'lq_handler' ); + for ( $i = 1; $i <= 5; $i++ ) { + $object->create( [ 'title' => 'Item ' . $i, 'category' => 'x' ] ); + } + + $handler = new PluralHandler( $object ); + $result = $handler->query( new ListQuery( page: 2, per_page: 2, orderby: 'title' ) ); + + $this->assertTrue( $result->is_success() ); + $this->assertCount( 2, $result->get_entities() ); + $this->assertSame( 5, $result->get_total() ); + $this->assertSame( + [ 'Item 3', 'Item 4' ], + array_map( static fn( Entity $e ) => $e->get( 'title' ), $result->get_entities() ) + ); + } + + /** + * Adapter wrappers (LMS/Quiz) extend PluralHandler WITHOUT a + * PluralObject and only override list(). The inherited query() must + * fall back to applying the query in memory over their list(). + */ + public function test_handler_query_falls_back_over_overridden_list(): void { + $handler = new class() extends PluralHandler { + public function __construct() { + // Deliberately no parent constructor — no PluralObject, + // mirroring the downstream adapter wrappers. + } + public function list(): Result { + $entities = []; + foreach ( [ + [ 'title' => 'Zeta', 'type' => 'video' ], + [ 'title' => 'Alpha', 'type' => 'text' ], + [ 'title' => 'Mid', 'type' => 'video' ], + ] as $i => $row ) { + $entity = new Entity( $row ); + $entity->set_id( $i + 1 ); + $entities[] = $entity; + } + return ( new Result() )->set_entities( $entities )->set_is_success( true ); + } + }; + + $result = $handler->query( new ListQuery( + per_page: 1, + orderby: 'title', + order: 'asc', + filters: [ 'type' => 'video' ] + ) ); + + $this->assertTrue( $result->is_success() ); + $this->assertSame( 2, $result->get_total() ); + $this->assertCount( 1, $result->get_entities() ); + $this->assertSame( 'Mid', $result->get_entities()[0]->get( 'title' ) ); + } + + public function test_result_total_defaults_to_null(): void { + $this->assertNull( ( new Result() )->get_total() ); + } + + /** + * ========================================================================== + * DatabaseModuleStorage: native SQL execution + * ========================================================================== + */ + + private function make_tdb_storage( string $slug ): DatabaseModuleStorage { + if ( ! function_exists( 'tdb_register_table' ) ) { + $this->markTestSkipped( 'Database module (TDB) is not loaded.' ); + } + + $storage = new DatabaseModuleStorage( $slug ); + $storage->register( $slug, [ + 'schema' => [ + 'id' => [ + 'type' => 'bigint', + 'length' => '20', + 'auto_increment' => true, + 'primary_key' => true, + ], + 'title' => [ + 'type' => 'varchar', + 'length' => '255', + ], + 'category' => [ + 'type' => 'varchar', + 'length' => '64', + ], + 'weight' => [ + 'type' => 'bigint', + 'length' => '20', + ], + ], + ] ); + + return $storage; + } + + private function seed_tdb( DatabaseModuleStorage $storage ): void { + $storage->insert( [ 'title' => 'Alpha video', 'category' => 'video', 'weight' => 10 ] ); + $storage->insert( [ 'title' => 'beta text', 'category' => 'text', 'weight' => 2 ] ); + $storage->insert( [ 'title' => 'Gamma VIDEO guide', 'category' => 'video', 'weight' => 30 ] ); + $storage->insert( [ 'title' => 'Delta text', 'category' => 'text', 'weight' => 2 ] ); + } + + public function test_tdb_storage_implements_queryable_interface(): void { + if ( ! function_exists( 'tdb_register_table' ) ) { + $this->markTestSkipped( 'Database module (TDB) is not loaded.' ); + } + + $this->assertInstanceOf( + QueryablePluralStorage::class, + new DatabaseModuleStorage( 'lq_tdb_iface' ) + ); + } + + public function test_tdb_query_matches_in_memory_reference_semantics(): void { + $storage = $this->make_tdb_storage( 'lq_tdb_parity' ); + $this->seed_tdb( $storage ); + + $scenarios = [ + new ListQuery( per_page: 0 ), + new ListQuery( per_page: 0, filters: [ 'category' => 'text' ] ), + new ListQuery( per_page: 0, search: 'video', search_fields: [ 'title' ] ), + new ListQuery( per_page: 0, search: 'video', search_fields: [ 'title' ], filters: [ 'category' => 'video' ] ), + new ListQuery( per_page: 0, orderby: 'weight', order: 'desc' ), + new ListQuery( page: 2, per_page: 2, orderby: 'title', order: 'asc' ), + new ListQuery( per_page: 0, filters: [ 'nonexistent' => 'x' ] ), + new ListQuery( per_page: 0, search: 'anything', search_fields: [ 'nonexistent' ] ), + ]; + + $all = $storage->all(); + + foreach ( $scenarios as $i => $query ) { + $this->assertSame( + array_column( $query->apply( $all ), 'id' ), + array_column( $storage->query( $query ), 'id' ), + "Scenario {$i}: native rows must match the in-memory reference" + ); + $this->assertSame( + $query->count_matching( $all ), + $storage->count( $query ), + "Scenario {$i}: native count must match the in-memory reference" + ); + } + } + + public function test_tdb_query_orders_by_id_alias(): void { + $storage = $this->make_tdb_storage( 'lq_tdb_id_alias' ); + $this->seed_tdb( $storage ); + + $rows = $storage->query( new ListQuery( per_page: 2, orderby: 'id', order: 'desc' ) ); + + $ids = array_column( $rows, 'id' ); + $this->assertCount( 2, $ids ); + $this->assertSame( max( array_column( $storage->all(), 'id' ) ), $ids[0] ); + } + + /** + * ========================================================================== + * DataViewConfig: the 'list' section + * ========================================================================== + */ + + private function base_config( array $overrides = [] ): array { + return array_merge( [ + 'slug' => 'lq_config', + 'label' => 'Item', + 'fields' => [ + 'title' => 'string', + 'category' => 'string', + ], + ], $overrides ); + } + + public function test_config_list_defaults(): void { + $config = new \Tangible\DataView\DataViewConfig( $this->base_config() ); + + $this->assertSame( [ 'title', 'category' ], $config->get_list_columns() ); + $this->assertSame( [], $config->get_sortable_fields() ); + $this->assertSame( [], $config->get_searchable_fields() ); + $this->assertSame( [], $config->get_filterable_fields() ); + $this->assertSame( 20, $config->get_list_per_page() ); + } + + public function test_config_list_declarations_are_honored(): void { + $config = new \Tangible\DataView\DataViewConfig( $this->base_config( [ + 'list' => [ + 'columns' => [ 'title' ], + 'sortable' => [ 'title', 'id' ], + 'searchable' => [ 'title' ], + 'filterable' => [ 'category' ], + 'per_page' => 5, + ], + ] ) ); + + $this->assertSame( [ 'title' ], $config->get_list_columns() ); + $this->assertSame( [ 'title', 'id' ], $config->get_sortable_fields() ); + $this->assertSame( [ 'category' ], $config->get_filterable_fields() ); + $this->assertSame( 5, $config->get_list_per_page() ); + } + + public function test_config_rejects_unknown_list_fields(): void { + $this->expectException( \InvalidArgumentException::class ); + $this->expectExceptionMessage( 'list.sortable' ); + + new \Tangible\DataView\DataViewConfig( $this->base_config( [ + 'list' => [ 'sortable' => [ 'bogus' ] ], + ] ) ); + } + + public function test_config_rejects_negative_per_page(): void { + $this->expectException( \InvalidArgumentException::class ); + + new \Tangible\DataView\DataViewConfig( $this->base_config( [ + 'list' => [ 'per_page' => -1 ], + ] ) ); + } + + /** + * ========================================================================== + * RequestRouter: list rendering + * ========================================================================== + */ + + private array $saved_get = []; + + private function render_list_output( array $config_overrides = [], array $get = [] ): string { + wp_set_current_user( $this->factory->user->create( [ 'role' => 'administrator' ] ) ); + + $this->saved_get = $_GET; + $_GET = array_merge( $_GET, $get ); + + try { + // Request snapshots superglobals at construction, so the view + // (and its router) must be built after $_GET is staged. + $view = new DataView( array_merge( $this->base_config( [ + 'slug' => 'lq_router_' . md5( serialize( [ $config_overrides, $get ] ) ), + ] ), $config_overrides ) ); + + $router = new \ReflectionProperty( DataView::class, 'router' ); + $router->setAccessible( true ); + + ob_start(); + $router->getValue( $view )->route(); + return (string) ob_get_clean(); + } finally { + $_GET = $this->saved_get; + } + } + + public function test_router_renders_sortable_header_link(): void { + $html = $this->render_list_output( [ + 'list' => [ 'sortable' => [ 'title' ] ], + ] ); + + $this->assertStringContainsString( 'sortable', $html ); + $this->assertStringContainsString( 'orderby=title', $html ); + $this->assertStringContainsString( 'sorting-indicator', $html ); + // The non-sortable column stays a plain header. + $this->assertStringContainsString( '', $html ); + } + + public function test_router_renders_search_box_only_when_declared(): void { + $without = $this->render_list_output(); + $this->assertStringNotContainsString( 'search-box', $without ); + + $with = $this->render_list_output( [ + 'list' => [ 'searchable' => [ 'title' ] ], + ] ); + $this->assertStringContainsString( 'search-box', $with ); + $this->assertStringContainsString( 'name="s"', $with ); + } + + public function test_router_renders_filter_dropdown_from_field_options(): void { + $html = $this->render_list_output( [ + 'fields' => [ + 'title' => 'string', + 'category' => [ + 'type' => 'string', + 'options' => [ 'video' => 'Video', 'text' => 'Text' ], + ], + ], + 'list' => [ 'filterable' => [ 'category' ] ], + ] ); + + $this->assertStringContainsString( 'name="filter_category"', $html ); + $this->assertStringContainsString( '>Video<', $html ); + } + + public function test_router_list_respects_orderby_search_and_pagination(): void { + $slug = 'lq_router_data'; + $object = null; + + $config = $this->base_config( [ + 'slug' => $slug, + 'list' => [ + 'sortable' => [ 'title' ], + 'searchable' => [ 'title' ], + 'per_page' => 2, + ], + ] ); + + // Seed through a handler-equivalent object so rows exist for the + // router's CPT-backed DataView (same slug, same storage). + $dataset = new DataSet(); + $dataset->add_string( 'title' ); + $dataset->add_string( 'category' ); + $object = new PluralObject( $slug ); + $object->set_dataset( $dataset ); + $object->register( [ 'public' => false, 'show_ui' => true ] ); + foreach ( [ 'Bravo', 'Alpha', 'Charlie' ] as $title ) { + $object->create( [ 'title' => $title, 'category' => 'x' ] ); + } + + $this->saved_get = $_GET; + $_GET = array_merge( $_GET, [ 'orderby' => 'title', 'order' => 'asc' ] ); + try { + $view = new DataView( $config ); + $router = new \ReflectionProperty( DataView::class, 'router' ); + $router->setAccessible( true ); + + wp_set_current_user( $this->factory->user->create( [ 'role' => 'administrator' ] ) ); + ob_start(); + $router->getValue( $view )->route(); + $html = (string) ob_get_clean(); + } finally { + $_GET = $this->saved_get; + } + + // Page 1 of 2-per-page, sorted: Alpha and Bravo visible, Charlie not. + $this->assertStringContainsString( 'Alpha', $html ); + $this->assertStringContainsString( 'Bravo', $html ); + $this->assertStringNotContainsString( 'Charlie', $html ); + // Pagination reports the full count and links to page 2. + $this->assertStringContainsString( '3 items', $html ); + $this->assertStringContainsString( 'paged=2', $html ); + // The active sort column flips its link to descending. + $this->assertStringContainsString( 'order=desc', $html ); + } + + public function test_router_list_ignores_undeclared_orderby(): void { + $html = $this->render_list_output( [], [ 'orderby' => 'title', 'order' => 'asc' ] ); + + // 'title' is not declared sortable in the base config, so no header + // renders as currently-sorted. + $this->assertStringNotContainsString( 'class="manage-column sorted', $html ); + } +} From 870573a866bff99a8ade06c6a2a998b2a7a9add3 Mon Sep 17 00:00:00 2001 From: Igor Zinovyev Date: Fri, 14 Aug 2026 14:34:41 +0400 Subject: [PATCH 2/3] chore(tests): honor WP_TESTS_CONFIG_FILE_PATH from the environment The WP test bootstrap only reads the config-file override as a PHP constant, so pointing the suite at an alternative wp-tests-config.php (e.g. one with a scratch database) required either editing the wordpress-develop checkout or a wrapper bootstrap defining the constant. Lift the environment variable into the constant instead: WP_TESTS_CONFIG_FILE_PATH=path make test now just works. Co-Authored-By: Claude Fable 5 --- tests/bootstrap.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index fdb29c8..47a4cc5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,16 @@ $_WORDPRESS_DEVELOP_DIR = __DIR__ . '/../wordpress-develop'; } +/** + * Optional: point the WP test suite at an alternative wp-tests-config.php + * (e.g. one with a scratch database) without editing the wordpress-develop + * checkout. The WP bootstrap only honors the constant, so lift the + * environment variable into it. + */ +if ( ! defined( 'WP_TESTS_CONFIG_FILE_PATH' ) && ( $_WP_TESTS_CONFIG = getenv( 'WP_TESTS_CONFIG_FILE_PATH' ) ) ) { + define( 'WP_TESTS_CONFIG_FILE_PATH', $_WP_TESTS_CONFIG ); +} + /** * Directory of PHPUnit test files * From c592d77d3639086e3dea885e1792c4ae2d831a15 Mon Sep 17 00:00:00 2001 From: Igor Zinovyev Date: Fri, 14 Aug 2026 17:47:10 +0400 Subject: [PATCH 3/3] feat: parent-aware URL building for parented menu pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataView pages registered under a parent admin file (e.g. a post-type submenu, ui.parent 'edit.php?post_type=book') got broken list chrome: sort links and pagination built on admin.php, and the list form's GET submit dropped the parent's parameters, landing on the parent screen instead of the page. UrlBuilder now accepts the parent menu and follows WordPress's own submenu URL rule — a .php parent becomes the URL base with its query parameters; a null or plugin-slug parent keeps admin.php. Its new base_params() is the single source for what identifies the page, and the router's list form renders exactly those as hidden inputs instead of hardcoding the page slug. Found porting the LMS Resources listing (a CPT-submenu page) onto the list mode. Co-Authored-By: Claude Fable 5 --- src/DataView/DataView.php | 5 ++- src/DataView/RequestRouter.php | 7 +++- src/DataView/UrlBuilder.php | 58 ++++++++++++++++++++++++++++++++-- tests/phpunit/list-query.php | 47 +++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/DataView/DataView.php b/src/DataView/DataView.php index bbd10cd..cad0124 100644 --- a/src/DataView/DataView.php +++ b/src/DataView/DataView.php @@ -81,7 +81,10 @@ public function __construct( array $config, ?FieldTypeRegistry $registry = null $this->build_object(); $this->build_handler(); - $this->url_builder = new UrlBuilder( $this->config->get_menu_page() ); + $this->url_builder = new UrlBuilder( + $this->config->get_menu_page(), + $this->config->get_parent_menu() + ); $this->router = new RequestRouter( $this->config, $this->dataset, diff --git a/src/DataView/RequestRouter.php b/src/DataView/RequestRouter.php index 2d1ce94..0eec7da 100644 --- a/src/DataView/RequestRouter.php +++ b/src/DataView/RequestRouter.php @@ -324,7 +324,12 @@ protected function render_list_controls( ListQuery $query ): void { } echo '
'; - echo ''; + // Everything that identifies the page must ride the GET submit — + // for a parented page that includes the parent file's parameters + // (e.g. post_type for a post-type submenu), not just the slug. + foreach ( $this->url_builder->base_params() as $name => $value ) { + echo ''; + } if ( $query->orderby !== '' ) { echo ''; echo ''; diff --git a/src/DataView/UrlBuilder.php b/src/DataView/UrlBuilder.php index 77ae2b1..2b8e32c 100644 --- a/src/DataView/UrlBuilder.php +++ b/src/DataView/UrlBuilder.php @@ -9,8 +9,60 @@ class UrlBuilder { protected string $menu_page; - public function __construct( string $menu_page ) { + /** + * The admin file the page hangs off, e.g. 'admin.php' (top level) or + * 'edit.php' for a post-type submenu. + * + * @var string + */ + protected string $base_file = 'admin.php'; + + /** + * Query parameters the base file needs to resolve the page, e.g. + * ['post_type' => 'book'] for a post-type submenu. Always merged + * into generated URLs, and rendered as hidden inputs by list forms. + * + * @var array + */ + protected array $base_query = []; + + /** + * Create a new UrlBuilder. + * + * The parent menu follows WordPress's own submenu URL rule: when it + * names an admin file ('edit.php?post_type=book', + * 'options-general.php'), page URLs build on that file with its + * query parameters; a null parent or a plugin-page slug keeps the + * top-level 'admin.php' base. + * + * @param string $menu_page The page slug. + * @param string|null $parent The parent menu (DataView ui.parent), if any. + */ + public function __construct( string $menu_page, ?string $parent = null ) { $this->menu_page = $menu_page; + + if ( $parent !== null && str_contains( $parent, '.php' ) ) { + $parts = explode( '?', $parent, 2 ); + $this->base_file = $parts[0]; + + if ( isset( $parts[1] ) ) { + parse_str( $parts[1], $query ); + foreach ( $query as $key => $value ) { + $this->base_query[ (string) $key ] = (string) $value; + } + } + } + } + + /** + * The parameters that identify this page: the base file's query + * parameters plus the page slug. List forms render these as hidden + * inputs so a GET submit resolves back to the page. + * + * @return array Parameter name => value. + */ + public function base_params(): array { + return $this->base_query + [ 'page' => $this->menu_page ]; } /** @@ -22,7 +74,7 @@ public function __construct( string $menu_page ) { * @return string Admin URL. */ public function url( string $action = 'list', ?int $id = null, array $extra = [] ): string { - $params = [ 'page' => $this->menu_page ]; + $params = $this->base_params(); if ( $action !== 'list' ) { $params['action'] = $action; @@ -34,7 +86,7 @@ public function url( string $action = 'list', ?int $id = null, array $extra = [] $params = array_merge( $params, $extra ); - return add_query_arg( $params, admin_url( 'admin.php' ) ); + return add_query_arg( $params, admin_url( $this->base_file ) ); } /** diff --git a/tests/phpunit/list-query.php b/tests/phpunit/list-query.php index ac89664..6dc420b 100644 --- a/tests/phpunit/list-query.php +++ b/tests/phpunit/list-query.php @@ -570,6 +570,53 @@ public function test_router_list_respects_orderby_search_and_pagination(): void $this->assertStringContainsString( 'order=desc', $html ); } + /** + * ========================================================================== + * UrlBuilder: parented menu pages + * ========================================================================== + */ + + public function test_url_builder_defaults_to_admin_php(): void { + $builder = new \Tangible\DataView\UrlBuilder( 'my_page' ); + + $this->assertStringContainsString( 'admin.php', $builder->url( 'list' ) ); + $this->assertSame( [ 'page' => 'my_page' ], $builder->base_params() ); + } + + public function test_url_builder_builds_on_the_parent_file(): void { + $builder = new \Tangible\DataView\UrlBuilder( 'my_page', 'edit.php?post_type=book' ); + + $url = $builder->url( 'list' ); + $this->assertStringContainsString( 'edit.php', $url ); + $this->assertStringContainsString( 'post_type=book', $url ); + $this->assertStringContainsString( 'page=my_page', $url ); + $this->assertStringNotContainsString( 'admin.php', $url ); + + $this->assertSame( + [ 'post_type' => 'book', 'page' => 'my_page' ], + $builder->base_params() + ); + } + + public function test_url_builder_treats_plugin_slug_parent_as_top_level(): void { + // A parent that is another plugin page's slug (no .php) keeps the + // admin.php base — matching WordPress's own submenu URL rule. + $builder = new \Tangible\DataView\UrlBuilder( 'my_page', 'some-plugin-menu' ); + + $this->assertStringContainsString( 'admin.php', $builder->url( 'list' ) ); + $this->assertSame( [ 'page' => 'my_page' ], $builder->base_params() ); + } + + public function test_router_list_form_carries_parent_params_as_hidden_inputs(): void { + $html = $this->render_list_output( [ + 'ui' => [ 'parent' => 'edit.php?post_type=book' ], + 'list' => [ 'searchable' => [ 'title' ] ], + ] ); + + $this->assertStringContainsString( 'name="post_type" value="book"', $html ); + $this->assertStringContainsString( 'name="page"', $html ); + } + public function test_router_list_ignores_undeclared_orderby(): void { $html = $this->render_list_output( [], [ 'orderby' => 'title', 'order' => 'asc' ] );
' . esc_html( ucfirst( $field ) ) . 'ActionsActions
' + . esc_html( $this->get_label( 'not_found' ) ) + . '
Category