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 ); } ); } );