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/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/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..0eec7da 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,205 @@ 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 '
'; + // 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 ''; + } + + 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 +754,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/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/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/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 * diff --git a/tests/phpunit/list-query.php b/tests/phpunit/list-query.php new file mode 100644 index 0000000..6dc420b --- /dev/null +++ b/tests/phpunit/list-query.php @@ -0,0 +1,627 @@ +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 ); + } + + /** + * ========================================================================== + * 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' ] ); + + // 'title' is not declared sortable in the base config, so no header + // renders as currently-sorted. + $this->assertStringNotContainsString( 'class="manage-column sorted', $html ); + } +}
' . esc_html( ucfirst( $field ) ) . 'ActionsActions
' + . esc_html( $this->get_label( 'not_found' ) ) + . '
Category