Skip to content

Commit 891e504

Browse files
authored
Airtable pagination support (#425)
* Support pagination for Airtable queries * Allow generate in pagination variables * Add simple cursor pagination * Restore public visibility to query methods
1 parent 33fa9c5 commit 891e504

File tree

7 files changed

+470
-81
lines changed

7 files changed

+470
-81
lines changed

inc/Integrations/Airtable/AirtableIntegration.php

+60-36
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use RemoteDataBlocks\Config\Query\HttpQuery;
77
use RemoteDataBlocks\Formatting\StringFormatter;
88
use RemoteDataBlocks\Snippet\Snippet;
9-
use WP_Error;
109

1110
class AirtableIntegration {
1211
public static function init(): void {
@@ -34,20 +33,17 @@ public static function register_blocks_for_airtable_data_source(
3433
$tables = $data_source->to_array()['service_config']['tables'];
3534

3635
foreach ( $tables as $table ) {
37-
$query = self::get_query( $data_source, $table );
38-
$list_query = self::get_list_query( $data_source, $table );
39-
4036
register_remote_data_block(
4137
array_merge(
4238
[
4339
'title' => $data_source->get_display_name() . '/' . $table['name'],
4440
'icon' => 'editor-table',
4541
'render_query' => [
46-
'query' => $query,
42+
'query' => self::get_item_query( $data_source, $table ),
4743
],
4844
'selection_queries' => [
4945
[
50-
'query' => $list_query,
46+
'query' => self::get_list_query( $data_source, $table ),
5147
'type' => 'list',
5248
],
5349
],
@@ -82,21 +78,9 @@ public static function register_loop_blocks_for_airtable_data_source(
8278
}
8379
}
8480

85-
public static function get_query( AirtableDataSource $data_source, array $table ): HttpQuery|WP_Error {
86-
$input_schema = [
87-
'record_id' => [
88-
'name' => 'Record ID',
89-
'type' => 'id:list',
90-
],
91-
];
92-
93-
$output_schema = [
94-
'is_collection' => true,
95-
'path' => '$.records[*]',
96-
'type' => self::get_airtable_output_schema_mappings( $table ),
97-
];
98-
99-
return HttpQuery::from_array( [
81+
public static function get_item_query( AirtableDataSource $data_source, array $table ): array {
82+
return [
83+
'__class' => HttpQuery::class,
10084
'data_source' => $data_source,
10185
'endpoint' => function ( array $input_variables ) use ( $data_source, $table ): string {
10286
// Build the formula
@@ -108,9 +92,18 @@ public static function get_query( AirtableDataSource $data_source, array $table
10892

10993
return $data_source->get_endpoint() . '/' . $table['id'] . '?filterByFormula=' . urlencode( $formula );
11094
},
111-
'input_schema' => $input_schema,
112-
'output_schema' => $output_schema,
113-
] );
95+
'input_schema' => [
96+
'record_id' => [
97+
'name' => 'Record ID',
98+
'type' => 'id:list',
99+
],
100+
],
101+
'output_schema' => [
102+
'is_collection' => true,
103+
'path' => '$.records[*]',
104+
'type' => self::get_airtable_output_schema_mappings( $table ),
105+
],
106+
];
114107
}
115108

116109
private static function get_airtable_output_schema_mappings( array $table ): array {
@@ -134,19 +127,50 @@ private static function get_airtable_output_schema_mappings( array $table ): arr
134127
return $output_schema;
135128
}
136129

137-
public static function get_list_query( AirtableDataSource $data_source, array $table ): HttpQuery|WP_Error {
138-
$output_schema = [
139-
'is_collection' => true,
140-
'path' => '$.records[*]',
141-
'type' => self::get_airtable_output_schema_mappings( $table ),
142-
];
143-
144-
return HttpQuery::from_array( [
130+
public static function get_list_query( AirtableDataSource $data_source, array $table ): array {
131+
return [
132+
'__class' => HttpQuery::class,
145133
'data_source' => $data_source,
146-
'endpoint' => $data_source->get_endpoint() . '/' . $table['id'],
147-
'input_schema' => [],
148-
'output_schema' => $output_schema,
149-
] );
134+
'endpoint' => function ( array $input_variables ) use ( $data_source, $table ): string {
135+
$endpoint = $data_source->get_endpoint() . '/' . $table['id'];
136+
137+
if ( isset( $input_variables['cursor'] ) ) {
138+
// While named as "offset", this is implemented as a string cursor.
139+
$endpoint = add_query_arg( 'offset', $input_variables['cursor'], $endpoint );
140+
}
141+
142+
if ( isset( $input_variables['page_size'] ) ) {
143+
$endpoint = add_query_arg( 'pageSize', $input_variables['page_size'], $endpoint );
144+
}
145+
146+
return $endpoint;
147+
},
148+
'input_schema' => [
149+
'cursor' => [
150+
'name' => 'Pagination cursor',
151+
'required' => false,
152+
'type' => 'ui:pagination_cursor',
153+
],
154+
'page_size' => [
155+
'default_value' => 20,
156+
'name' => 'Page Size',
157+
'required' => false,
158+
'type' => 'ui:pagination_per_page',
159+
],
160+
],
161+
'output_schema' => [
162+
'is_collection' => true,
163+
'path' => '$.records[*]',
164+
'type' => self::get_airtable_output_schema_mappings( $table ),
165+
],
166+
'pagination_schema' => [
167+
'cursor_next' => [
168+
'name' => 'Next page cursor',
169+
'path' => '$.offset', // named "offset" but functions as cursor
170+
'type' => 'string',
171+
],
172+
],
173+
];
150174
}
151175

152176
private static function get_output_schema_mappings_snippet( array $table ): string {

inc/Validation/ConfigSchemas.php

+13-4
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ private static function generate_http_query_config_schema(): array {
241241
// and offset-based pagination variables.
242242
'ui:pagination_cursor_next',
243243
'ui:pagination_cursor_previous',
244+
//
245+
// Some APIs provide a single pagination cursor that is used for
246+
// both previous and next pages. If specified, this variable
247+
// takes precedence over next and previous cursor variables.
248+
'ui:pagination_cursor',
244249
),
245250
'required' => Types::nullable( Types::boolean() ),
246251
] ),
@@ -308,8 +313,9 @@ private static function generate_http_query_config_schema(): array {
308313
// `has_next_page` must be defined in order to enable pagination.
309314
'total_items' => Types::nullable(
310315
Types::object( [
316+
'generate' => Types::nullable( Types::callable() ),
311317
'name' => Types::nullable( Types::string() ),
312-
'path' => Types::json_path(),
318+
'path' => Types::nullable( Types::json_path() ),
313319
'type' => Types::enum( 'integer' ),
314320
] ),
315321
),
@@ -318,8 +324,9 @@ private static function generate_http_query_config_schema(): array {
318324
// field must be defined in order to enable cursor-based pagination.
319325
'cursor_next' => Types::nullable(
320326
Types::object( [
327+
'generate' => Types::nullable( Types::callable() ),
321328
'name' => Types::nullable( Types::string() ),
322-
'path' => Types::json_path(),
329+
'path' => Types::nullable( Types::json_path() ),
323330
'type' => Types::enum( 'string' ),
324331
] ),
325332
),
@@ -328,8 +335,9 @@ private static function generate_http_query_config_schema(): array {
328335
// field must be defined in order to enable cursor-based pagination.
329336
'cursor_previous' => Types::nullable(
330337
Types::object( [
338+
'generate' => Types::nullable( Types::callable() ),
331339
'name' => Types::nullable( Types::string() ),
332-
'path' => Types::json_path(),
340+
'path' => Types::nullable( Types::json_path() ),
333341
'type' => Types::enum( 'string' ),
334342
] ),
335343
),
@@ -338,8 +346,9 @@ private static function generate_http_query_config_schema(): array {
338346
// total number of items.
339347
'has_next_page' => Types::nullable(
340348
Types::object( [
349+
'generate' => Types::nullable( Types::callable() ),
341350
'name' => Types::nullable( Types::string() ),
342-
'path' => Types::json_path(),
351+
'path' => Types::nullable( Types::json_path() ),
343352
'type' => Types::enum( 'boolean' ),
344353
] )
345354
),

src/blocks/remote-data-container/config/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const REMOTE_DATA_REST_API_URL = getRestUrl();
1414

1515
export const CONTAINER_CLASS_NAME = getClassName( 'container' );
1616

17+
export const PAGINATION_CURSOR_VARIABLE_TYPE = 'ui:pagination_cursor';
1718
export const PAGINATION_CURSOR_NEXT_VARIABLE_TYPE = 'ui:pagination_cursor_next';
1819
export const PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE = 'ui:pagination_cursor_previous';
1920
export const PAGINATION_OFFSET_VARIABLE_TYPE = 'ui:pagination_offset';

src/blocks/remote-data-container/hooks/usePaginationVariables.ts

+62-38
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState } from '@wordpress/element';
33
import {
44
PAGINATION_CURSOR_NEXT_VARIABLE_TYPE,
55
PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE,
6+
PAGINATION_CURSOR_VARIABLE_TYPE,
67
PAGINATION_OFFSET_VARIABLE_TYPE,
78
PAGINATION_PAGE_VARIABLE_TYPE,
89
PAGINATION_PER_PAGE_VARIABLE_TYPE,
@@ -16,9 +17,6 @@ interface UsePaginationVariables {
1617
perPage?: number;
1718
setPage: ( page: number ) => void;
1819
setPerPage: ( perPage: number ) => void;
19-
supportsCursorPagination: boolean;
20-
supportsOffsetPagination: boolean;
21-
supportsPagePagination: boolean;
2220
supportsPagination: boolean;
2321
supportsPerPage: boolean;
2422
totalItems?: number;
@@ -31,6 +29,19 @@ interface UsePaginationVariablesInput {
3129
inputVariables: InputVariable[];
3230
}
3331

32+
interface PaginationCursors {
33+
next?: string;
34+
previous?: string;
35+
}
36+
37+
export enum PaginationType {
38+
CURSOR_SIMPLE = 'cursor_simple',
39+
CURSOR = 'cursor',
40+
OFFSET = 'offset',
41+
NONE = 'none',
42+
PAGE = 'page',
43+
}
44+
3445
export function usePaginationVariables( {
3546
initialPage = 1,
3647
initialPerPage,
@@ -39,7 +50,11 @@ export function usePaginationVariables( {
3950
const [ paginationData, setPaginationData ] = useState< RemoteDataPagination >();
4051
const [ page, setPage ] = useState< number >( initialPage );
4152
const [ perPage, setPerPage ] = useState< number | null >( initialPerPage ?? null );
53+
const [ cursors, setCursors ] = useState< PaginationCursors >( {} );
4254

55+
const cursorVariable = inputVariables?.find(
56+
input => input.type === PAGINATION_CURSOR_VARIABLE_TYPE
57+
);
4358
const cursorNextVariable = inputVariables?.find(
4459
input => input.type === PAGINATION_CURSOR_NEXT_VARIABLE_TYPE
4560
);
@@ -57,71 +72,83 @@ export function usePaginationVariables( {
5772
);
5873

5974
const paginationQueryInput: RemoteDataQueryInput = {};
75+
const nonFirstPage = page > 1;
76+
const calculatedPerPage = perPage ?? paginationData?.perPage ?? 10;
6077

6178
// These will be amended below.
62-
let supportsCursorPagination = false;
63-
let supportsOffsetPagination = false;
64-
let supportsPagePagination = false;
79+
let hasNextPage = Boolean( paginationData?.hasNextPage ?? paginationData?.cursorNext );
80+
let paginationType = PaginationType.NONE;
6581
let setPageFn: ( page: number ) => void = () => {};
6682

67-
if ( cursorNextVariable && cursorPreviousVariable ) {
83+
if ( cursorVariable ) {
84+
paginationType = PaginationType.CURSOR_SIMPLE;
85+
setPageFn = setPageForCursorPagination;
86+
Object.assign( paginationQueryInput, {
87+
[ cursorVariable.slug ]: cursors.next ?? cursors.previous,
88+
} );
89+
} else if ( cursorNextVariable && cursorPreviousVariable ) {
90+
paginationType = PaginationType.CURSOR;
6891
setPageFn = setPageForCursorPagination;
69-
supportsCursorPagination = true;
7092
Object.assign( paginationQueryInput, {
71-
[ cursorNextVariable.slug ]: paginationData?.cursorNext,
72-
[ cursorPreviousVariable.slug ]: paginationData?.cursorPrevious,
93+
[ cursorNextVariable.slug ]: cursors.next,
94+
[ cursorPreviousVariable.slug ]: cursors.previous,
7395
} );
74-
} else if ( offsetVariable && perPage ) {
96+
} else if ( offsetVariable ) {
97+
paginationType = PaginationType.OFFSET;
7598
setPageFn = setPage;
76-
supportsOffsetPagination = true;
77-
Object.assign( paginationQueryInput, { [ offsetVariable.slug ]: page * perPage } );
99+
if ( nonFirstPage ) {
100+
Object.assign( paginationQueryInput, {
101+
[ offsetVariable.slug ]: ( page - 1 ) * calculatedPerPage,
102+
} );
103+
}
78104
} else if ( pageVariable ) {
105+
paginationType = PaginationType.PAGE;
79106
setPageFn = setPage;
80-
supportsPagePagination = true;
81107
Object.assign( paginationQueryInput, { [ pageVariable.slug ]: page } );
82108
}
83109

84110
if ( perPageVariable && perPage ) {
85111
Object.assign( paginationQueryInput, { [ perPageVariable.slug ]: perPage } );
86112
}
87113

114+
const supportsPagination = paginationType !== PaginationType.NONE;
88115
const totalItems = paginationData?.totalItems;
89-
const totalPages = totalItems && perPage ? Math.ceil( totalItems / perPage ) : undefined;
90-
const hasNextPage = paginationData?.hasNextPage;
91-
const supportsPagination =
92-
supportsCursorPagination ||
93-
supportsPagePagination ||
94-
supportsOffsetPagination ||
95-
hasNextPage ||
96-
Boolean( totalItems );
116+
const totalPages = totalItems ? Math.ceil( totalItems / calculatedPerPage ) : undefined;
117+
118+
if ( totalPages && page < totalPages ) {
119+
hasNextPage = true;
120+
}
97121

98122
function onFetch( remoteData: RemoteData ): void {
99123
if ( ! supportsPagination ) {
100124
return;
101125
}
102126

103-
setPaginationData( remoteData.pagination );
104-
105-
// We need a perPage value to calculate the total pages, so inpsect the results.
106-
if ( ! perPage && remoteData.results.length ) {
107-
setPerPage( remoteData.results.length );
108-
}
127+
setPaginationData( {
128+
perPage: perPage ?? remoteData.results.length,
129+
...remoteData.pagination,
130+
} );
109131
}
110132

111133
// With cursor pagination, we can only go one page at a time.
112134
function setPageForCursorPagination( newPage: number ): void {
135+
// if page has gone up, we want to use nextCursor and set aside the current cursor as the previous one
136+
// if the page has gone down, we want to use previousCursor and set aside the current cursor as the next one
113137
if ( newPage > page ) {
114-
if ( totalPages ) {
115-
setPage( Math.min( totalPages, page + 1 ) );
116-
return;
117-
}
118-
119-
setPage( page + 1 );
138+
setPage( Math.min( totalPages ?? page + 1, page + 1 ) );
139+
setCursors( {
140+
next: paginationData?.cursorNext ?? cursors.next,
141+
previous: undefined,
142+
} );
120143
return;
121144
}
122145

123146
if ( newPage < page ) {
124147
setPage( Math.max( 1, page - 1 ) );
148+
setCursors( {
149+
next: undefined,
150+
previous: paginationData?.cursorPrevious ?? cursors.previous,
151+
} );
125152
}
126153
}
127154

@@ -130,12 +157,9 @@ export function usePaginationVariables( {
130157
onFetch,
131158
page,
132159
paginationQueryInput,
133-
perPage: perPage ?? undefined,
160+
perPage: perPage ?? paginationData?.perPage,
134161
setPage: setPageFn,
135162
setPerPage: supportsPagination ? setPerPage : () => {},
136-
supportsCursorPagination,
137-
supportsOffsetPagination,
138-
supportsPagePagination,
139163
supportsPagination,
140164
supportsPerPage: Boolean( perPageVariable ),
141165
totalItems,

src/blocks/remote-data-container/hooks/useRemoteData.ts

-3
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,6 @@ interface UseRemoteData {
5959
setPage: ( page: number ) => void;
6060
setPerPage: ( perPage: number ) => void;
6161
setSearchInput: ( searchInput: string ) => void;
62-
supportsCursorPagination: boolean;
63-
supportsOffsetPagination: boolean;
64-
supportsPagePagination: boolean;
6562
supportsPagination: boolean;
6663
supportsPerPage: boolean;
6764
supportsSearch: boolean;

0 commit comments

Comments
 (0)