Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions classes/fields/pick.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 );
Expand Down
110 changes: 106 additions & 4 deletions ui/js/dfv/src/fields/pick/full-select.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -165,11 +245,33 @@ const FullSelect = ( {
items={ Array.isArray( value ) ? value.map( ( item ) => item.value ) : [] }
strategy={ horizontalListSortingStrategy }
>
{ useAsyncSelectComponent ? (
{ isAjax ? (
<PaginatedSelectComponent
controlShouldRenderValue={ shouldRenderValue }
options={ ajaxOptions }
isLoading={ ajaxIsLoading }
filterOption={ null }
onInputChange={ handleAjaxInputChange }
onMenuOpen={ handleAjaxMenuOpen }
onMenuScrollToBottom={ handleAjaxMenuScrollToBottom }
value={ value }
placeholder={ placeholder }
isMulti={ isMulti }
isClearable={ isClearable }
onChange={ addNewItem }
isDisabled={ isReadOnly }
components={ {
MultiValue: SortableMultiValue,
} }
menuPortalTarget={ document.body }
menuPosition="fixed"
styles={ selectStyles }
classNamePrefix="pods-dfv-pick-full-select"
/>
) : useAsyncSelectComponent ? (
<AsyncSelectComponent
controlShouldRenderValue={ shouldRenderValue }
defaultOptions={ formattedOptions }
loadOptions={ ajaxData?.ajax ? loadAjaxOptions( ajaxData ) : undefined }
value={ value }
placeholder={ placeholder }
isMulti={ isMulti }
Expand Down
7 changes: 5 additions & 2 deletions ui/js/dfv/src/helpers/loadAjaxOptions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* global ajaxurl */

const loadAjaxOptions = ( ajaxData = {} ) => async ( inputValue = '' ) => {
const loadAjaxOptions = ( ajaxData = {} ) => async ( inputValue = '', page = 1 ) => {
const data = {
_wpnonce: ajaxData?._wpnonce,
action: 'pods_relationship',
Expand All @@ -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();
Expand All @@ -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;
Expand Down
158 changes: 25 additions & 133 deletions ui/js/dfv/src/helpers/test/loadAjaxOptions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
} );
} );