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 (
+
+
+
+ );
+}
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: {