diff --git a/classes/fields/pick.php b/classes/fields/pick.php
index c7a53b1327..96698e4562 100644
--- a/classes/fields/pick.php
+++ b/classes/fields/pick.php
@@ -2541,7 +2541,7 @@ public function get_object_data( $object_params = null ) {
$id = $object_params['id'];
$context = $object_params['context'];
$data_params = $object_params['data_params'];
- $page = min( 1, (int) $object_params['page'] );
+ $page = max( 1, (int) $object_params['page'] );
$limit = (int) $object_params['limit'];
$autocomplete = false;
@@ -3336,7 +3336,8 @@ public function admin_ajax_relationship() {
$items = apply_filters( 'pods_field_pick_data_ajax_items', $items, $field['name'], null, $field, $pod, $id );
$items = [
- 'results' => $items,
+ 'results' => $items,
+ 'has_more' => $limit > 0 && count( $items ) >= $limit,
];
wp_send_json( $items );
diff --git a/ui/js/dfv/src/fields/pick/full-select.js b/ui/js/dfv/src/fields/pick/full-select.js
index 180ce31835..27c7c4cee6 100644
--- a/ui/js/dfv/src/fields/pick/full-select.js
+++ b/ui/js/dfv/src/fields/pick/full-select.js
@@ -1,10 +1,11 @@
/**
* External dependencies
*/
-import React from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import Select, { components } from 'react-select';
import AsyncSelect from 'react-select/async';
import AsyncCreatableSelect from 'react-select/async-creatable';
+import CreatableSelect from 'react-select/creatable';
import {
DndContext,
closestCenter,
@@ -89,9 +90,88 @@ const FullSelect = ( {
isClearable,
isReadOnly,
} ) => {
- const useAsyncSelectComponent = isTaggable || ajaxData?.ajax;
+ const isAjax = Boolean( ajaxData?.ajax );
+ const useAsyncSelectComponent = isTaggable || isAjax;
const AsyncSelectComponent = isTaggable ? AsyncCreatableSelect : AsyncSelect;
+ // For AJAX-backed relationships we drive the option list ourselves so that
+ // scrolling can append additional pages. react-select's Async components hand
+ // loadOptions a single-use callback (see useAsync: it stores the pending
+ // request and ignores any callback whose request is no longer current), so an
+ // appended page can never be delivered through it.
+ const PaginatedSelectComponent = isTaggable ? CreatableSelect : Select;
+
+ const [ ajaxOptions, setAjaxOptions ] = useState( formattedOptions );
+ const [ ajaxIsLoading, setAjaxIsLoading ] = useState( false );
+ const [ ajaxHasMore, setAjaxHasMore ] = useState( true );
+ const ajaxQuery = useRef( '' );
+ const ajaxPage = useRef( 1 );
+ const ajaxRequestId = useRef( 0 );
+ const ajaxDebounce = useRef( null );
+
+ const fetchAjaxOptions = useCallback( ( inputValue, page ) => {
+ const requestId = ++ajaxRequestId.current;
+
+ setAjaxIsLoading( true );
+
+ loadAjaxOptions( ajaxData )( inputValue, page )
+ .then( ( results ) => {
+ if ( requestId !== ajaxRequestId.current ) {
+ return;
+ }
+
+ ajaxQuery.current = inputValue;
+ ajaxPage.current = page;
+
+ setAjaxOptions( ( previousOptions ) => (
+ 1 === page ? results : [ ...previousOptions, ...results ]
+ ) );
+ setAjaxHasMore( Boolean( results.hasMore ) );
+ } )
+ .catch( () => {
+ if ( requestId === ajaxRequestId.current ) {
+ setAjaxHasMore( false );
+ }
+ } )
+ .finally( () => {
+ if ( requestId === ajaxRequestId.current ) {
+ setAjaxIsLoading( false );
+ }
+ } );
+ }, [ ajaxData ] );
+
+ const handleAjaxInputChange = ( inputValue, { action } ) => {
+ if ( 'input-change' !== action ) {
+ return;
+ }
+
+ if ( ajaxDebounce.current ) {
+ clearTimeout( ajaxDebounce.current );
+ }
+
+ ajaxDebounce.current = setTimeout( () => fetchAjaxOptions( inputValue, 1 ), 300 );
+ };
+
+ const handleAjaxMenuOpen = () => {
+ if ( ! ajaxIsLoading && 0 === ajaxOptions.length ) {
+ fetchAjaxOptions( ajaxQuery.current, 1 );
+ }
+ };
+
+ const handleAjaxMenuScrollToBottom = () => {
+ if ( ajaxIsLoading || ! ajaxHasMore ) {
+ return;
+ }
+
+ fetchAjaxOptions( ajaxQuery.current, ajaxPage.current + 1 );
+ };
+
+ useEffect( () => () => {
+ if ( ajaxDebounce.current ) {
+ clearTimeout( ajaxDebounce.current );
+ }
+ }, [] );
+
const sensors = useSensors(
useSensor( PointerSensor, {
activationConstraint: {
@@ -165,11 +245,33 @@ const FullSelect = ( {
items={ Array.isArray( value ) ? value.map( ( item ) => item.value ) : [] }
strategy={ horizontalListSortingStrategy }
>
- { useAsyncSelectComponent ? (
+ { isAjax ? (
+
+ ) : useAsyncSelectComponent ? (
async ( inputValue = '' ) => {
+const loadAjaxOptions = ( ajaxData = {} ) => async ( inputValue = '', page = 1 ) => {
const data = {
_wpnonce: ajaxData?._wpnonce,
action: 'pods_relationship',
@@ -10,6 +10,7 @@ const loadAjaxOptions = ( ajaxData = {} ) => async ( inputValue = '' ) => {
uri_hash: ajaxData?.uri_hash ?? '',
id: ajaxData?.id ?? 0,
query: inputValue,
+ page,
};
const formData = new FormData();
@@ -35,11 +36,13 @@ const loadAjaxOptions = ( ajaxData = {} ) => async ( inputValue = '' ) => {
const formattedResults = resultBody.results.map( ( result ) => (
{
- label: result?.name,
+ label: result?.name ?? result?.text,
value: result?.id,
}
) );
+ formattedResults.hasMore = Boolean( resultBody.has_more );
+
return formattedResults;
} catch ( e ) {
throw e;
diff --git a/ui/js/dfv/src/helpers/test/loadAjaxOptions.test.js b/ui/js/dfv/src/helpers/test/loadAjaxOptions.test.js
index 61a9d46593..c8083dc459 100644
--- a/ui/js/dfv/src/helpers/test/loadAjaxOptions.test.js
+++ b/ui/js/dfv/src/helpers/test/loadAjaxOptions.test.js
@@ -3,172 +3,64 @@
*/
import loadAjaxOptions from '../loadAjaxOptions';
-// Mock the global fetch function
global.fetch = jest.fn();
-
-// Mock the global ajaxurl variable
global.ajaxurl = 'http://example.com/wp-admin/admin-ajax.php';
describe( 'loadAjaxOptions', () => {
- // Clear all mocks before each test
beforeEach( () => {
fetch.mockClear();
} );
- test( 'creates a function that fetches options with correct parameters', async () => {
- // Setup
- const ajaxData = {
+ test( 'fetches and formats options with requested page', async () => {
+ fetch.mockResolvedValueOnce( {
+ json: () => Promise.resolve( {
+ has_more: true,
+ results: [ { id: 1, name: 'Option 1' } ],
+ } ),
+ } );
+
+ const result = await loadAjaxOptions( {
_wpnonce: 'abc123',
pod_name: 'post',
field_name: 'category',
uri_hash: 'hash123',
id: 42,
- };
-
- const mockResponse = {
- results: [
- {
- id: 1,
- name: 'Option 1',
- },
- {
- id: 2,
- name: 'Option 2',
- },
- ],
- };
-
- // Mock the fetch response
- fetch.mockResolvedValueOnce( {
- json: () => Promise.resolve( mockResponse ),
- } );
-
- // Create the loader function
- const loader = loadAjaxOptions( ajaxData );
+ } )( 'search term', 3 );
- // Call the loader with a search term
- const result = await loader( 'search term' );
-
- // Verify it called fetch with the right parameters
- expect( fetch ).toHaveBeenCalledTimes( 1 );
expect( fetch ).toHaveBeenCalledWith(
'http://example.com/wp-admin/admin-ajax.php?pods_ajax=1',
- expect.objectContaining( {
- method: 'POST',
- body: expect.any( FormData ),
- } ),
+ expect.objectContaining( { method: 'POST', body: expect.any( FormData ) } ),
);
-
- // Check that formData was built correctly
- const formData = fetch.mock.calls[ 0 ][ 1 ].body;
-
- // Since FormData is not directly inspectable in Jest, we need to mock it
- // This test assumes the FormData was constructed correctly based on the inputs
-
- // Check the result format
- expect( result ).toEqual( [
- {
- label: 'Option 1',
- value: 1,
- },
- {
- label: 'Option 2',
- value: 2,
- },
- ] );
+ expect( fetch.mock.calls[ 0 ][ 1 ].body.get( 'page' ) ).toBe( '3' );
+ expect( [ ...result ] ).toEqual( [ { label: 'Option 1', value: 1 } ] );
+ expect( result.hasMore ).toBe( true );
} );
- test( 'provides default values for missing ajaxData parameters', async () => {
- // Setup with minimal ajaxData
- const ajaxData = {
- _wpnonce: 'abc123',
- };
-
- const mockResponse = {
- results: [
- {
- id: 1,
- name: 'Option 1',
- },
- ],
- };
-
- // Mock the fetch response
+ test( 'uses defaults for missing ajax data', async () => {
fetch.mockResolvedValueOnce( {
- json: () => Promise.resolve( mockResponse ),
+ json: () => Promise.resolve( { results: [] } ),
} );
- // Create the loader function
- const loader = loadAjaxOptions( ajaxData );
-
- // Call the loader
- await loader();
+ const result = await loadAjaxOptions( { _wpnonce: 'abc123' } )();
- // Get the FormData from the fetch call
- const formData = fetch.mock.calls[ 0 ][ 1 ].body;
-
- // We can't directly test FormData contents in Jest, but we can verify
- // that fetch was called with a FormData object as the body
- expect( fetch ).toHaveBeenCalledWith(
- expect.any( String ),
- expect.objectContaining( {
- body: expect.any( FormData ),
- } ),
- );
- } );
-
- test( 'handles empty ajaxData correctly', async () => {
- const mockResponse = {
- results: [],
- };
-
- // Mock the fetch response
- fetch.mockResolvedValueOnce( {
- json: () => Promise.resolve( mockResponse ),
- } );
-
- // Create the loader function with empty ajaxData
- const loader = loadAjaxOptions();
-
- // Call the loader
- const result = await loader();
-
- // Check the result
- expect( result ).toEqual( [] );
+ expect( [ ...result ] ).toEqual( [] );
+ expect( result.hasMore ).toBe( false );
} );
- test( 'throws an error when response is invalid', async () => {
- // Setup
- const ajaxData = {
- _wpnonce: 'abc123',
- };
-
- // Mock an invalid response (no results field)
+ test( 'throws when response is invalid', async () => {
fetch.mockResolvedValueOnce( {
json: () => Promise.resolve( { error: 'Invalid response' } ),
} );
- // Create the loader function
- const loader = loadAjaxOptions( ajaxData );
-
- // Call the loader and expect an error
- await expect( loader() ).rejects.toThrow( 'Invalid response.' );
+ await expect( loadAjaxOptions( { _wpnonce: 'abc123' } )() )
+ .rejects.toThrow( 'Invalid response.' );
} );
- test( 'throws the original error when fetch fails', async () => {
- // Setup
- const ajaxData = {
- _wpnonce: 'abc123',
- };
-
- // Mock a network error
+ test( 'throws original fetch error', async () => {
const networkError = new Error( 'Network failure' );
fetch.mockRejectedValueOnce( networkError );
- // Create the loader function
- const loader = loadAjaxOptions( ajaxData );
-
- // Call the loader and expect the original error to be thrown
- await expect( loader() ).rejects.toEqual( networkError );
+ await expect( loadAjaxOptions( { _wpnonce: 'abc123' } )() )
+ .rejects.toEqual( networkError );
} );
} );