diff --git a/docs/extending/query.md b/docs/extending/query.md index 31e740f6..c55b07b7 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -74,7 +74,7 @@ The `get_input_schema` method defines the input data expected by the query. The - `image_alt` - `image_url` - `number` - - `price` + - `currency` - `string` #### Example diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 49e21d3b..b868cedf 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -241,7 +241,10 @@ public function execute( array $input_variables ): array|WP_Error { * @param string $field_type The field type. * @return string The field value. */ - protected function get_field_value( array|string $field_value, string $default_value = '', string $field_type = 'string' ): string { + protected function get_field_value( array|string $field_value, array $mapping ): string { + $default_value = $mapping['default_value'] ?? ''; + $field_type = $mapping['type']; + $field_value_single = is_array( $field_value ) && count( $field_value ) > 1 ? $field_value : ( $field_value[0] ?? $default_value ); switch ( $field_type ) { @@ -251,10 +254,18 @@ protected function get_field_value( array|string $field_value, string $default_v case 'html': return $field_value_single; - case 'price': - return sprintf( '$%s', number_format( (float) $field_value_single, 2 ) ); + case 'currency': + $currency_symbol = $mapping['prefix'] ?? '$'; + return sprintf( '%s%s', $currency_symbol, number_format( (float) $field_value_single, 2 ) ); case 'string': + if ( is_array( $field_value_single ) ) { + // Ensure all elements are strings and filter out non-string values + $string_values = array_filter( $field_value_single, '\is_string' ); + if ( ! empty( $string_values ) ) { + return wp_strip_all_tags( implode( ', ', $string_values ) ); + } + } return wp_strip_all_tags( $field_value_single ); } @@ -304,7 +315,7 @@ protected function map_fields( string|array|object|null $response_data, bool $is // JSONPath always returns values in an array, even if there's only one value. // Because we're mostly interested in single values for field mapping, unwrap the array if it's only one item. - $field_value_single = self::get_field_value( $field_value, $mapping['default_value'] ?? '', $mapping['type'] ); + $field_value_single = self::get_field_value( $field_value, $mapping ); } return array_merge( $mapping, [ diff --git a/inc/Editor/BlockPatterns/BlockPatterns.php b/inc/Editor/BlockPatterns/BlockPatterns.php index efb24f8c..e2160aec 100644 --- a/inc/Editor/BlockPatterns/BlockPatterns.php +++ b/inc/Editor/BlockPatterns/BlockPatterns.php @@ -109,7 +109,7 @@ public static function register_default_block_pattern( string $block_name, strin break; case 'base64': - case 'price': + case 'currency': $bindings['paragraphs'][] = [ 'content' => [ $field, $name ], ]; diff --git a/inc/Integrations/Airtable/AirtableDataSource.php b/inc/Integrations/Airtable/AirtableDataSource.php index 0f1d8c7d..59e0ae3e 100644 --- a/inc/Integrations/Airtable/AirtableDataSource.php +++ b/inc/Integrations/Airtable/AirtableDataSource.php @@ -44,11 +44,23 @@ class AirtableDataSource extends HttpDataSource { 'items' => [ 'type' => 'object', 'properties' => [ - 'name' => [ 'type' => 'string' ], + 'key' => [ 'type' => 'string' ], + 'name' => [ + 'type' => 'string', + 'required' => false, + ], 'type' => [ 'type' => 'string', 'required' => false, ], + 'path' => [ + 'type' => 'string', + 'required' => false, + ], + 'prefix' => [ + 'type' => 'string', + 'required' => false, + ], ], ], ], @@ -121,11 +133,16 @@ public function ___temp_get_query(): AirtableGetItemQuery|\WP_Error { ]; foreach ( $this->config['tables'][0]['output_query_mappings'] as $mapping ) { - $output_schema['mappings'][ ucfirst( $mapping['name'] ) ] = [ - 'name' => $mapping['name'], - 'path' => '$.fields.' . $mapping['name'], + $mapping_key = $mapping['key']; + $output_schema['mappings'][ $mapping_key ] = [ + 'name' => $mapping['name'] ?? $mapping_key, + 'path' => $mapping['path'] ?? '$.fields["' . $mapping_key . '"]', 'type' => $mapping['type'] ?? 'string', ]; + + if ( 'currency' === $mapping['type'] && isset( $mapping['prefix'] ) ) { + $output_schema['mappings'][ $mapping_key ]['prefix'] = $mapping['prefix']; + } } return AirtableGetItemQuery::from_array([ diff --git a/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php b/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php index 183fc47a..1f743701 100644 --- a/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php +++ b/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php @@ -47,7 +47,7 @@ public function get_output_schema(): array { 'price' => [ 'name' => 'Item price', 'path' => '$.data.product.priceRange.maxVariantPrice.amount', - 'type' => 'price', + 'type' => 'currency', ], 'variant_id' => [ 'name' => 'Variant ID', diff --git a/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php b/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php index 1c1baabd..2aeaceb5 100644 --- a/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php +++ b/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php @@ -31,7 +31,7 @@ public function get_output_schema(): array { 'price' => [ 'name' => 'Item price', 'path' => '$.node.priceRange.maxVariantPrice.amount', - 'type' => 'price', + 'type' => 'currency', ], 'image_url' => [ 'name' => 'Item image URL', diff --git a/src/blocks/remote-data-container/config/constants.ts b/src/blocks/remote-data-container/config/constants.ts index 07c3ec2f..64441c8b 100644 --- a/src/blocks/remote-data-container/config/constants.ts +++ b/src/blocks/remote-data-container/config/constants.ts @@ -15,5 +15,5 @@ export const REMOTE_DATA_REST_API_URL = getRestUrl(); export const CONTAINER_CLASS_NAME = getClassName( 'container' ); export const IMAGE_FIELD_TYPES = [ 'image_alt', 'image_url' ]; -export const TEXT_FIELD_TYPES = [ 'number', 'base64', 'price', 'string' ]; +export const TEXT_FIELD_TYPES = [ 'number', 'base64', 'currency', 'string' ]; export const BUTTON_FIELD_TYPES = [ 'button_url' ]; diff --git a/src/data-sources/airtable/AirtableSettings.tsx b/src/data-sources/airtable/AirtableSettings.tsx index 3df12ade..1646c75e 100644 --- a/src/data-sources/airtable/AirtableSettings.tsx +++ b/src/data-sources/airtable/AirtableSettings.tsx @@ -4,7 +4,9 @@ import { useEffect, useMemo, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { ChangeEvent } from 'react'; +import { SUPPORTED_AIRTABLE_TYPES } from '@/data-sources/airtable/constants'; import { AirtableFormState } from '@/data-sources/airtable/types'; +import { getAirtableOutputQueryMappingValue } from '@/data-sources/airtable/utils'; import { DataSourceForm } from '@/data-sources/components/DataSourceForm'; import { DataSourceFormActions } from '@/data-sources/components/DataSourceFormActions'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; @@ -15,7 +17,11 @@ import { useAirtableApiUserId, } from '@/data-sources/hooks/useAirtable'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; -import { AirtableConfig, SettingsComponentProps } from '@/data-sources/types'; +import { + AirtableConfig, + AirtableOutputQueryMappingValue, + SettingsComponentProps, +} from '@/data-sources/types'; import { getConnectionMessage } from '@/data-sources/utils'; import { useForm } from '@/hooks/useForm'; import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; @@ -51,7 +57,7 @@ const getInitialStateFromConfig = ( config?: AirtableConfig ): AirtableFormState name: table.name, }; initialStateFromConfig.table_fields = new Set( - table.output_query_mappings.map( ( { name } ) => name ) + table.output_query_mappings.map( ( { key } ) => key ) ); } } @@ -105,6 +111,11 @@ export const AirtableSettings = ( { return; } + const selectedTable = tables?.find( table => table.id === state.table?.id ); + if ( ! selectedTable ) { + return; + } + const airtableConfig: AirtableConfig = { uuid: uuidFromProps ?? '', service: 'airtable', @@ -114,10 +125,18 @@ export const AirtableSettings = ( { { id: state.table.id, name: state.table.name, - output_query_mappings: Array.from( state.table_fields ).map( name => ( { - name, - type: name.endsWith( '.url' ) ? 'image_url' : 'string', - } ) ), + output_query_mappings: Array.from( state.table_fields ) + .map( key => { + const field = selectedTable.fields.find( tableField => tableField.name === key ); + if ( field ) { + return getAirtableOutputQueryMappingValue( field ); + } + /** + * Remove any fields which are not from this table or not supported. + */ + return null; + } ) + .filter( Boolean ) as AirtableOutputQueryMappingValue[], }, ], slug: state.slug, @@ -148,6 +167,11 @@ export const AirtableSettings = ( { } else if ( id === 'table' ) { const selectedTable = tables?.find( table => table.id === value ); newValue = { id: value, name: selectedTable?.name ?? '' }; + + if ( value !== state.table?.id ) { + // Reset the selected fields when the table changes. + handleOnChange( 'table_fields', new Set< string >() ); + } } handleOnChange( id, newValue ); } @@ -284,19 +308,8 @@ export const AirtableSettings = ( { if ( selectedTable ) { selectedTable.fields.forEach( field => { - const simpleFieldTypes = [ - 'singleLineText', - 'multilineText', - 'email', - 'phoneNumber', - 'url', - 'number', - ]; - - if ( simpleFieldTypes.includes( field.type ) ) { + if ( SUPPORTED_AIRTABLE_TYPES.includes( field.type ) ) { newAvailableTableFields.push( field.name ); - } else if ( field.type === 'multipleAttachments' ) { - newAvailableTableFields.push( `${ field.name }[0].url` ); } } ); } diff --git a/src/data-sources/airtable/constants.ts b/src/data-sources/airtable/constants.ts new file mode 100644 index 00000000..366ed11d --- /dev/null +++ b/src/data-sources/airtable/constants.ts @@ -0,0 +1,65 @@ +export const AIRTABLE_STRING_TYPES = Object.freeze( + new Set( [ + 'singleLineText', + 'multilineText', + 'email', + 'phoneNumber', + 'richText', + 'barcode', + 'singleSelect', + 'date', + 'dateTime', + 'lastModifiedTime', + 'createdTime', + 'multipleRecordLinks', + 'rollup', + 'externalSyncSource', + ] ) +); + +export const AIRTABLE_NUMBER_TYPES = Object.freeze( + new Set( [ 'number', 'autoNumber', 'rating', 'duration', 'count', 'percent' ] ) +); + +export const AIRTABLE_USER_TYPES = Object.freeze( + new Set( [ 'createdBy', 'lastModifiedBy', 'singleCollaborator' ] ) +); + +export const SUPPORTED_AIRTABLE_TYPES = Object.freeze( [ + // String types + 'singleLineText', + 'multilineText', + 'email', + 'phoneNumber', + 'richText', + 'barcode', + 'singleSelect', + 'multipleSelects', + 'date', + 'dateTime', + 'lastModifiedTime', + 'createdTime', + 'multipleRecordLinks', + 'rollup', + 'externalSyncSource', + // Number types + 'number', + 'autoNumber', + 'rating', + 'duration', + 'count', + 'percent', + // User types + 'createdBy', + 'lastModifiedBy', + 'singleCollaborator', + // Other types + 'multipleCollaborator', + 'url', + 'button', + 'currency', + 'checkbox', + 'multipleAttachments', + 'formula', + 'lookup', +] ); diff --git a/src/data-sources/airtable/types.ts b/src/data-sources/airtable/types.ts index 8d6f7010..33410d36 100644 --- a/src/data-sources/airtable/types.ts +++ b/src/data-sources/airtable/types.ts @@ -34,13 +34,35 @@ export interface AirtableTable { syncStatus: 'complete' | 'pending'; } -interface AirtableField { +/** + * Represents an Airtable field configuration. + * @see https://airtable.com/developers/web/api/model/table-model#fields + */ +export interface AirtableField { id: string; name: string; type: string; description: string | null; options?: { - [ key: string ]: unknown; + choices?: Array< { + id: string; + name: string; + color?: string; + } >; + precision?: number; + symbol?: string; + format?: string; + foreignTableId?: string; + relationship?: 'many' | 'one'; + symmetricColumnId?: string; + result?: { + type: string; + options?: { + precision?: number; + symbol?: string; + format?: string; + }; + }; }; } diff --git a/src/data-sources/airtable/utils.ts b/src/data-sources/airtable/utils.ts new file mode 100644 index 00000000..f36b5663 --- /dev/null +++ b/src/data-sources/airtable/utils.ts @@ -0,0 +1,86 @@ +import { + AIRTABLE_STRING_TYPES, + AIRTABLE_NUMBER_TYPES, + AIRTABLE_USER_TYPES, +} from '@/data-sources/airtable/constants'; +import { AirtableField } from '@/data-sources/airtable/types'; +import { AirtableOutputQueryMappingValue } from '@/data-sources/types'; + +export const getAirtableOutputQueryMappingValue = ( + field: AirtableField +): AirtableOutputQueryMappingValue => { + const baseField = { + path: `$.fields["${ field.name }"]`, + name: field.name, + key: field.name, + }; + + if ( AIRTABLE_STRING_TYPES.has( field.type ) ) { + return { ...baseField, type: 'string' }; + } + + if ( AIRTABLE_NUMBER_TYPES.has( field.type ) ) { + return { ...baseField, type: 'number' }; + } + + if ( AIRTABLE_USER_TYPES.has( field.type ) ) { + return { ...baseField, path: `$.fields["${ field.name }"].name`, type: 'string' }; + } + + switch ( field.type ) { + case 'url': + case 'button': + return { ...baseField, type: 'button_url' }; + + case 'currency': + return { + ...baseField, + type: 'currency', + prefix: field.options?.symbol, + }; + + case 'checkbox': + return { ...baseField, type: 'boolean' }; + + case 'multipleSelects': + return { + ...baseField, + path: `$.fields["${ field.name }"][*]`, + type: 'string', + }; + + case 'multipleRecordLinks': + return { + ...baseField, + path: `$.fields["${ field.name }"][*].id`, + type: 'string', + }; + + case 'multipleAttachments': + return { + ...baseField, + path: `$.fields["${ field.name }"][0].url`, + type: 'image_url', + }; + + case 'multipleCollaborator': + return { + ...baseField, + path: `$.fields["${ field.name }"][*].name`, + type: 'string', + }; + + case 'formula': + case 'lookup': + if ( field.options?.result?.type ) { + return getAirtableOutputQueryMappingValue( { + ...field, + type: field.options.result.type, + } ); + } + return { ...baseField, type: 'string' }; + + default: + return { ...baseField, type: 'string' }; + } +}; diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts index b8fe8029..b1261da1 100644 --- a/src/data-sources/types.ts +++ b/src/data-sources/types.ts @@ -12,9 +12,10 @@ interface BaseDataSourceConfig { } export interface DataSourceQueryMappingValue { + key: string; name: string; path: string; - type: 'id' | 'string'; + type: string; } export type DataSourceQueryMapping = Record< string, DataSourceQueryMappingValue >; @@ -30,7 +31,11 @@ export interface DataSourceQuery { * - `type` is always string for now. */ export interface AirtableOutputQueryMappingValue { - name: string; + key: string; + name?: string; + path?: string; + type?: string; + prefix?: string; } export interface AirtableTableConfig extends StringIdName {