From 243d6ea3f82b8d863d593479a9b22bf2fb434c82 Mon Sep 17 00:00:00 2001 From: Chris Zarate Date: Fri, 7 Feb 2025 19:17:05 -0500 Subject: [PATCH] Add `ui:search_input` input variable (#337) * Update useRemoteData to be more explicit about state management * Add ui:search input variable * Update useRemoteData to implement search * Update Art Institute example with ui:search variable * Update search documentation * Update useDebouncedState to avoid need for ref * Revert "Update useDebouncedState to avoid need for ref" This reverts commit 77656a05386dec8562a39d44c0fe8e0c39f406f3. * Fix selector in FieldShortcodeSelectNew * Implement query pagination (#338) * Implement pagination * Update documentation for pagination * Update Art Institute example * Update Query docs * Fix a few schema references * Move field filter to useful place * Move removeNullValues to util * Refactor ItemList to remove memos * Tighten up Co-authored-by: Max Schmeling --------- Co-authored-by: Max Schmeling --------- Co-authored-by: Max Schmeling --- docs/concepts/index.md | 8 +- docs/extending/block-registration.md | 12 +- docs/extending/query.md | 67 ++++- .../rest-api/art-institute/art-institute.php | 36 ++- inc/Config/Query/HttpQuery.php | 8 + inc/Config/Query/QueryInterface.php | 1 + .../QueryRunner/QueryResponseParser.php | 5 + inc/Config/QueryRunner/QueryRunner.php | 16 ++ inc/Editor/BlockManagement/ConfigRegistry.php | 19 +- .../SalesforceB2CIntegration.php | 6 +- .../Shopify/ShopifyIntegration.php | 4 +- inc/Validation/ConfigSchemas.php | 86 +++++- .../FieldShortcodeSelectNew.tsx | 40 ++- .../components/item-list/ItemList.tsx | 271 +++++++----------- .../components/item-list/ItemListField.tsx | 71 +++++ .../components/modals/DataViewsModal.tsx | 73 +++-- .../placeholders/ItemSelectQueryType.tsx | 1 + .../remote-data-container/config/constants.ts | 7 + .../hooks/usePaginationVariables.ts | 137 +++++++++ .../hooks/useRemoteData.ts | 82 +++++- .../hooks/useSearchResults.ts | 52 ---- .../hooks/useSearchVariables.ts | 41 +++ src/hooks/useDebouncedState.ts | 21 ++ src/utils/type-narrowing.ts | 8 + tests/inc/Functions/FunctionsTest.php | 4 +- tests/src/utils/type-narrowing.test.ts | 15 +- types/remote-data.d.ts | 12 + 27 files changed, 823 insertions(+), 280 deletions(-) create mode 100644 src/blocks/remote-data-container/components/item-list/ItemListField.tsx create mode 100644 src/blocks/remote-data-container/hooks/usePaginationVariables.ts delete mode 100644 src/blocks/remote-data-container/hooks/useSearchResults.ts create mode 100644 src/blocks/remote-data-container/hooks/useSearchVariables.ts create mode 100644 src/hooks/useDebouncedState.ts diff --git a/docs/concepts/index.md b/docs/concepts/index.md index e7066668..9f04e676 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -18,8 +18,8 @@ Below, you'll find specific use cases where Remote Data Blocks shines. We are wo - **Example:** Create a page and rewrite rule for /products/{product_id}/ and configure a Remote Data Block on that page to display the referenced product. - Your presentation of remote data aligns with the capabilities of Block Bindings. - **Example:** Display an item of clothing using a core paragraph, heading, image, and button blocks. -- You do not require complex filtering or pagination. - - **Example:** To select an item of clothing, load a finite list of top-selling products or search all products by a specific term. +- You do not require complex filtering. + - **Example:** To select an item of clothing, load a list of top-selling products or search all products by a specific term. - Your data is denormalized and relatively flat. - **Example:** A row from a Google Sheet with no references to external entities. @@ -35,8 +35,8 @@ Below, you'll find specific use cases where Remote Data Blocks shines. We are wo - You have multiple remote data sources that require interaction. Or, you want to implement a complex content architecture using Remote Data Blocks instead of leveraging WordPress custom post types and/or taxonomies. - These two challenges are directly related to the issues with normalized data. If you have data sources that relate to one another, you have to write custom code to query missing data and stitch them together. - Judging complexity is difficult, but implementing large applications using Remote Data Blocks is not advisable. -- You require complex filtering or rely heavily on pagination. - - Our UI components for filtering and pagination are still under development. +- You require complex filtering or have complex pagination needs. + - Our UI components for filtering are pagination still under development. Over time, Remote Data Blocks will grow and improve and these guidelines will change. diff --git a/docs/extending/block-registration.md b/docs/extending/block-registration.md index 8451909a..90f9ba75 100644 --- a/docs/extending/block-registration.md +++ b/docs/extending/block-registration.md @@ -95,21 +95,21 @@ Example: #### Search queries -Search queries must return a collection and must accept a string input variable of `search_terms`. The [Art Institute of Chicago](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/rest-api/art-institute/README.md) example looks like this: +Search queries must return a collection and must accept one input variable with the special type `ui:search_input`. The [Art Institute of Chicago](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/rest-api/art-institute/README.md) example looks like this: ```php $search_art_query = HttpQuery::from_array([ 'data_source' => $aic_data_source, 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $query = $input_variables['search_terms']; + $query = $input_variables['search']; $endpoint = $aic_data_source->get_endpoint() . '/search'; return add_query_arg( [ 'q' => $query ], $endpoint ); }, 'input_schema' => [ - 'search_terms' => [ - 'name' => 'Search Terms', - 'type' => 'string', + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', ], ], 'output_schema' => [ @@ -129,7 +129,7 @@ $search_art_query = HttpQuery::from_array([ ]); ``` -Here you can see the input variable of `search_terms` is used in the endpoint method to populate a query string. You can read more about [queries](./query.md) and how to construct them. End users enter the search term to find the specific item. +Here you can see the `search` input variable has a special type of `ui:search_input` and is used in the endpoint method to populate a query string. You can read more about [queries](./query.md) and how to construct them. End users enter the search term to find the specific item. ![Screenshot showing the search inputin the WordPress Editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/extending/search-input.png) diff --git a/docs/extending/query.md b/docs/extending/query.md index 7ff6649e..52002d15 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -84,7 +84,7 @@ The `endpoint` property defines the query endpoint. It can be a string or a call ### input_schema: array -The `input_schema` property defines the input variables expected by the query. The method should return an associative array of input variable definitions. The keys of the array are machine-friendly input variable names, and the values are associative arrays with the following structure: +The `input_schema` property defines the input variables expected by the query. The property should be an associative array of input variable definitions. The keys of the array are machine-friendly input variable names, and the values are associative arrays with the following structure: - `name` (optional): The human-friendly display name of the input variable - `default_value` (optional): The default value for the input variable. @@ -107,11 +107,40 @@ The `input_schema` property defines the input variables expected by the query. T ], ``` -If omitted, it defaults to an empty array. +There are also some special input variable types: + +- `ui:search_input`: A variable with this type indicates that the query supports searching. It must accept a `string` containing search terms. +- `ui:pagination_offset`: A variable with this type indicates that the query supports offset pagination. It must accept an `integer` containing the requested offset. See `pagination_schema` for additional information and requirements. +- `ui:pagination_page`: A variable with this type indicates that the query supports page-based pagination. It must accept an `integer` containing the requested results page. See `pagination_schema` for additional information and requirements. +- `ui:pagination_per_page`: A variable with this type indicates that the query supports controlling the number of resultsper page. It must accept an `integer` containing the number of requested results. +- `ui:pagination_cursor_next` and `ui_pagination_cursor_previous`: Variables with these types indicate that the query supports cursor pagination. They accept `strings` containing the requested cursor. See `pagination_schema` for additional information and requirements. + +#### Example with search and pagination input variables + +```php +'input_schema' => [ + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Pagination limit', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Pagination page', + 'type' => 'ui:pagination_page', + ], +], +``` + +If omitted, `input_schema` defaults to an empty array. ### output_schema: array (required) -The `output_schema` property defines how to extract data from the API response. The method should return an associative array with the following structure: +The `output_schema` property defines how to extract data from the API response. The property should be an associative array with the following structure: - `format` (optional): A callable function that formats the output variable value. - `generate` (optional): A callable function that generates or extracts the output variable value from the response, as an alternative to `path`. @@ -163,6 +192,38 @@ Accepted primitive types are: We have more in-depth [`output_schema`](./query-output_schema.md) examples. +### pagination_schema: array + +If your query supports pagination, the `pagination_schema` property defines how to extract pagination-related values from the query response. If defined, the property should be an associative array with the following structure: + +- `total_items` (required): A variable definition that extracts the total number of items across every page of results. +- `cursor_next`: If your query supports cursor pagination, a variable definition that extracts the cursor for the next page of results. +- `cursor_previous`: If your query supports cursor pagination, a variable definition that extracts the cursor for the previous page of results. + +Note that the `total_items` variable is required for all types of pagination. + +#### Example + +```php +'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.pagination.totalItems', + 'type' => 'integer', + ], + 'cursor_next' => [ + 'name' => 'Next page cursor', + 'path' => '$.pagination.nextCursor', + 'type' => 'string', + ], + 'cursor_previous' => [ + 'name' => 'Previous page cursor', + 'path' => '$.pagination.previousCursor', + 'type' => 'string', + ], +], +``` + ### request_method: string The `request_method` property defines the HTTP request method used by the query. By default, it is `'GET'`. diff --git a/example/rest-api/art-institute/art-institute.php b/example/rest-api/art-institute/art-institute.php index 28cc0af2..f0e9c418 100644 --- a/example/rest-api/art-institute/art-institute.php +++ b/example/rest-api/art-institute/art-institute.php @@ -69,15 +69,32 @@ function register_aic_block(): void { $search_art_query = HttpQuery::from_array([ 'data_source' => $aic_data_source, 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $query = $input_variables['search_terms']; - $endpoint = $aic_data_source->get_endpoint() . '/search'; + $endpoint = $aic_data_source->get_endpoint(); + $search_terms = $input_variables['search'] ?? ''; - return add_query_arg( [ 'q' => $query ], $endpoint ); + if ( ! empty( $search_terms ) ) { + $endpoint = add_query_arg( [ 'q' => $search_terms ], $endpoint . '/search' ); + } + + return add_query_arg( [ + 'limit' => $input_variables['limit'], + 'page' => $input_variables['page'], + ], $endpoint ); }, 'input_schema' => [ - 'search_terms' => [ - 'name' => 'Search Terms', - 'type' => 'string', + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Pagination limit', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Pagination page', + 'type' => 'ui:pagination_page', ], ], 'output_schema' => [ @@ -94,6 +111,13 @@ function register_aic_block(): void { ], ], ], + 'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.pagination.total', + 'type' => 'integer', + ], + ], ]); register_remote_data_block([ diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index f6d021b1..b4e77ae5 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -86,6 +86,14 @@ public function get_output_schema(): array { return $this->config['output_schema']; } + /** + * Get the pagination schema for this query. If null, pagination will be + * disabled. + */ + public function get_pagination_schema(): ?array { + return $this->config['pagination_schema']; + } + /** * Get the request body for the current query execution. Any non-null result * will be converted to JSON using `wp_json_encode`. diff --git a/inc/Config/Query/QueryInterface.php b/inc/Config/Query/QueryInterface.php index 14ea646e..f09c2032 100644 --- a/inc/Config/Query/QueryInterface.php +++ b/inc/Config/Query/QueryInterface.php @@ -16,4 +16,5 @@ public function get_data_source(): DataSourceInterface; public function get_image_url(): ?string; public function get_input_schema(): array; public function get_output_schema(): array; + public function get_pagination_schema(): ?array; } diff --git a/inc/Config/QueryRunner/QueryResponseParser.php b/inc/Config/QueryRunner/QueryResponseParser.php index e79795d0..622bdfe4 100644 --- a/inc/Config/QueryRunner/QueryResponseParser.php +++ b/inc/Config/QueryRunner/QueryResponseParser.php @@ -83,6 +83,11 @@ private function parse_response_objects( mixed $objects, array $type ): array { // Loop over the defined fields in the schema type and extract the values from the object. foreach ( $type as $field_name => $mapping ) { + // Skip null values. + if ( null === $mapping ) { + continue; + } + // A generate function accepts the current object and returns the field value. if ( isset( $mapping['generate'] ) && is_callable( $mapping['generate'] ) ) { $field_value = call_user_func( $mapping['generate'], json_decode( $json_obj->getJson(), true ) ); diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 8df508d4..436ef480 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -226,9 +226,25 @@ public function execute( HttpQueryInterface $query, array $input_variables ): ar $results = $is_collection ? $results : [ $results ]; $metadata = $this->get_response_metadata( $query, $raw_response_data['metadata'], $results ); + // Pagination schema defines how to extract pagination data from the response. + $pagination = null; + $pagination_schema = $query->get_pagination_schema(); + + if ( is_array( $pagination_schema ) ) { + $pagination_data = $parser->parse( $response_data, [ 'type' => $pagination_schema ] )['result'] ?? null; + + if ( is_array( $pagination_data ) ) { + $pagination = []; + foreach ( $pagination_data as $key => $value ) { + $pagination[ $key ] = $value['value']; + } + } + } + return [ 'is_collection' => $is_collection, 'metadata' => $metadata, + 'pagination' => $pagination, 'results' => $results, ]; } diff --git a/inc/Editor/BlockManagement/ConfigRegistry.php b/inc/Editor/BlockManagement/ConfigRegistry.php index a0cea711..288329cf 100644 --- a/inc/Editor/BlockManagement/ConfigRegistry.php +++ b/inc/Editor/BlockManagement/ConfigRegistry.php @@ -98,8 +98,14 @@ public static function register_block( array $user_config = [] ): bool|WP_Error } } - if ( self::SEARCH_QUERY_KEY === $from_query_type && ! isset( $from_input_schema['search_terms'] ) ) { - return self::create_error( $block_title, 'A search query must have a "search_terms" input variable' ); + if ( self::SEARCH_QUERY_KEY === $from_query_type ) { + $search_input_count = count( array_filter( $from_input_schema, function ( array $input_var ): bool { + return 'ui:search_input' === $input_var['type']; + } ) ); + + if ( 1 !== $search_input_count ) { + return self::create_error( $block_title, 'A search query must have one input variable with type "ui:search_input"' ); + } } // Add the selector to the configuration. @@ -107,7 +113,14 @@ public static function register_block( array $user_config = [] ): bool|WP_Error $config['selectors'], [ 'image_url' => $from_query->get_image_url(), - 'inputs' => [], + 'inputs' => array_map( function ( $slug, $input_var ) { + return [ + 'name' => $input_var['name'] ?? $slug, + 'required' => $input_var['required'] ?? false, + 'slug' => $slug, + 'type' => $input_var['type'] ?? 'string', + ]; + }, array_keys( $from_input_schema ), array_values( $from_input_schema ) ), 'name' => $selection_query['display_name'] ?? ucfirst( $from_query_type ), 'query_key' => $from_query::class, 'type' => $from_query_type, diff --git a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php b/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php index c386fe02..dd4eafba 100644 --- a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php +++ b/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php @@ -108,12 +108,12 @@ private static function get_queries( SalesforceB2CDataSource $data_source ): arr '%s/search/shopper-search/v1/organizations/%s/product-search?siteId=RefArchGlobal&q=%s', $base_endpoint, $service_config['organization_id'], - urlencode( $input_variables['search_terms'] ) + urlencode( $input_variables['search'] ) ); }, 'input_schema' => [ - 'search_terms' => [ - 'type' => 'string', + 'search' => [ + 'type' => 'ui:search_input', ], ], 'output_schema' => [ diff --git a/inc/Integrations/Shopify/ShopifyIntegration.php b/inc/Integrations/Shopify/ShopifyIntegration.php index dbb17735..75f63edf 100644 --- a/inc/Integrations/Shopify/ShopifyIntegration.php +++ b/inc/Integrations/Shopify/ShopifyIntegration.php @@ -84,8 +84,8 @@ public static function get_queries( ShopifyDataSource $data_source ): array { 'shopify_search_products' => GraphqlQuery::from_array( [ 'data_source' => $data_source, 'input_schema' => [ - 'search_terms' => [ - 'type' => 'string', + 'search' => [ + 'type' => 'ui:search_input', ], ], 'output_schema' => [ diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php index f92f0b59..ff3f543f 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -169,7 +169,54 @@ private static function generate_http_query_config_schema(): array { // NOTE: These values are string references to the "core primitive // types" from our formal schema. Referencing these types allows us // to use the same validation and sanitization logic. - 'type' => Types::enum( 'boolean', 'id', 'integer', 'null', 'number', 'string' ), + // + // There are also special types that are not core primitives, with + // accompanying notes. + 'type' => Types::enum( + 'boolean', + 'id', + 'integer', + 'null', + 'number', + 'string', + // Special non-primitive types + // + // 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. + 'ui:search_input', + // + // An integer that represents the requested offset for paginated + // results. Providing this input variable enables offset-based + // pagination. + // + // Note that a `total_items` pagination variable is also required + // for pagination to be enabled. + 'ui:pagination_offset', + // + // An integer that represents the requested page of paginated + // results. Providing this input variable enables page-based + // pagination. + // + // Note that a `total_items` pagination variable is also required + // for pagination to be enabled. + 'ui:pagination_page', + // + // An integer that represents the number of items to request in + // paginated results. This variable can be used in any pagination + // scheme. Often, this variable is named `limit` or `count`. + 'ui:pagination_per_page', + // + // A string that represents the pagination cursor used to request + // the next or previous page. Both must be specified to opt-in to + // cursor-based pagination, with corresponding fields defined in + // the `pagination_schema`. + // + // If specified, these variables take precedence over page-based + // and offset-based pagination variables. + 'ui:pagination_cursor_next', + 'ui:pagination_cursor_previous', + ), ] ), ) ), @@ -223,6 +270,43 @@ private static function generate_http_query_config_schema(): array { ), ] ), ), + // NOTE: The "pagination schema" for a query is not a formal schema like + // the ones generated by this class. It is a simple object structure that + // defines how the query response can inform subsequent requests for + // paginated data. + 'pagination_schema' => Types::nullable( + Types::object( [ + // This field provides an integer representing the total number of + // items available in paginated results. This field must be defined + // in order present to enable pagination support of any type, + // including cursor-based pagination. + 'total_items' => Types::object( [ + 'name' => Types::nullable( Types::string() ), + 'path' => Types::json_path(), + 'type' => Types::enum( 'integer' ), + ] ), + // This field provides a pagination cursor for the next page of + // paginated results, or a null value if there is no next page. This + // field must be defined in order to enable cursor-based pagination. + 'cursor_next' => Types::nullable( + Types::object( [ + 'name' => Types::nullable( Types::string() ), + 'path' => Types::json_path(), + 'type' => Types::enum( 'string' ), + ] ), + ), + // This field provides a pagination cursor for the previous page of + // paginated results, or a null value if there is no previous page. This + // field must be defined in order to enable cursor-based pagination. + 'cursor_previous' => Types::nullable( + Types::object( [ + 'name' => Types::nullable( Types::string() ), + 'path' => Types::json_path(), + 'type' => Types::enum( 'string' ), + ] ), + ), + ] ) + ), 'preprocess_response' => Types::nullable( Types::callable() ), 'query_runner' => Types::nullable( Types::instance_of( QueryRunnerInterface::class ) ), 'request_body' => Types::nullable( diff --git a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx index bcd72592..4a6d68dc 100644 --- a/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx +++ b/src/blocks/remote-data-container/components/field-shortcode/FieldShortcodeSelectNew.tsx @@ -41,19 +41,33 @@ export function FieldShortcodeSelectNew( props: FieldShortcodeSelectNewProps ) { { () => Object.entries( blocksByType ).map( ( [ dataSourceType, configs ] ) => ( - { configs.map( blockConfig => ( - ( - - { blockConfig.settings?.title ?? blockConfig.name } - - ) } - /> - ) ) } + { configs.map( blockConfig => { + // For now, we will use the first compatible selector, but this + // should be improved. + const compatibleSelector = blockConfig.selectors.find( selector => + [ 'list', 'search' ].includes( selector.type ) + ); + + if ( ! compatibleSelector ) { + return null; + } + + return ( + ( + + { blockConfig.settings?.title ?? blockConfig.name } + + ) } + /> + ); + } ) } ) ) } diff --git a/src/blocks/remote-data-container/components/item-list/ItemList.tsx b/src/blocks/remote-data-container/components/item-list/ItemList.tsx index 53912e00..6ba90c70 100644 --- a/src/blocks/remote-data-container/components/item-list/ItemList.tsx +++ b/src/blocks/remote-data-container/components/item-list/ItemList.tsx @@ -1,10 +1,28 @@ -import { Button } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; -import { DataViews, filterSortAndPaginate, View } from '@wordpress/dataviews/wp'; -import { useEffect, useMemo, useState } from '@wordpress/element'; +import { Action, DataViews, View } from '@wordpress/dataviews/wp'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { ItemListField } from '@/blocks/remote-data-container/components/item-list/ItemListField'; import { usePatterns } from '@/blocks/remote-data-container/hooks/usePatterns'; +import { removeNullValuesFromObject } from '@/utils/type-narrowing'; + +function getResultsWithId( results: RemoteDataResult[], instanceId: string ): RemoteDataResult[] { + return ( results ?? [] ).map( ( result: RemoteDataResult ) => { + const parsedItem = removeNullValuesFromObject( result ); + + if ( parsedItem.id ) { + return parsedItem; + } + + // ensure each result has an 'id' key + const idKey = Object.keys( parsedItem ).find( key => /(^|_)(id)$/i.test( key ) ); + return { + ...parsedItem, + id: idKey ? parsedItem[ idKey ] : instanceId, + }; + } ); +} interface ItemListProps { availableBindings: Record< string, RemoteDataBinding >; @@ -12,36 +30,17 @@ interface ItemListProps { loading: boolean; onSelect: ( data: RemoteDataQueryInput ) => void; onSelectField?: ( data: FieldSelection, fieldValue: string ) => void; + page: number; + perPage?: number; remoteData?: RemoteData; - searchTerms: string; - setSearchTerms: ( newValue: string ) => void; + searchInput: string; + setPage: ( newPage: number ) => void; + setSearchInput: ( newValue: string ) => void; + supportsSearch: boolean; + totalItems?: number; + totalPages?: number; } -const createFieldSelection = ( - field: string, - item: RemoteDataResult, - blockName: string, - remoteData: RemoteData -): FieldSelection => ( { - action: 'add_field_shortcode', - remoteData: { - ...remoteData, - blockName, - queryInput: { - ...item, - field: { - field, - value: item[ field ] as string, - }, - }, - resultId: item.id?.toString() ?? '', - results: [ item ], - }, - selectedField: field, - selectionPath: 'select_new_tab', - type: 'field', -} ); - export function ItemList( props: ItemListProps ) { const { availableBindings, @@ -49,118 +48,83 @@ export function ItemList( props: ItemListProps ) { loading, onSelect, onSelectField, + page, + perPage, remoteData, - searchTerms, - setSearchTerms, + searchInput, + setPage, + setSearchInput, + supportsSearch, + totalItems, + totalPages, } = props; - const results = remoteData?.results ?? []; const { defaultPattern: pattern } = usePatterns( blockName ); - const instanceId = useInstanceId( ItemList, blockName ); - const data = useMemo( () => { - // remove null values from the data to prevent errors in filterSortAndPaginate - const removeNullValues = ( obj: Record< string, unknown > ): Record< string, unknown > => { - return Object.fromEntries( - Object.entries( obj ).filter( ( [ _, value ] ) => value !== null ) - ); - }; - - return ( results ?? [] ).map( ( item: Record< string, unknown > ) => { - const parsedItem = removeNullValues( item ); - - if ( parsedItem.id ) { - return parsedItem; - } - - // ensure each result has an 'id' key - const idKey = Object.keys( parsedItem ).find( key => /(^|_)(id)$/i.test( key ) ); - return { - ...parsedItem, - id: idKey ? parsedItem[ idKey ] : instanceId, - }; - } ) as RemoteDataResult[]; - }, [ results ] ); + const results = remoteData?.results ?? []; + const data = loading ? [] : getResultsWithId( results ?? [], instanceId ); // get fields from results data to use as columns - const { fields, mediaField, tableFields, titleField } = useMemo( () => { - const getFields: string[] = Array.from( - new Set( - data - ?.flatMap( item => Object.keys( item ) ) - .filter( - key => key in availableBindings && availableBindings[ key ]?.type !== 'id' // filter out ID fields to hide from table - ) - ) - ); - - // Find title field from availableBindings by checking type - const title = Object.entries( availableBindings ).find( - ( [ _, binding ] ) => binding.type === 'string' && binding.name.toLowerCase() === 'title' - )?.[ 0 ]; - - // Find media field from availableBindings by checking type - const media = Object.entries( availableBindings ).find( - ( [ _, binding ] ) => binding.type === 'image_url' - )?.[ 0 ]; - - const renderField = ( field: string, item: RemoteDataResult ) => { - if ( field === media ) { - return {; - } - - if ( onSelectField && remoteData ) { - const queryInput: RemoteDataQueryInput = { - ...item, - field: { - field, - value: item[ field ] as string, - }, - }; - - return ( - - ); - } - - return item[ field ] as string; - }; - - const fieldObject = getFields.map( field => ( { - id: field, - label: availableBindings[ field ]?.name ?? field, - enableGlobalSearch: true, - getValue: ( { item }: { item: RemoteDataResult } ) => item[ field ] as string, - render: ( { item }: { item: RemoteDataResult } ) => renderField( field, item ), - enableSorting: field !== media, - } ) ); + const fieldNames: string[] = Array.from( + new Set( + data + ?.flatMap( item => Object.keys( item ) ) + .filter( + key => key in availableBindings && availableBindings[ key ]?.type !== 'id' // filter out ID fields to hide from table + ) + ) + ); - return { fields: fieldObject, tableFields: getFields, titleField: title, mediaField: media }; - }, [ availableBindings, data, onSelectField, remoteData ] ); + // Find title field from availableBindings by checking type + const titleField = Object.entries( availableBindings ).find( + ( [ _, binding ] ) => binding.type === 'string' && binding.name.toLowerCase() === 'title' + )?.[ 0 ]; + + // Find media field from availableBindings by checking type + const mediaField = Object.entries( availableBindings ).find( + ( [ _, binding ] ) => binding.type === 'image_url' + )?.[ 0 ]; + + const fields = fieldNames.map( field => ( { + id: field, + label: availableBindings[ field ]?.name ?? field, + enableGlobalSearch: true, + getValue: ( { item }: { item: RemoteDataResult } ) => item[ field ]?.toString() ?? '', + render: ( { item }: { item: RemoteDataResult } ) => ( + + ), + enableSorting: field !== mediaField, + } ) ); + + // hide media and title fields from table view if defined to avoid duplication + const tableFields = fieldNames.filter( field => field !== mediaField && field !== titleField ); const [ view, setView ] = useState< View >( { type: 'table' as const, - perPage: 8, - page: 1, - search: '', - fields: [], + perPage: perPage ?? data.length, + page, + search: searchInput, + fields: tableFields, filters: [], layout: {}, titleField, mediaField, } ); + function onChangeView( newView: View ) { + setPage( newView.page ?? 1 ); + setSearchInput( newView.search ?? '' ); + setView( newView ); + } + const defaultLayouts = mediaField ? { table: {}, @@ -168,55 +132,34 @@ export function ItemList( props: ItemListProps ) { } : { table: {} }; - // this prevents just an empty table rendering - useEffect( () => { - if ( tableFields.length > 0 ) { - setView( prevView => ( { - ...prevView, - // hide media and title fields from table view if defined to avoid duplication - fields: tableFields.filter( field => field !== mediaField && field !== titleField ), - } ) ); - } - }, [ mediaField, tableFields, titleField ] ); - - useEffect( () => { - if ( view.search !== searchTerms ) { - setSearchTerms( view.search ?? '' ); - } - }, [ view, searchTerms ] ); - - // filter, sort and paginate data - const { data: filteredData, paginationInfo } = useMemo( () => { - return filterSortAndPaginate( data ?? [], view, fields ); - }, [ data, view ] ); - // Hide actions for field shortcode selection - const actions = ! onSelectField - ? [ - { - id: 'choose', - icon: <>{ __( 'Choose' ) }, - isPrimary: true, - label: '', - callback: ( items: RemoteDataResult[] ) => { - items.map( item => onSelect( item ) ); - }, - }, - ] - : []; + const chooseItemAction = { + id: 'choose', + icon: <>{ __( 'Choose' ) }, + isPrimary: true, + label: '', + callback: ( items: RemoteDataResult[] ) => { + items.map( item => onSelect( item ) ); + }, + }; + const actions: Action< RemoteDataResult >[] = onSelectField ? [] : [ chooseItemAction ]; return ( - actions={ actions } - data={ filteredData } + data={ data } defaultLayouts={ defaultLayouts } fields={ fields } getItemId={ ( item: { id?: string } ) => item.id || '' } isLoading={ loading || ! pattern || ! results } isItemClickable={ () => true } onClickItem={ item => onSelect( item ) } - onChangeView={ setView } - paginationInfo={ paginationInfo } + onChangeView={ onChangeView } + paginationInfo={ { + totalItems: totalItems ?? data.length, + totalPages: totalPages ?? 1, + } } + search={ supportsSearch } view={ view } /> ); diff --git a/src/blocks/remote-data-container/components/item-list/ItemListField.tsx b/src/blocks/remote-data-container/components/item-list/ItemListField.tsx new file mode 100644 index 00000000..f63b8642 --- /dev/null +++ b/src/blocks/remote-data-container/components/item-list/ItemListField.tsx @@ -0,0 +1,71 @@ +import { Button } from '@wordpress/components'; + +function createFieldSelection( + field: string, + item: RemoteDataResult, + blockName: string, + remoteData: RemoteData +): FieldSelection { + return { + action: 'add_field_shortcode', + remoteData: { + ...remoteData, + blockName, + queryInput: { + ...item, + field: { + field, + value: item[ field ], + }, + }, + resultId: item.id?.toString() ?? '', + results: [ item ], + }, + selectedField: field, + selectionPath: 'select_new_tab', + type: 'field', + }; +} + +interface ItemListFieldProps { + blockName: string; + field: string; + item: RemoteDataResult; + mediaField?: string; + onSelect: ( data: RemoteDataQueryInput ) => void; + onSelectField?: ( data: FieldSelection, fieldValue: string ) => void; + remoteData?: RemoteData; +} + +export function ItemListField( props: ItemListFieldProps ) { + const { blockName, field, item, mediaField, onSelect, onSelectField, remoteData } = props; + const value = item[ field ]?.toString() ?? ''; + + if ( field === mediaField ) { + return {; + } + + if ( onSelectField && remoteData ) { + const queryInput: RemoteDataQueryInput = { + ...item, + field: { + field, + value: item[ field ] as string, + }, + }; + + return ( + + ); + } + + return value; +} diff --git a/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx b/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx index 0696acc7..0e4b1cea 100644 --- a/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx +++ b/src/blocks/remote-data-container/components/modals/DataViewsModal.tsx @@ -1,15 +1,22 @@ import { Button, Modal } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { useModalState } from '../../hooks/useModalState'; -import { ItemList } from '../item-list/ItemList'; -import { useSearchResults } from '@/blocks/remote-data-container/hooks/useSearchResults'; -import { getBlockAvailableBindings, getBlockConfig } from '@/utils/localized-block-data'; +import { ItemList } from '@/blocks/remote-data-container/components/item-list/ItemList'; +import { useModalState } from '@/blocks/remote-data-container/hooks/useModalState'; +import { useRemoteData } from '@/blocks/remote-data-container/hooks/useRemoteData'; +import { sendTracksEvent } from '@/blocks/remote-data-container/utils/tracks'; +import { + getBlockAvailableBindings, + getBlockConfig, + getBlockDataSourceType, +} from '@/utils/localized-block-data'; interface DataViewsModalProps { className?: string; blockName: string; headerImage?: string; + inputVariables: InputVariable[]; onSelect?: ( data: RemoteDataQueryInput ) => void; onSelectField?: ( data: FieldSelection, fieldValue: string ) => void; queryKey: string; @@ -18,27 +25,51 @@ interface DataViewsModalProps { } export const DataViewsModal: React.FC< DataViewsModalProps > = props => { - const { className, blockName, onSelect, onSelectField, queryKey, renderTrigger, title } = props; + const { + className, + blockName, + inputVariables, + onSelect, + onSelectField, + queryKey, + renderTrigger, + title, + } = props; const blockConfig = getBlockConfig( blockName ); - const availableBindings = getBlockAvailableBindings( blockName ); + const { close, isOpen, open } = useModalState(); const { + data, + fetch, loading, - data: remoteData, - searchTerms, - setSearchTerms, - } = useSearchResults( { + page, + searchInput, + setPage, + setSearchInput, + supportsSearch, + totalItems, + totalPages, + } = useRemoteData( { blockName, + inputVariables, queryKey, } ); - const { close, isOpen, open } = useModalState(); + useEffect( () => { + void fetch( {} ); + }, [] ); - const handleSelect = ( data: RemoteDataQueryInput ): void => { - onSelect?.( data ); - }; + function onSelectItem( input: RemoteDataQueryInput ): void { + onSelect?.( input ); + sendTracksEvent( 'remotedatablocks_add_block', { + action: 'select_item', + selected_option: 'search_from_list', + data_source_type: getBlockDataSourceType( blockName ), + } ); + close(); + } const triggerElement = renderTrigger ? ( renderTrigger( { onClick: open } ) @@ -47,6 +78,7 @@ export const DataViewsModal: React.FC< DataViewsModalProps > = props => { { __( 'Choose' ) } ); + return ( <> { triggerElement } @@ -61,11 +93,16 @@ export const DataViewsModal: React.FC< DataViewsModalProps > = props => { availableBindings={ availableBindings } blockName={ blockName } loading={ loading } - onSelect={ handleSelect } + onSelect={ onSelect ? onSelectItem : close } onSelectField={ onSelectField } - remoteData={ remoteData ?? undefined } - searchTerms={ searchTerms } - setSearchTerms={ setSearchTerms } + page={ page } + remoteData={ data } + searchInput={ searchInput } + setPage={ setPage } + setSearchInput={ setSearchInput } + supportsSearch={ supportsSearch } + totalItems={ totalItems } + totalPages={ totalPages } /> ) } diff --git a/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx b/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx index 04abcdfa..99c3c259 100644 --- a/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx +++ b/src/blocks/remote-data-container/components/placeholders/ItemSelectQueryType.tsx @@ -21,6 +21,7 @@ export function ItemSelectQueryType( props: ItemSelectQueryTypeProps ) { const selectorProps = { blockName, headerImage: selector.image_url, + inputVariables: selector.inputs, onSelect, queryKey: selector.query_key, title, diff --git a/src/blocks/remote-data-container/config/constants.ts b/src/blocks/remote-data-container/config/constants.ts index 315735c0..4b77256e 100644 --- a/src/blocks/remote-data-container/config/constants.ts +++ b/src/blocks/remote-data-container/config/constants.ts @@ -14,6 +14,13 @@ export const REMOTE_DATA_REST_API_URL = getRestUrl(); export const CONTAINER_CLASS_NAME = getClassName( 'container' ); +export const PAGINATION_CURSOR_NEXT_VARIABLE_TYPE = 'ui:pagination_cursor_next'; +export const PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE = 'ui:pagination_cursor_previous'; +export const PAGINATION_OFFSET_VARIABLE_TYPE = 'ui:pagination_offset'; +export const PAGINATION_PAGE_VARIABLE_TYPE = 'ui:pagination_page'; +export const PAGINATION_PER_PAGE_VARIABLE_TYPE = 'ui:pagination_per_page'; +export const SEARCH_INPUT_VARIABLE_TYPE = 'ui:search_input'; + export const BUTTON_TEXT_FIELD_TYPES = [ 'button_text' ]; export const BUTTON_URL_FIELD_TYPES = [ 'button_url' ]; export const HTML_FIELD_TYPES = [ 'html' ]; diff --git a/src/blocks/remote-data-container/hooks/usePaginationVariables.ts b/src/blocks/remote-data-container/hooks/usePaginationVariables.ts new file mode 100644 index 00000000..d4cdf536 --- /dev/null +++ b/src/blocks/remote-data-container/hooks/usePaginationVariables.ts @@ -0,0 +1,137 @@ +import { useState } from '@wordpress/element'; + +import { + PAGINATION_CURSOR_NEXT_VARIABLE_TYPE, + PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE, + PAGINATION_OFFSET_VARIABLE_TYPE, + PAGINATION_PAGE_VARIABLE_TYPE, + PAGINATION_PER_PAGE_VARIABLE_TYPE, +} from '@/blocks/remote-data-container/config/constants'; + +interface UsePaginationVariables { + onFetch: ( remoteData: RemoteData ) => void; + page: number; + paginationQueryInput: RemoteDataQueryInput; + perPage?: number; + setPage: ( page: number ) => void; + setPerPage: ( perPage: number ) => void; + supportsCursorPagination: boolean; + supportsOffsetPagination: boolean; + supportsPagePagination: boolean; + supportsPagination: boolean; + supportsPerPage: boolean; + totalItems?: number; + totalPages?: number; +} + +interface UsePaginationVariablesInput { + initialPage?: number; + initialPerPage?: number; + inputVariables: InputVariable[]; +} + +export function usePaginationVariables( { + initialPage = 1, + initialPerPage, + inputVariables, +}: UsePaginationVariablesInput ): UsePaginationVariables { + const [ paginationData, setPaginationData ] = useState< RemoteDataPagination >(); + const [ page, setPage ] = useState< number >( initialPage ); + const [ perPage, setPerPage ] = useState< number | null >( initialPerPage ?? null ); + + const cursorNextVariable = inputVariables?.find( + input => input.type === PAGINATION_CURSOR_NEXT_VARIABLE_TYPE + ); + const cursorPreviousVariable = inputVariables?.find( + input => input.type === PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE + ); + const offsetVariable = inputVariables?.find( + input => input.type === PAGINATION_OFFSET_VARIABLE_TYPE + ); + const pageVariable = inputVariables?.find( + input => input.type === PAGINATION_PAGE_VARIABLE_TYPE + ); + const perPageVariable = inputVariables?.find( + input => input.type === PAGINATION_PER_PAGE_VARIABLE_TYPE + ); + + const paginationQueryInput: RemoteDataQueryInput = {}; + + // These will be amended below. + let supportsCursorPagination = false; + let supportsOffsetPagination = false; + let supportsPagePagination = false; + let setPageFn: ( page: number ) => void = () => {}; + + if ( cursorNextVariable && cursorPreviousVariable ) { + setPageFn = setPageForCursorPagination; + supportsCursorPagination = true; + Object.assign( paginationQueryInput, { + [ cursorNextVariable.slug ]: paginationData?.cursorNext, + [ cursorPreviousVariable.slug ]: paginationData?.cursorPrevious, + } ); + } else if ( offsetVariable && perPage ) { + setPageFn = setPage; + supportsOffsetPagination = true; + Object.assign( paginationQueryInput, { [ offsetVariable.slug ]: page * perPage } ); + } else if ( pageVariable ) { + setPageFn = setPage; + supportsPagePagination = true; + Object.assign( paginationQueryInput, { [ pageVariable.slug ]: page } ); + } + + if ( perPageVariable && perPage ) { + Object.assign( paginationQueryInput, { [ perPageVariable.slug ]: perPage } ); + } + + const supportsPagination = + supportsCursorPagination || supportsPagePagination || supportsOffsetPagination; + const totalItems = paginationData?.totalItems; + const totalPages = totalItems && perPage ? Math.ceil( totalItems / perPage ) : undefined; + + function onFetch( remoteData: RemoteData ): void { + if ( ! supportsPagination ) { + return; + } + + setPaginationData( remoteData.pagination ); + + // We need a perPage value to calculate the total pages, so inpsect the results. + if ( ! perPage && remoteData.results.length ) { + setPerPage( remoteData.results.length ); + } + } + + // With cursor pagination, we can only go one page at a time. + function setPageForCursorPagination( newPage: number ): void { + if ( newPage > page ) { + if ( totalPages ) { + setPage( Math.min( totalPages, page + 1 ) ); + return; + } + + setPage( page + 1 ); + return; + } + + if ( newPage < page ) { + setPage( Math.max( 1, page - 1 ) ); + } + } + + return { + onFetch, + page, + paginationQueryInput, + perPage: perPage ?? undefined, + setPage: setPageFn, + setPerPage: supportsPagination ? setPerPage : () => {}, + supportsCursorPagination, + supportsOffsetPagination, + supportsPagePagination, + supportsPagination, + supportsPerPage: Boolean( perPageVariable ), + totalItems, + totalPages, + }; +} diff --git a/src/blocks/remote-data-container/hooks/useRemoteData.ts b/src/blocks/remote-data-container/hooks/useRemoteData.ts index 816a897a..1b88da27 100644 --- a/src/blocks/remote-data-container/hooks/useRemoteData.ts +++ b/src/blocks/remote-data-container/hooks/useRemoteData.ts @@ -1,7 +1,9 @@ import apiFetch from '@wordpress/api-fetch'; -import { useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { REMOTE_DATA_REST_API_URL } from '@/blocks/remote-data-container/config/constants'; +import { usePaginationVariables } from '@/blocks/remote-data-container/hooks/usePaginationVariables'; +import { useSearchVariables } from '@/blocks/remote-data-container/hooks/useSearchVariables'; async function fetchRemoteData( requestData: RemoteDataApiRequest ): Promise< RemoteData | null > { const { body } = await apiFetch< RemoteDataApiResponse >( { @@ -18,6 +20,11 @@ async function fetchRemoteData( requestData: RemoteDataApiRequest ): Promise< Re blockName: body.block_name, isCollection: body.is_collection, metadata: body.metadata, + pagination: body.pagination && { + cursorNext: body.pagination.cursor_next, + cursorPrevious: body.pagination.cursor_previous, + totalItems: body.pagination.total_items, + }, queryInput: body.query_input, resultId: body.result_id, results: body.results.map( result => @@ -35,8 +42,25 @@ async function fetchRemoteData( requestData: RemoteDataApiRequest ): Promise< Re interface UseRemoteData { data?: RemoteData; fetch: ( queryInput: RemoteDataQueryInput ) => Promise< void >; + hasNextPage: boolean; + hasPreviousPage: boolean; loading: boolean; + page: number; + perPage?: number; reset: () => void; + searchAllowsEmptyInput: boolean; + searchInput: string; + setPage: ( page: number ) => void; + setPerPage: ( perPage: number ) => void; + setSearchInput: ( searchInput: string ) => void; + supportsCursorPagination: boolean; + supportsOffsetPagination: boolean; + supportsPagePagination: boolean; + supportsPagination: boolean; + supportsPerPage: boolean; + supportsSearch: boolean; + totalItems?: number; + totalPages?: number; } interface UseRemoteDataInput { @@ -44,6 +68,10 @@ interface UseRemoteDataInput { enabledOverrides?: string[]; externallyManagedRemoteData?: RemoteData; externallyManagedUpdateRemoteData?: ( remoteData?: RemoteData ) => void; + initialPage?: number; + initialPerPage?: number; + initialSearchInput?: string; + inputVariables?: InputVariable[]; onSuccess?: () => void; queryKey: string; } @@ -61,6 +89,10 @@ export function useRemoteData( { enabledOverrides = [], externallyManagedRemoteData, externallyManagedUpdateRemoteData, + initialPage, + initialPerPage, + initialSearchInput, + inputVariables = [], onSuccess, queryKey, }: UseRemoteDataInput ): UseRemoteData { @@ -69,6 +101,35 @@ export function useRemoteData( { const resolvedData = externallyManagedRemoteData ?? data; const resolvedUpdater = externallyManagedUpdateRemoteData ?? setData; + const hasResolvedData = Boolean( resolvedData ); + + const { + onFetch: onFetchForPagination, + page, + perPage, + paginationQueryInput, + supportsPagination, + totalItems, + totalPages, + ...paginationVariables + } = usePaginationVariables( { + initialPage, + initialPerPage, + inputVariables, + } ); + const { searchQueryInput, searchAllowsEmptyInput, searchInput, setSearchInput, supportsSearch } = + useSearchVariables( { + initialSearchInput, + inputVariables, + } ); + + useEffect( () => { + if ( ! hasResolvedData ) { + return; + } + + void fetch( resolvedData?.queryInput ?? {} ); + }, [ hasResolvedData, page, perPage, searchInput ] ); async function fetch( queryInput: RemoteDataQueryInput ): Promise< void > { setLoading( true ); @@ -76,7 +137,11 @@ export function useRemoteData( { const requestData: RemoteDataApiRequest = { block_name: blockName, query_key: queryKey, - query_input: queryInput, + query_input: { + ...queryInput, + ...paginationQueryInput, + ...searchQueryInput, + }, }; const remoteData = await fetchRemoteData( requestData ).catch( () => null ); @@ -87,6 +152,7 @@ export function useRemoteData( { return; } + onFetchForPagination( remoteData ); resolvedUpdater( { enabledOverrides, ...remoteData } ); setLoading( false ); onSuccess?.(); @@ -99,7 +165,19 @@ export function useRemoteData( { return { data: resolvedData, fetch, + hasNextPage: totalPages ? page < totalPages : supportsPagination, + hasPreviousPage: page > 1, loading, + page, + perPage, reset, + searchAllowsEmptyInput, + searchInput, + setSearchInput, + supportsPagination, + supportsSearch, + totalItems: resolvedData?.pagination?.totalItems, + totalPages, + ...paginationVariables, }; } diff --git a/src/blocks/remote-data-container/hooks/useSearchResults.ts b/src/blocks/remote-data-container/hooks/useSearchResults.ts deleted file mode 100644 index fcf01a47..00000000 --- a/src/blocks/remote-data-container/hooks/useSearchResults.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef, useState } from '@wordpress/element'; - -import { useRemoteData } from '@/blocks/remote-data-container/hooks/useRemoteData'; - -export interface UseSearchResultsInput { - allowEmptySearchTerms?: boolean; - blockName: string; - debounceInMs?: number; - queryKey: string; -} -export function useSearchResults( { - allowEmptySearchTerms = true, - blockName, - debounceInMs = 200, - queryKey, -}: UseSearchResultsInput ) { - const [ searchTerms, setSearchTerms ] = useState< string >( '' ); - const { data, fetch, loading } = useRemoteData( { blockName, queryKey } ); - const timer = useRef< NodeJS.Timeout >(); - - function onSubmit(): void { - void fetch( { search_terms: searchTerms } ); - } - - function onKeyDown( event: React.KeyboardEvent< HTMLInputElement > ): void { - if ( event.code !== 'Enter' ) { - return; - } - - event.preventDefault(); - onSubmit(); - } - - useEffect( () => { - if ( allowEmptySearchTerms || searchTerms ) { - // Debounce the search term input. - const newTimer = setTimeout( onSubmit, debounceInMs ); - clearTimeout( timer.current ); - timer.current = newTimer; - } - - return () => clearTimeout( timer.current ); - }, [ allowEmptySearchTerms, searchTerms ] ); - - return { - loading, - onKeyDown, - data, - searchTerms, - setSearchTerms, - }; -} diff --git a/src/blocks/remote-data-container/hooks/useSearchVariables.ts b/src/blocks/remote-data-container/hooks/useSearchVariables.ts new file mode 100644 index 00000000..074b2005 --- /dev/null +++ b/src/blocks/remote-data-container/hooks/useSearchVariables.ts @@ -0,0 +1,41 @@ +import { SEARCH_INPUT_VARIABLE_TYPE } from '@/blocks/remote-data-container/config/constants'; +import { useDebouncedState } from '@/hooks/useDebouncedState'; + +interface UseSearchVariables { + searchAllowsEmptyInput: boolean; + searchInput: string; + searchQueryInput: RemoteDataQueryInput; + setSearchInput: ( searchInput: string ) => void; + supportsSearch: boolean; +} + +interface UseSearchVariablesInput { + initialSearchInput?: string; + inputVariables: InputVariable[]; + searchInputDelayInMs?: number; +} + +export function useSearchVariables( { + initialSearchInput = '', + inputVariables, + searchInputDelayInMs = 500, +}: UseSearchVariablesInput ): UseSearchVariables { + const [ searchInput, setSearchInput ] = useDebouncedState< string >( + searchInputDelayInMs, + initialSearchInput + ); + + const inputVariable = inputVariables?.find( input => input.type === SEARCH_INPUT_VARIABLE_TYPE ); + const supportsSearch = Boolean( inputVariable ); + const searchAllowsEmptyInput = supportsSearch && ! inputVariable?.required; + const hasSearchInput = supportsSearch && ( searchInput || searchAllowsEmptyInput ); + + return { + searchAllowsEmptyInput, + searchInput, + searchQueryInput: + hasSearchInput && inputVariable ? { [ inputVariable.slug ]: searchInput } : {}, + setSearchInput: supportsSearch ? setSearchInput : () => {}, + supportsSearch, + }; +} diff --git a/src/hooks/useDebouncedState.ts b/src/hooks/useDebouncedState.ts new file mode 100644 index 00000000..5a9299e4 --- /dev/null +++ b/src/hooks/useDebouncedState.ts @@ -0,0 +1,21 @@ +import { useCallback, useRef, useState } from '@wordpress/element'; + +export function useDebouncedState< T >( + delayInMs: number, + initialValue: T +): [ T, ( value: T ) => void ] { + const timer = useRef< NodeJS.Timeout | null >( null ); + const [ value, setValue ] = useState< T >( initialValue ); + + const debouncedSetValue = useCallback( ( newValue: T ) => { + if ( timer.current ) { + clearTimeout( timer.current ); + } + + timer.current = setTimeout( () => { + setValue( newValue ); + }, delayInMs ); + }, [] ); + + return [ value, debouncedSetValue ]; +} diff --git a/src/utils/type-narrowing.ts b/src/utils/type-narrowing.ts index 1874eba2..32edb84a 100644 --- a/src/utils/type-narrowing.ts +++ b/src/utils/type-narrowing.ts @@ -1,3 +1,11 @@ export function isObjectWithStringKeys( value: unknown ): value is Record< string, unknown > { return typeof value === 'object' && value !== null && ! Array.isArray( value ); } + +export function removeNullValuesFromObject< ValueType >( + obj: Record< string, ValueType | null > +): Record< string, ValueType > { + return Object.fromEntries< ValueType >( + Object.entries( obj ).filter( ( entry ): entry is [ string, ValueType ] => entry[ 1 ] !== null ) + ); +} diff --git a/tests/inc/Functions/FunctionsTest.php b/tests/inc/Functions/FunctionsTest.php index ebc75c79..3ba87bb9 100644 --- a/tests/inc/Functions/FunctionsTest.php +++ b/tests/inc/Functions/FunctionsTest.php @@ -28,7 +28,7 @@ protected function setUp(): void { ] ); $this->mock_search_query = MockQuery::from_array( [ 'input_schema' => [ - 'search_terms' => [ 'type' => 'string' ], + 'search' => [ 'type' => 'ui:search_input' ], ], ] ); @@ -165,6 +165,6 @@ public function testRegisterSearchQueryWithoutSearchTerms(): void { $this->assertTrue( $this->mock_logger->hasLoggedLevel( LogLevel::ERROR ) ); $error_logs = $this->mock_logger->getLogsByLevel( LogLevel::ERROR ); - $this->assertStringContainsString( 'search_terms', $error_logs[0]['message'] ); + $this->assertStringContainsString( 'ui:search_input', $error_logs[0]['message'] ); } } diff --git a/tests/src/utils/type-narrowing.test.ts b/tests/src/utils/type-narrowing.test.ts index 705e2d78..d6fbee40 100644 --- a/tests/src/utils/type-narrowing.test.ts +++ b/tests/src/utils/type-narrowing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isObjectWithStringKeys } from '@/utils/type-narrowing'; +import { isObjectWithStringKeys, removeNullValuesFromObject } from '@/utils/type-narrowing'; describe( 'type-narrowing utils', () => { describe( 'isObjectWithStringKeys', () => { @@ -35,4 +35,17 @@ describe( 'type-narrowing utils', () => { expect( isObjectWithStringKeys( obj ) ).toBe( true ); } ); } ); + + describe( 'removeNullValuesFromObject', () => { + it( 'should remove null values from object', () => { + const obj = { + aaa: 1, + bbb: null, + ccc: 'three', + ddd: null, + }; + + expect( removeNullValuesFromObject( obj ) ).toEqual( { aaa: 1, ccc: 'three' } ); + } ); + } ); } ); diff --git a/types/remote-data.d.ts b/types/remote-data.d.ts index 4a878358..082e1f82 100644 --- a/types/remote-data.d.ts +++ b/types/remote-data.d.ts @@ -2,6 +2,12 @@ interface InnerBlockContext { index: number; } +interface RemoteDataPagination { + cursorNext?: string; + cursorPrevious?: string; + totalItems: number; +} + interface RemoteDataResultFields { name: string; type: string; @@ -16,6 +22,7 @@ interface RemoteData { enabledOverrides?: string[]; isCollection: boolean; metadata: Record< string, RemoteDataResultFields >; + pagination?: RemoteDataPagination; queryInput: RemoteDataQueryInput; resultId: string; results: RemoteDataResult[]; @@ -77,6 +84,11 @@ interface RemoteDataApiResponseBody { block_name: string; is_collection: boolean; metadata: Record< string, RemoteDataResultFields >; + pagination?: { + cursor_next?: string; + cursor_previous?: string; + total_items: number; + }; query_input: RemoteDataQueryInput; result_id: string; results: RemoteDataApiResult[];