diff --git a/example/rest-api/art-institute/art-institute.php b/example/rest-api/art-institute/art-institute.php index d854c407d..fcbb177ba 100644 --- a/example/rest-api/art-institute/art-institute.php +++ b/example/rest-api/art-institute/art-institute.php @@ -89,6 +89,51 @@ function register_aic_block(): void { ], ]); + $collection_query = HttpQuery::from_array([ + 'data_source' => $aic_data_source, + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $endpoint = $aic_data_source->get_endpoint(); + return add_query_arg( [ + 'limit' => $input_variables['limit'], + 'fields' => 'id,title,image_id,artist_title', + ], $endpoint ); + }, + 'input_schema' => [ + 'limit' => [ + 'name' => 'Limit', + 'type' => 'ui:input', + 'default_value' => 10, + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'artist_title' => [ + 'name' => 'Artist Title', + 'type' => 'string', + 'path' => '$.artist_title', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'title', + 'path' => '$.title', + ], + 'image_url' => [ + 'name' => 'Image URL', + 'generate' => function ( $data ): string { + return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; + }, + 'type' => 'image_url', + ], + ], + ], + ]); + $search_art_query = HttpQuery::from_array([ 'data_source' => $aic_data_source, 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { @@ -156,6 +201,16 @@ function register_aic_block(): void { ], ]); + register_remote_data_block( [ + 'title' => 'Art Institute of Chicago Loop', + 'icon' => 'art', + 'instructions' => 'This block displays a set amount of artworks based on the provided limit.', + 'render_query' => [ + 'query' => $collection_query, + 'loop' => true, + ], + ] ); + register_remote_data_block([ 'title' => 'Art Institute of Chicago', 'icon' => 'art', diff --git a/inc/Editor/BlockManagement/BlockRegistration.php b/inc/Editor/BlockManagement/BlockRegistration.php index 2da4b0515..4acaa3a4e 100644 --- a/inc/Editor/BlockManagement/BlockRegistration.php +++ b/inc/Editor/BlockManagement/BlockRegistration.php @@ -88,6 +88,7 @@ public static function register_block_configuration( array $config ): array { $block_config = [ 'availableBindings' => $available_bindings, 'availableOverrides' => $config['overrides'] ?? [], + 'instructions' => $config['instructions'], 'loop' => $config['loop'], 'name' => $block_name, 'dataSourceType' => ConfigStore::get_data_source_type( $block_name ), diff --git a/inc/Editor/BlockManagement/ConfigRegistry.php b/inc/Editor/BlockManagement/ConfigRegistry.php index c10f2ffea..c8141f737 100644 --- a/inc/Editor/BlockManagement/ConfigRegistry.php +++ b/inc/Editor/BlockManagement/ConfigRegistry.php @@ -51,14 +51,17 @@ public static function register_block( array $user_config = [] ): bool|WP_Error $display_query = self::inflate_query( $user_config[ self::RENDER_QUERY_KEY ]['query'] ); $input_schema = $display_query->get_input_schema(); + $is_loop = $user_config[ self::RENDER_QUERY_KEY ]['loop'] ?? false; + // Build the base configuration for the block. This is our own internal // configuration, not what will be passed to WordPress's register_block_type. // @see BlockRegistration::register_block_type::register_blocks. $config = [ 'description' => '', 'icon' => $user_config['icon'] ?? 'cloud', + 'instructions' => $user_config['instructions'] ?? null, 'name' => $block_name, - 'loop' => $user_config[ self::RENDER_QUERY_KEY ]['loop'] ?? false, + 'loop' => $is_loop, 'overrides' => $user_config['overrides'] ?? [], 'patterns' => [], 'queries' => [ @@ -75,9 +78,9 @@ public static function register_block( array $user_config = [] ): bool|WP_Error 'type' => $input_var['type'] ?? 'string', ]; }, array_keys( $input_schema ), array_values( $input_schema ) ), - 'name' => 'Manual input', + 'name' => $is_loop ? 'Collection' : 'Manual input', 'query_key' => self::DISPLAY_QUERY_KEY, - 'type' => 'input', + 'type' => $is_loop ? 'loop' : 'input', ], ], 'title' => $block_title, diff --git a/inc/Integrations/Shopify/ShopifyIntegration.php b/inc/Integrations/Shopify/ShopifyIntegration.php index 619524b15..eedd8abfc 100644 --- a/inc/Integrations/Shopify/ShopifyIntegration.php +++ b/inc/Integrations/Shopify/ShopifyIntegration.php @@ -34,6 +34,7 @@ public static function get_queries( ShopifyDataSource $data_source ): array { 'input_schema' => [ 'id' => [ 'type' => 'id', + 'name' => 'Product ID', ], ], 'output_schema' => [ diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php index 03c9c68b5..41bd756d5 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -76,6 +76,7 @@ public static function get_remote_data_block_attribute_config_schema(): array { private static function generate_remote_data_block_config_schema(): array { return Types::object( [ 'icon' => Types::nullable( Types::string() ), + 'instructions' => Types::nullable( Types::string() ), 'patterns' => Types::nullable( Types::list_of( Types::object( [ @@ -206,6 +207,8 @@ private static function generate_http_query_config_schema(): array { // implode an array of IDs into a comma-separated list and map it // to a query parameter). 'id:list', + // A string that represents an input field to refine the query results. + 'ui:input', // A string that represents search query input. An input variable // with this type must be present for the query to be considered a // search query. diff --git a/src/blocks/remote-data-container/components/panels/QueryInputsPanel.tsx b/src/blocks/remote-data-container/components/panels/QueryInputsPanel.tsx new file mode 100644 index 000000000..63bde3a9e --- /dev/null +++ b/src/blocks/remote-data-container/components/panels/QueryInputsPanel.tsx @@ -0,0 +1,66 @@ +import { Button, PanelBody, TextControl } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +interface QueryInputsPanelProps { + queryInputs: RemoteDataQueryInput[]; + onUpdateQueryInputs: ( inputs: RemoteDataQueryInput[] ) => void; +} + +export function QueryInputsPanel( { queryInputs, onUpdateQueryInputs }: QueryInputsPanelProps ) { + const [ localInputs, setLocalInputs ] = useState( queryInputs ); + + return ( + +
{ + event.preventDefault(); + const cleanedInputs = localInputs.map( input => { + const entries = Object.entries( input ).map( ( [ key, value ] ) => [ + key, + typeof value === 'string' && value.includes( ',' ) + ? value + .split( ',' ) + .map( item => item.trim() ) + .filter( Boolean ) + : value, + ] ); + + return Object.fromEntries( entries ) as RemoteDataQueryInput; + } ); + + onUpdateQueryInputs( cleanedInputs ); + } } + > + { localInputs.map( ( input, index ) => + Object.entries( input ).map( ( [ key, value ] ) => { + const displayValue = Array.isArray( value ) ? value.join( ',' ) : ( value as string ); + + return ( + { + setLocalInputs( + localInputs.map( ( item, itemIndex ) => + itemIndex === index ? { ...item, [ key ]: newValue } : item + ) + ); + } } + onBlur={ () => { + onUpdateQueryInputs( localInputs ); + } } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + ); + } ) + ) } + + +
+ ); +} diff --git a/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx b/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx index b5a7c33dc..a57fdb0c5 100644 --- a/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx +++ b/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx @@ -1,4 +1,4 @@ -import { ButtonGroup } from '@wordpress/components'; +import { ButtonGroup, Button } from '@wordpress/components'; import { InputModal } from '../modals/InputModal'; import { InputPopover } from '../popovers/InputPopover'; @@ -39,13 +39,32 @@ export function ItemSelectQueryType( props: ItemSelectQueryTypeProps ) { /> ); case 'input': - return selector.inputs.length === 1 && selector.inputs[ 0 ] ? ( - - ) : ( - - ); + case 'loop': + if ( selector.inputs.length === 1 && selector.inputs[ 0 ] ) { + return ( + + ); + } + return ; } + return ( + + ); + return null; } ) } diff --git a/src/blocks/remote-data-container/components/placeholders/Placeholder.tsx b/src/blocks/remote-data-container/components/placeholders/Placeholder.tsx index 5b43a5f3b..886738dff 100644 --- a/src/blocks/remote-data-container/components/placeholders/Placeholder.tsx +++ b/src/blocks/remote-data-container/components/placeholders/Placeholder.tsx @@ -1,17 +1,31 @@ -import { PlaceholderLoop } from '@/blocks/remote-data-container/components/placeholders/PlaceholderLoop'; -import { PlaceholderSingle } from '@/blocks/remote-data-container/components/placeholders/PlaceholderSingle'; +import { IconType, Placeholder as PlaceholderComponent } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { cloud } from '@wordpress/icons'; -export interface PlaceholderProps { +import { ItemSelectQueryType } from '@/blocks/remote-data-container/components/placeholders/ItemSelectQueryType'; + +interface PlaceholderProps { blockConfig: BlockConfig; - onSelect: ( input: RemoteDataQueryInput[] ) => void; + onSelect: ( data: RemoteDataQueryInput[] ) => void; } export function Placeholder( props: PlaceholderProps ) { - const { loop } = props.blockConfig; + const { blockConfig, onSelect } = props; + const { instructions, loop, settings } = blockConfig; + + const iconElement: IconType = ( settings.icon as IconType ) ?? cloud; - if ( loop ) { - return ; - } + const defaultInstructions = loop + ? __( 'This block displays a list of items.' ) + : __( 'This block requires selection of one or more items for display.' ); - return ; + return ( + + + + ); } diff --git a/src/blocks/remote-data-container/components/placeholders/PlaceholderLoop.tsx b/src/blocks/remote-data-container/components/placeholders/PlaceholderLoop.tsx deleted file mode 100644 index eec13fa52..000000000 --- a/src/blocks/remote-data-container/components/placeholders/PlaceholderLoop.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button, Placeholder } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { cloud } from '@wordpress/icons'; - -interface PlaceholderLoopProps { - blockConfig: BlockConfig; - onSelect: ( data: RemoteDataQueryInput[] ) => void; -} - -export function PlaceholderLoop( props: PlaceholderLoopProps ) { - const { - blockConfig: { - settings: { title }, - }, - onSelect, - } = props; - - return ( - - - - ); -} diff --git a/src/blocks/remote-data-container/components/placeholders/PlaceholderSingle.tsx b/src/blocks/remote-data-container/components/placeholders/PlaceholderSingle.tsx deleted file mode 100644 index ff8a59507..000000000 --- a/src/blocks/remote-data-container/components/placeholders/PlaceholderSingle.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { IconType, Placeholder } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { cloud } from '@wordpress/icons'; - -import { ItemSelectQueryType } from '@/blocks/remote-data-container/components/placeholders/ItemSelectQueryType'; - -interface PlaceholderSingleProps { - blockConfig: BlockConfig; - onSelect: ( data: RemoteDataQueryInput[] ) => void; -} - -export function PlaceholderSingle( props: PlaceholderSingleProps ) { - const { blockConfig, onSelect } = props; - - const iconElement: IconType = ( blockConfig.settings.icon as IconType ) ?? cloud; - - return ( - - - - ); -} diff --git a/src/blocks/remote-data-container/components/popovers/InputPopover.tsx b/src/blocks/remote-data-container/components/popovers/InputPopover.tsx index 815889ff0..2ae411c31 100644 --- a/src/blocks/remote-data-container/components/popovers/InputPopover.tsx +++ b/src/blocks/remote-data-container/components/popovers/InputPopover.tsx @@ -23,7 +23,7 @@ interface InputPopoverProps { } export function InputPopover( props: InputPopoverProps ) { - const { input, onSelect } = props; + const { input, onSelect, title } = props; const dataSourceType = getBlockDataSourceType( props.blockName ); @@ -62,7 +62,7 @@ export function InputPopover( props: InputPopoverProps ) { }; default: return { - buttonText: __( 'Provide manual input' ), + buttonText: __( 'Provide' ) + ' ' + title, }; } } diff --git a/src/blocks/remote-data-container/edit.tsx b/src/blocks/remote-data-container/edit.tsx index 2eb305d14..bbeb17c80 100644 --- a/src/blocks/remote-data-container/edit.tsx +++ b/src/blocks/remote-data-container/edit.tsx @@ -3,6 +3,7 @@ import { BlockEditProps } from '@wordpress/blocks'; import { Spinner } from '@wordpress/components'; import { useState } from '@wordpress/element'; +import { QueryInputsPanel } from './components/panels/QueryInputsPanel'; import { InnerBlocks } from '@/blocks/remote-data-container/components/InnerBlocks'; import { DataPanel } from '@/blocks/remote-data-container/components/panels/DataPanel'; import { OverridesPanel } from '@/blocks/remote-data-container/components/panels/OverridesPanel'; @@ -83,6 +84,16 @@ export function Edit( props: BlockEditProps< RemoteDataBlockAttributes > ) { } } + function onUpdateQueryInputs( inputs: RemoteDataQueryInput[] ): void { + if ( ! remoteDataAttribute ) return; + + updateRemoteData( { + ...remoteDataAttribute, + queryInputs: inputs, + } ); + refreshRemoteData(); + } + // No remote data has been selected yet, show a placeholder. if ( ! data ) { return ( @@ -120,6 +131,10 @@ export function Edit( props: BlockEditProps< RemoteDataBlockAttributes > ) { remoteData={ data } resetRemoteData={ resetRemoteData } /> +
diff --git a/tests/src/blocks/remote-data-container/components/panels/QueryInputsPanel.test.tsx b/tests/src/blocks/remote-data-container/components/panels/QueryInputsPanel.test.tsx new file mode 100644 index 000000000..1c53d2067 --- /dev/null +++ b/tests/src/blocks/remote-data-container/components/panels/QueryInputsPanel.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { QueryInputsPanel } from '@/blocks/remote-data-container/components/panels/QueryInputsPanel'; + +describe( 'QueryInputsPanel', () => { + it( 'should render multiple inputs for query inputs with an array of ids', async () => { + const user = userEvent.setup(); + const onUpdateQueryInputs = vi.fn(); + render( + + ); + + expect( screen.getAllByRole( 'textbox' ) ).toHaveLength( 3 ); + expect( screen.getByDisplayValue( 'test1' ) ).toBeVisible(); + expect( screen.getByDisplayValue( 'test2' ) ).toBeVisible(); + expect( screen.getByDisplayValue( 'test3' ) ).toBeVisible(); + + const firstInputField = screen.getAllByRole( 'textbox', { + name: 'id', + } )[ 0 ] as HTMLInputElement; + + // Clear the input field + await user.clear( firstInputField ); + expect( firstInputField ).toHaveValue( '' ); + // Type a new value + await user.type( firstInputField, 'test4' ); + await user.tab(); + + // Called with only the first input value changed + expect( onUpdateQueryInputs ).toHaveBeenCalledWith( [ + { + id: 'test4', + }, + { + id: 'test2', + }, + { + id: 'test3', + }, + ] ); + } ); + + it( 'should render a single input for comma separated values', async () => { + const user = userEvent.setup(); + const onUpdateQueryInputs = vi.fn(); + render( + + ); + + await user.click( screen.getByRole( 'textbox' ) ); + // Clear the input field + await user.clear( screen.getByRole( 'textbox' ) ); + // Type a new value + await user.type( screen.getByRole( 'textbox' ), 'test5' ); + await user.tab(); + + expect( onUpdateQueryInputs ).toHaveBeenCalledWith( [ + { + record_id: 'test5', + }, + ] ); + + // Add additional value + await user.type( screen.getByRole( 'textbox' ), ',test6' ); + await user.tab(); + + expect( onUpdateQueryInputs ).toHaveBeenCalledWith( [ + { + record_id: 'test5,test6', + }, + ] ); + } ); +} ); diff --git a/types/localized-block-data.d.ts b/types/localized-block-data.d.ts index 2856c562f..08ad38cb1 100644 --- a/types/localized-block-data.d.ts +++ b/types/localized-block-data.d.ts @@ -25,6 +25,7 @@ interface BlockConfig { availableBindings: AvailableBindings; availableOverrides: InputVariableOverride[]; dataSourceType: string; + instructions?: string; loop: boolean; name: string; patterns: {