Skip to content

Front-end pagination support #449

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/extending/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ There are also some special input variable types:
- `ui:pagination_offset`: A variable with this type indicates that the query supports offset pagination. It must accept an `integer` containing the requested offset. See `pagination_schema` for additional information and requirements.
- `ui:pagination_page`: A variable with this type indicates that the query supports page-based pagination. It must accept an `integer` containing the requested results page. See `pagination_schema` for additional information and requirements.
- `ui:pagination_per_page`: A variable with this type indicates that the query supports controlling the number of resultsper page. It must accept an `integer` containing the number of requested results.
- `ui:pagination_cursor_next` and `ui_pagination_cursor_previous`: Variables with these types indicate that the query supports cursor pagination. They accept `strings` containing the requested cursor. See `pagination_schema` for additional information and requirements.
- `ui:pagination_cursor_next` and `ui_pagination_cursor_previous`: Variables with these types indicate that the query supports cursor pagination. They accept `string`s containing the requested cursor. See `pagination_schema` for additional information and requirements.
- `ui:pagination_cursor`: A variable with this type indicates support for a simple variant of cursor pagination that uses a single cursor instead of a pair of forward / backward cursors. It accepts a `string` containing the requested cursor. See `pagination_schema` for additional information and requirements.

#### Example with search and pagination input variables

Expand Down Expand Up @@ -196,11 +197,14 @@ We have more in-depth [`output_schema`](./query-output_schema.md) examples.

If your query supports pagination, the `pagination_schema` property defines how to extract pagination-related values from the query response. If defined, the property should be an associative array with the following structure:

- `total_items` (required): A variable definition that extracts the total number of items across every page of results.
- `cursor_next`: If your query supports cursor pagination, a variable definition that extracts the cursor for the next page of results.
- `total_items`: A variable definition that extracts the total number of items across every page of results.
- `has_next_page`: A variable definition that extracts a boolean indicating whether there are more pages of results. Useful for APIs that do not report the total number of items.
- `cursor_next`: If your query supports cursor pagination, a variable definition that extracts the cursor for the next page of results. This output variable will also be mapped to `ui:pagination_cursor`, if present.
- `cursor_previous`: If your query supports cursor pagination, a variable definition that extracts the cursor for the previous page of results.

Note that the `total_items` variable is required for all types of pagination.
Note that one of `has_next_page` or `total_items` is required for all pagination types.

A pagination block will automatically be added to remote data blocks that support pagination.

#### Example

Expand Down
10 changes: 8 additions & 2 deletions example/rest-api/art-institute/art-institute.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,19 @@ function register_aic_block(): void {
return add_query_arg( [
'limit' => $input_variables['limit'],
'fields' => 'id,title,image_id,artist_title',
'page' => $input_variables['page'],
], $endpoint );
},
'input_schema' => [
'limit' => [
'name' => 'Limit',
'type' => 'ui:input',
'default_value' => 10,
'name' => 'Pagination limit',
'type' => 'ui:pagination_per_page',
],
'page' => [
'default_value' => 1,
'name' => 'Pagination page',
'type' => 'ui:pagination_page',
],
],
'output_schema' => [
Expand Down
10 changes: 9 additions & 1 deletion inc/Config/ArraySerializable.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
* ArraySerializable class
*/
abstract class ArraySerializable implements ArraySerializableInterface {
final private function __construct( protected array $config ) {}
protected string $id;

final private function __construct( protected array $config ) {
$this->id = md5( wp_json_encode( [ get_called_class(), $config ] ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the thoughts I had was to see if we could leverage the UUID from the config instead of doing it this way. But, I see that the problem is this isn't always available like for code based sources (Art Institute being an example of this).

Is there any benefit in changing that, so that all data source configs do have a UUID assigned to them? Then, we could use that UUID here and wherever else we might need to in the future?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All good if the answer is no, or that it be punted to a future PR. This is just a thought that occurred to me when I tried to experiment with changing it to the UUID, and not seeing it reflect since the Art Institute didn't have one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any benefit in changing that, so that all data source configs do have a UUID assigned to them? Then, we could use that UUID here and wherever else we might need to in the future?

It's a good question. We've discussed both sides. The major downsides of universally requiring UUIDs for all entities are:

  1. You have to delegate UUID creation to the user, and they can mess it up. We cannot create stable UUIDs on their behalf without persistence (which code-generated entities don't have). We can validate that a string looks like a UUID, but we can't really validate that it is unique or stable.
  2. There isn't really much benefit to the user that justifies making this a requirement. Code-generated UUIDs are not persisted and there is no central store, so there is no concept of "get by UUID." Even if there was, users have already created an instance of their entity and can freely pass it around in their code—which is probably easier and clearer than passing around a UUID string.

Another downside of UUIDs in this use case is that they have no functional value. They are just identifiers. The ID introduced in this PR, by contrast, is deterministic based on the inputs that influence its behavior. This is useful since we are using it to identify external attributes that can be applied to this entity (pagination variables). If the input changes, the ID changes, and it makes sense that the attributes might no longer apply. Meanwhile, a UUID might change for any number of reasons that have nothing to do with the entity's behavior.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All that said, the main reason that this ID is useful at all is because block instances don't have stable identifiers. If they did, we would use them.

}

final public function get_id(): string {
return $this->id;
}

protected function get_or_call_from_config( string $property_name, mixed ...$callable_args ): mixed {
$config_value = $this->config[ $property_name ] ?? null;
Expand Down
6 changes: 6 additions & 0 deletions inc/Config/ArraySerializableInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ interface ArraySerializableInterface {
*/
public static function from_array( array $config, ?ValidatorInterface $validator ): static|WP_Error;

/**
* Get a deterministic identifier for the instance. Instances with the same
* base class and config should have the same ID.
*/
public function get_id(): string;

/**
* This method will be called by ::from_array() to prior to validating the
* config. This allows you to modify the config before it's validated, perhaps
Expand Down
5 changes: 4 additions & 1 deletion inc/Config/QueryRunner/QueryRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Exception;
use GuzzleHttp\RequestOptions;
use RemoteDataBlocks\Config\Query\HttpQueryInterface;
use RemoteDataBlocks\Editor\DataBinding\Pagination;
use RemoteDataBlocks\HttpClient\HttpClient;
use WP_Error;

Expand Down Expand Up @@ -252,8 +253,9 @@ public function execute( HttpQueryInterface $query, array $input_variables ): ar

return [
'metadata' => $metadata,
'pagination' => $pagination,
'pagination' => Pagination::format_pagination_data_for_query_response( $pagination, $input_schema, $input_variables ),
'results' => $results,
'query_id' => $query->get_id(),
'query_inputs' => [ $input_variables ],
];
}
Expand Down Expand Up @@ -309,6 +311,7 @@ function ( array $carry, mixed $item ): array {
'metadata' => $this->get_response_metadata( $query, [ 'batch' => true ], $merged_results ),
'pagination' => null, // Pagination is always disabled for batch executions.
'results' => $merged_results,
'query_id' => $query->get_id(),
'query_inputs' => $merged_query_inputs,
];
}
Expand Down
3 changes: 3 additions & 0 deletions inc/Editor/BlockManagement/BlockRegistration.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public static function register_helper_blocks(): void {
// Remote data HTML block - used to render HTML content in the absence of a proper binding.
register_block_type( REMOTE_DATA_BLOCKS__PLUGIN_DIRECTORY . '/build/blocks/remote-html' );

// Remote data pagination block - used to render pagination links for collections.
register_block_type( REMOTE_DATA_BLOCKS__PLUGIN_DIRECTORY . '/build/blocks/remote-data-pagination' );

// Remote data template - used to render remote data collections.
register_block_type( REMOTE_DATA_BLOCKS__PLUGIN_DIRECTORY . '/build/blocks/remote-data-template' );
}
Expand Down
42 changes: 40 additions & 2 deletions inc/Editor/DataBinding/BlockBindings.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use RemoteDataBlocks\Logging\LoggerManager;
use RemoteDataBlocks\Sanitization\Sanitizer;
use WP_Block;
use function add_action;
use function add_filter;
use function remove_filter;

use function register_block_bindings_source;

Expand Down Expand Up @@ -130,8 +133,7 @@ private static function execute_queries( array $block_context, array $source_arg
$enabled_overrides = $source_args['enabledOverrides'] ?? $remote_data['enabledOverrides'];
$query_key = $source_args['queryKey'] ?? $remote_data['queryKey'] ?? ConfigRegistry::DISPLAY_QUERY_KEY;

// Extract the input variables. Support the previous property name used
// before we allowed multiple query inputs.
// Extract the input variables. Allow the binding source args to override.
$array_of_input_variables = $source_args['queryInputs'] ?? $remote_data['queryInputs'];

$block_config = ConfigStore::get_block_configuration( $block_name );
Expand All @@ -142,6 +144,13 @@ private static function execute_queries( array $block_context, array $source_arg
return null;
}

// If there is a single array of input variables, fetch pagination variables.
// Pagination is disabled for batch execution.
if ( 1 === count( $array_of_input_variables ) ) {
$pagination_input_variables = Pagination::get_pagination_input_variables_for_current_request( $query );
$array_of_input_variables[0] = array_merge( $array_of_input_variables[0] ?? [], $pagination_input_variables );
}

$array_of_input_variables = array_map( function ( $input_variables ) use ( $enabled_overrides, $block_name ): array {
/**
* Filter the query input overrides for a block binding.
Expand Down Expand Up @@ -189,6 +198,35 @@ private static function execute_queries( array $block_context, array $source_arg
}
}

public static function get_pagination_links( WP_Block $block ): array {
$block_context = $block->context[ self::$context_name ] ?? [];
$query_response = self::execute_queries( $block_context, [], 'remote_data_block_get_pagination_data' );

$pagination_data = $query_response['pagination'] ?? null;
$query_id = $query_response['query_id'] ?? null;

if ( null === $pagination_data || null === $query_id ) {
return [];
}

$next_link = null;
$previous_link = null;

// Create pagination links.
if ( isset( $pagination_data['input_variables']['next_page'] ) ) {
$next_link = Pagination::create_query_var( $query_id, $pagination_data['input_variables']['next_page'] );
}

if ( isset( $pagination_data['input_variables']['previous_page'] ) ) {
$previous_link = Pagination::create_query_var( $query_id, $pagination_data['input_variables']['previous_page'] );
}

return [
'next_page' => $next_link,
'previous_page' => $previous_link,
];
}

public static function get_value( array $source_args, WP_Block|array $block, string $attribute_name ): ?string {
// We may be passed a block instance (by core block bindings) or a block
// array (by our hooks into the Block Data API).
Expand Down
212 changes: 212 additions & 0 deletions inc/Editor/DataBinding/Pagination.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php declare(strict_types = 1);

namespace RemoteDataBlocks\Editor\DataBinding;

use RemoteDataBlocks\Config\Query\QueryInterface;

use function add_filter;
use function add_query_arg;
use function get_query_var;

defined( 'ABSPATH' ) || exit();

class Pagination {
private static $variable_name = 'rdb-pagination';

public static function init(): void {
add_filter( 'query_vars', [ __CLASS__, 'register_query_var' ], 10, 1 );
}

public static function create_query_var( string $query_id, array $pagination_input_variables ): string {
$value = [
$query_id => $pagination_input_variables,
];

return add_query_arg( self::$variable_name, self::encode_query_var( $value ) );
}

private static function decode_query_var( string $query_var_string ): array {
return json_decode( base64_decode( $query_var_string ), true ) ?? [];
}

private static function encode_query_var( array $query_var_value ): string {
return base64_encode( wp_json_encode( $query_var_value ) );
}

public static function get_pagination_input_variables_for_current_request( QueryInterface $query ): array {
$untrusted_variables = self::decode_query_var( get_query_var( self::$variable_name, '' ) );

if ( empty( $untrusted_variables ) || ! is_array( $untrusted_variables ) ) {
return [];
}

$query_id = $query->get_id();

// The query var value is an associative array with IDs as keys and
// values that are an associative array of input variables.
//
// We only expect a single key => value pair, but in the future we may
// decide to support more than one. This would allow us to control the
// pagination of multiple remote data blocks independently.
$untrusted_variables = $untrusted_variables[ $query_id ] ?? [];

if ( empty( $untrusted_variables ) || ! is_array( $untrusted_variables ) ) {
return [];
}

// Only accept pagination input variables that are defined in this query's
// input schema.
$input_schema = $query->get_input_schema();
$input_variables = [];
$pagination_input_variable_types = [
'ui:pagination_cursor',
'ui:pagination_cursor_next',
'ui:pagination_cursor_previous',
'ui:pagination_offset',
'ui:pagination_page',
'ui:pagination_per_page',
];

foreach ( $input_schema as $slug => $input ) {
if ( ! in_array( $input['type'] ?? null, $pagination_input_variable_types, true ) ) {
continue;
}

if ( ! array_key_exists( $slug, $untrusted_variables ) ) {
continue;
}

$value = $untrusted_variables[ $slug ];

if ( ! is_string( $value ) && ! is_int( $value ) ) {
continue;
}

$input_variables[ $slug ] = $value;
}

return $input_variables;
}

public static function format_pagination_data_for_query_response( array|null $pagination_data, array $query_input_schema, array $input_variables ): array {
// If the input schema is empty, it does not support pagination.
if ( empty( $query_input_schema ) ) {
return [];
}

// Find the appropriate pagination variables from the input schema.
$cursor_variable = null;
$cursor_next_variable = null;
$cursor_previous_variable = null;
$offset_variable = null;
$page_variable = null;
$per_page_variable = null;

foreach ( $query_input_schema as $slug => $input ) {
$type = $input['type'] ?? '';
if ( 'ui:pagination_cursor' === $type ) {
$cursor_variable = $slug;
} elseif ( 'ui:pagination_cursor_next' === $type ) {
$cursor_next_variable = $slug;
} elseif ( 'ui:pagination_cursor_previous' === $type ) {
$cursor_previous_variable = $slug;
} elseif ( 'ui:pagination_offset' === $type ) {
$offset_variable = $slug;
} elseif ( 'ui:pagination_page' === $type ) {
$page_variable = $slug;
} elseif ( 'ui:pagination_per_page' === $type ) {
$per_page_variable = $slug;
}
}

// Default pagination values
$current_per_page = $input_variables[ $per_page_variable ] ?? 10;
$pagination_input_variables = [];
$pagination_type = 'NONE';
$total_items = $pagination_data['total_items'] ?? null;

$pagination_input_variable_targets = [
'offset' => $offset_variable,
'page' => $page_variable,
'per_page' => $per_page_variable,
];

if ( $cursor_variable ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this logic be documented just above this if statement, or as a function comment?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1465512

$pagination_type = 'CURSOR_SIMPLE';

if ( isset( $pagination_data['cursor_next'] ) ) {
$pagination_input_variables['next_page'] = [
$cursor_variable => $pagination_data['cursor_next'],
];
}

if ( isset( $pagination_data['cursor_previous'] ) ) {
$pagination_input_variables['previous_page'] = [
$cursor_variable => $pagination_data['cursor_previous'],
];
}
} elseif ( $cursor_next_variable && $cursor_previous_variable ) {
$pagination_type = 'CURSOR';

if ( isset( $pagination_data['cursor_next'] ) ) {
$pagination_input_variables['next_page'] = [
$cursor_next_variable => $pagination_data['cursor_next'],
];
}

if ( isset( $pagination_data['cursor_previous'] ) ) {
$pagination_input_variables['previous_page'] = [
$cursor_previous_variable => $pagination_data['cursor_previous'],
];
}
} elseif ( $offset_variable ) {
$pagination_type = 'OFFSET';
$current_offset = $input_variables[ $offset_variable ] ?? 0;

if ( null === $total_items || $current_offset + $current_per_page < $total_items ) {
$pagination_input_variables['next_page'] = [
$offset_variable => $current_offset + $current_per_page,
];
}

if ( $current_offset >= $current_per_page ) {
$pagination_input_variables['previous_page'] = [
$offset_variable => $current_offset - $current_per_page,
];
}
} elseif ( $page_variable ) {
$pagination_type = 'PAGE';
$current_page = $input_variables[ $page_variable ] ?? 1;
$total_pages = $total_items ? ceil( $total_items / $current_per_page ) : null;

if ( null === $total_pages || $current_page < $total_pages ) {
$pagination_input_variables['next_page'] = [
$page_variable => $current_page + 1,
];
}

if ( $current_page > 1 ) {
$pagination_input_variables['previous_page'] = [
$page_variable => $current_page - 1,
];
}
}

if ( 'NONE' === $pagination_type ) {
return [];
}

return [
'input_variables' => $pagination_input_variables,
'input_variable_targets' => $pagination_input_variable_targets,
'per_page' => $current_per_page,
'total_items' => $total_items,
'type' => $pagination_type,
];
}

public static function register_query_var( array $query_vars ): array {
$query_vars[] = self::$variable_name;
return $query_vars;
}
}
Loading
Loading