diff --git a/.wp-env.json b/.wp-env.json index c4f1cd99a..3bfc57f45 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -7,11 +7,13 @@ "WP_REDIS_DISABLE_BANNERS": true, "WP_REDIS_HOST": "host.docker.internal" }, + "core": "WordPress/WordPress#master", "mappings": { ".htaccess": "./bin/conf/htaccess" }, "plugins": [ ".", + "../gutenberg", "https://downloads.wordpress.org/plugin/query-monitor.latest-stable.zip", "https://downloads.wordpress.org/plugin/redis-cache.latest-stable.zip" ] diff --git a/inc/Editor/BlockManagement/BlockRegistration.php b/inc/Editor/BlockManagement/BlockRegistration.php index 7519b4ca9..140a07b6f 100644 --- a/inc/Editor/BlockManagement/BlockRegistration.php +++ b/inc/Editor/BlockManagement/BlockRegistration.php @@ -39,7 +39,8 @@ public static function add_block_category( array $block_categories ): array { */ public static function enqueue_block_assets(): void { Assets::enqueue_build_asset( 'remote-data-blocks-dataviews', 'dataviews' ); - Assets::enqueue_build_asset( 'remote-data-blocks-block-editor', 'block-editor', [ 'remote-data-blocks-dataviews' ] ); + Assets::enqueue_build_asset( 'remote-data-blocks-store', 'store' ); + Assets::enqueue_build_asset( 'remote-data-blocks-block-editor', 'block-editor', [ 'remote-data-blocks-dataviews', 'remote-data-blocks-store' ] ); } public static function register_helper_blocks(): void { diff --git a/src/block-editor/binding-sources/remote-data-binding.ts b/src/block-editor/binding-sources/remote-data-binding.ts deleted file mode 100644 index 7d9670623..000000000 --- a/src/block-editor/binding-sources/remote-data-binding.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { registerBlockBindingsSource } from '@wordpress/blocks'; - -registerBlockBindingsSource( { - name: 'remote-data/binding', - label: 'Remote Data Blocks', - usesContext: [ 'remote-data-blocks/remoteData' ], - getValues() { - return {}; - }, -} ); diff --git a/src/block-editor/binding-sources/remote-data-binding.tsx b/src/block-editor/binding-sources/remote-data-binding.tsx new file mode 100644 index 000000000..b19e5cbcf --- /dev/null +++ b/src/block-editor/binding-sources/remote-data-binding.tsx @@ -0,0 +1,130 @@ +import { BlockEditorStoreSelectors } from '@wordpress/block-editor'; +import { BlockBindingsSource, registerBlockBindingsSource } from '@wordpress/blocks'; + +import { BlockBindingControls } from '@/blocks/remote-data-container/components/BlockBindingControls'; +import { REMOTE_DATA_CONTEXT_KEY } from '@/blocks/remote-data-container/config/constants'; +import { BLOCK_BINDING_SOURCE, STORE_NAME as rdbStore } from '@/config/constants'; +import { getBlockAvailableBindings } from '@/utils/localized-block-data'; + +import type { Selectors } from '@/store'; + +interface RemoteDataRawContext { + [ REMOTE_DATA_CONTEXT_KEY ]?: RemoteData; +} + +interface EditorUIDatum { + field: string; + label: string; + type: string; + value?: string; +} + +type EditorUIFn = BlockBindingsSource< RemoteDataRawContext, RemoteDataBlockBinding >[ 'editorUI' ]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const dropDownEditorUI: EditorUIFn = ( { context } ) => { + const remoteData = context[ REMOTE_DATA_CONTEXT_KEY ]; + const availableBindings = getBlockAvailableBindings( remoteData?.blockName ?? '' ); + const hasAvailableBindings = Boolean( Object.keys( availableBindings ).length ); + + if ( ! remoteData || ! hasAvailableBindings ) { + return {}; + } + + return { + mode: 'dropdown', + data: Object.entries( availableBindings ).map( + ( [ field, binding ] ): EditorUIDatum => ( { + field, + label: binding.name, + type: 'string', + value: remoteData.results?.[ 0 ]?.result?.[ field ]?.value as string | undefined, + } ) + ), + getArgs( { item }: { item: EditorUIDatum } ) { + return { + block: remoteData.blockName, + field: item.field, + }; + }, + isSelected( { + item, + binding, + }: { + item: EditorUIDatum; + binding: RemoteDataBlockBinding; + } ): boolean { + return binding?.args?.field === item.field; + }, + }; +}; + +const modalEditorUI: EditorUIFn = ( { context, select } ) => { + const remoteData = context[ REMOTE_DATA_CONTEXT_KEY ]; + const availableBindings = getBlockAvailableBindings( remoteData?.blockName ?? '' ); + const hasAvailableBindings = Boolean( Object.keys( availableBindings ).length ); + const block = + select< BlockEditorStoreSelectors >( + 'core/block-editor' + )?.getSelectedBlock< RemoteDataInnerBlockAttributes >(); + + if ( ! remoteData || ! hasAvailableBindings || ! block ) { + return {}; + } + + return { + mode: 'modal', + data: Object.entries( availableBindings ).map( ( [ key, binding ] ) => ( { + key, + label: binding.name, + type: binding.type, + value: key, + } ) ), + isSelected( { + item, + binding, + }: { + item: EditorUIDatum; + binding: RemoteDataBlockBinding; + } ): boolean { + return binding?.args?.field === item.field; + }, + renderModalContent( { attribute } ) { + return ( + + ); + }, + }; +}; + +registerBlockBindingsSource< RemoteDataRawContext, RemoteDataBlockBinding >( { + name: BLOCK_BINDING_SOURCE, + usesContext: [ 'remote-data-blocks/remoteData' ], + // editorUI: dropDownEditorUI, + editorUI: modalEditorUI, + getValues( { bindings, context, select } ): Record< string, string > { + if ( ! context[ REMOTE_DATA_CONTEXT_KEY ]?.results?.length ) { + return {}; + } + + const remoteData = context[ REMOTE_DATA_CONTEXT_KEY ]; + const previewIndex = select< Selectors >( rdbStore ).getPreviewIndex( remoteData.resultId ); + + return Object.fromEntries( + Object.entries( bindings ).map( ( [ targetAttribute, binding ] ): [ string, string ] => { + const index = binding.args.previewIndex ?? previewIndex; + const label = binding.args.label ? `${ binding.args.label }: ` : ''; + const value = String( + remoteData.results?.[ index ?? 0 ]?.result?.[ binding.args.field ]?.value ?? '' + ); + + return [ targetAttribute, `${ label }${ value }` ]; + } ) + ); + }, +} ); diff --git a/src/block-editor/filters/addUsesContext.ts b/src/block-editor/filters/addUsesContext.ts deleted file mode 100644 index 622e26dfe..000000000 --- a/src/block-editor/filters/addUsesContext.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BlockConfiguration } from '@wordpress/blocks'; - -import { - REMOTE_DATA_CONTEXT_KEY, - SUPPORTED_CORE_BLOCKS, -} from '@/blocks/remote-data-container/config/constants'; - -export function addUsesContext( - settings: BlockConfiguration< RemoteDataInnerBlockAttributes >, - name: string -) { - if ( ! SUPPORTED_CORE_BLOCKS.includes( name ) ) { - return settings; - } - - const { usesContext = [] } = settings; - - if ( ! usesContext?.includes( REMOTE_DATA_CONTEXT_KEY ) ) { - return { - ...settings, - usesContext: [ ...usesContext, REMOTE_DATA_CONTEXT_KEY ], - }; - } - - return settings; -} diff --git a/src/block-editor/filters/index.ts b/src/block-editor/filters/index.ts deleted file mode 100644 index 4b1ea2f2a..000000000 --- a/src/block-editor/filters/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { addFilter } from '@wordpress/hooks'; - -import { addUsesContext } from '@/block-editor/filters/addUsesContext'; -import { withBlockBindingShim } from '@/block-editor/filters/withBlockBinding'; - -/** - * Use a filter to wrap the block edit component with our block binding HOC. - * We are intentionally using the `blocks.registerBlockType` filter instead of - * `editor.BlockEdit` so that we can make sure our HOC is applied after any - * other HOCs from Core -- specifically this one, which injects the binding label - * as the attribute value: - * - * https://github.com/WordPress/gutenberg/blob/f56dbeb9257c19acf6fbd8b45d87ae8a841624da/packages/block-editor/src/hooks/use-bindings-attributes.js#L159 - */ -addFilter( - 'blocks.registerBlockType', - 'remote-data-blocks/withBlockBinding', - withBlockBindingShim, - 5 // Ensure this runs before core filters -); - -/** - * Use a filter to inject usesContext to core block settings. - */ -addFilter( 'blocks.registerBlockType', 'remote-data-blocks/addUsesContext', addUsesContext, 10 ); diff --git a/src/block-editor/filters/withBlockBinding.tsx b/src/block-editor/filters/withBlockBinding.tsx deleted file mode 100644 index 5cbef56c1..000000000 --- a/src/block-editor/filters/withBlockBinding.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { - BlockEditorStoreSelectors, - InspectorControls, - store as blockEditorStore, -} from '@wordpress/block-editor'; -import { BlockConfiguration, BlockEditProps } from '@wordpress/blocks'; -import { PanelBody } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; -import { __, sprintf } from '@wordpress/i18n'; - -import { BlockBindingControls } from '@/blocks/remote-data-container/components/BlockBindingControls'; -import { useRemoteDataContext } from '@/blocks/remote-data-container/hooks/useRemoteDataContext'; -import { - BLOCK_BINDING_SOURCE, - PATTERN_OVERRIDES_BINDING_SOURCE, - PATTERN_OVERRIDES_CONTEXT_KEY, -} from '@/config/constants'; -import { getBoundBlockClassName, getMismatchedAttributes } from '@/utils/block-binding'; -import { getBlockAvailableBindings, getBlockTitle } from '@/utils/localized-block-data'; - -interface BoundBlockEditProps { - attributes: RemoteDataInnerBlockAttributes; - availableBindings: AvailableBindings; - blockName: string; - children: JSX.Element; - remoteDataName: string; - remoteDataTitle: string; - setAttributes: ( attributes: RemoteDataInnerBlockAttributes ) => void; -} - -// This prop is provided by the `withPreviewIndex` filter, which is bundled with -// the Remote Data Template block. -interface BlockEditWithPreviewIndex { - previewIndex?: number; -} - -function BoundBlockEdit( props: BoundBlockEditProps ) { - const { - attributes, - availableBindings, - blockName, - remoteDataName, - remoteDataTitle, - setAttributes, - } = props; - const existingBindings = attributes.metadata?.bindings ?? {}; - - function removeBinding( target: string ) { - const { [ target ]: _remove, ...newBindings } = existingBindings; - setAttributes( { - metadata: { - ...attributes.metadata, - bindings: newBindings, - name: undefined, - }, - } ); - } - - function updateBinding( target: string, args: Omit< RemoteDataBlockBindingArgs, 'block' > ) { - setAttributes( { - className: getBoundBlockClassName( attributes, remoteDataName ), - metadata: { - ...attributes.metadata, - bindings: { - ...attributes.metadata?.bindings, - [ target ]: { - source: BLOCK_BINDING_SOURCE, - args: { - ...args, - block: remoteDataName, // Remote Data Block name - }, - }, - }, - name: availableBindings[ args.field ]?.name, // Changes block name in list view. - }, - } ); - } - - return ( - <> - - -

- { sprintf( __( 'Connected to %s', 'remote-data-blocks' ), remoteDataTitle ) } -

- -
-
- { props.children } - - ); -} - -export const withBlockBinding = createHigherOrderComponent( BlockEdit => { - return ( - props: BlockEditProps< RemoteDataInnerBlockAttributes > & BlockEditWithPreviewIndex - ) => { - const { attributes, context, name, previewIndex: index = 0, setAttributes } = props; - const { remoteData } = useRemoteDataContext( context ); - const availableBindings = getBlockAvailableBindings( remoteData?.blockName ?? '' ); - const hasAvailableBindings = Boolean( Object.keys( availableBindings ).length ); - const { hasMultiSelection } = useSelect< BlockEditorStoreSelectors >( blockEditorStore ); - - // If the block does not have a remote data context, render it as usual. - if ( ! remoteData || ! hasAvailableBindings ) { - return ; - } - - // Synced pattern overrides are provided via context and the value can be: - // - // - undefined (block is not in a synced pattern) - // - an empty array (block is in a synced pattern, but no overrides are applied) - // - an object defining the applied overrides - // - // This gives no indication of whether overrides are enabled or not. For - // that, we need to check the block's metadata bindings for the pattern - // overrides binding source. - // - // This seems likely to change, so the code here may need maintenance. For - // our purposes, though, we just want to know whether the block is in a - // synced pattern and whether overrides are enabled. Trying to update - // a synced block without overrides enabled is useless and can cause issues. - - const patternOverrides = context[ PATTERN_OVERRIDES_CONTEXT_KEY ] as string[] | undefined; - const isInSyncedPattern = Boolean( patternOverrides ); - const hasEnabledOverrides = Object.values( attributes.metadata?.bindings ?? {} ).some( - binding => binding.source === PATTERN_OVERRIDES_BINDING_SOURCE - ); - - // If the block has a binding and the attributes do not match their expected - // values, update and merge the attributes. - const mergedAttributes = { - ...attributes, - ...getMismatchedAttributes( attributes, remoteData.results, remoteData.blockName, index ), - }; - - // If multiple blocks are being selected, render it as usual. - if ( hasMultiSelection() ) { - return ; - } - - // If the block is not writable, render it as usual. - if ( isInSyncedPattern && ! hasEnabledOverrides ) { - return ; - } - - // lookup the title of the remote data block - const remoteBlockTitle = getBlockTitle( remoteData?.blockName ); - - return ( - - - - ); - }; -}, 'withBlockBinding' ); - -/** - * Shim for the block binding HOC to be used with the `blocks.registerBlockType` filter. - */ -export function withBlockBindingShim( - settings: BlockConfiguration< RemoteDataInnerBlockAttributes > -): BlockConfiguration< RemoteDataInnerBlockAttributes > { - return { - ...settings, - edit: withBlockBinding( settings.edit ?? ( () => null ) ), - }; -} diff --git a/src/block-editor/index.ts b/src/block-editor/index.ts index f71937de3..cd07bb273 100644 --- a/src/block-editor/index.ts +++ b/src/block-editor/index.ts @@ -1,3 +1,2 @@ import '@/block-editor/binding-sources/remote-data-binding'; -import '@/block-editor/filters'; import '@/block-editor/format-types/inline-binding'; diff --git a/src/blocks/remote-data-container/components/BlockBindingControls.tsx b/src/blocks/remote-data-container/components/BlockBindingControls.tsx index 26821781c..9ee8cd8d6 100644 --- a/src/blocks/remote-data-container/components/BlockBindingControls.tsx +++ b/src/blocks/remote-data-container/components/BlockBindingControls.tsx @@ -1,3 +1,4 @@ +import { useBlockBindingsUtils } from '@wordpress/block-editor'; import { CheckboxControl, SelectControl } from '@wordpress/components'; import { @@ -9,6 +10,7 @@ import { TEXT_FIELD_TYPES, } from '@/blocks/remote-data-container/config/constants'; import { sendTracksEvent } from '@/blocks/remote-data-container/utils/tracks'; +import { BLOCK_BINDING_SOURCE } from '@/config/constants'; import { getBlockDataSourceType } from '@/utils/localized-block-data'; interface BlockBindingFieldControlProps { @@ -17,7 +19,7 @@ interface BlockBindingFieldControlProps { label: string; target: string; updateFieldBinding: ( target: string, field: string ) => void; - value: string; + value?: string; } export function BlockBindingFieldControl( props: BlockBindingFieldControlProps ) { @@ -43,23 +45,37 @@ export function BlockBindingFieldControl( props: BlockBindingFieldControlProps ) } interface BlockBindingControlsProps { - attributes: RemoteDataInnerBlockAttributes; + args?: RemoteDataBlockBindingArgs; availableBindings: AvailableBindings; blockName: string; remoteDataName: string; - removeBinding: ( target: string ) => void; - updateBinding: ( target: string, args: Omit< RemoteDataBlockBindingArgs, 'block' > ) => void; } export function BlockBindingControls( props: BlockBindingControlsProps ) { - const { attributes, availableBindings, blockName, remoteDataName, removeBinding, updateBinding } = - props; - const contentArgs = attributes.metadata?.bindings?.content?.args; - const contentField = contentArgs?.field ?? ''; - const imageAltField = attributes.metadata?.bindings?.alt?.args?.field ?? ''; - const imageUrlField = attributes.metadata?.bindings?.url?.args?.field ?? ''; - const buttonUrlField = attributes.metadata?.bindings?.url?.args?.field ?? ''; - const buttonTextField = attributes.metadata?.bindings?.text?.args?.field ?? ''; + const { args, availableBindings, blockName, remoteDataName } = props; + + const { updateBlockBindings } = useBlockBindingsUtils(); + + function removeBinding( target: string ): void { + updateBlockBindings( { + [ target ]: undefined, + } ); + } + + function updateBinding( + target: string, + newArgs: Omit< RemoteDataBlockBindingArgs, 'block' > + ): void { + updateBlockBindings( { + [ target ]: { + source: BLOCK_BINDING_SOURCE, + args: { + ...newArgs, + block: remoteDataName, + }, + }, + } ); + } function updateFieldBinding( target: string, field: string ): void { if ( ! field ) { @@ -69,11 +85,9 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { data_source_type: getBlockDataSourceType( remoteDataName ), block_target_attribute: target, } ); - return; } - const args = attributes.metadata?.bindings?.[ target ]?.args ?? {}; updateBinding( target, { ...args, field } ); sendTracksEvent( 'remote_data_container_actions', { action: 'update_binding', @@ -84,15 +98,14 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { } function updateFieldLabel( showLabel: boolean ): void { - if ( ! contentField ) { - // Form input should be disabled in this state, but check anyway. + if ( ! args?.field ) { return; } const label = showLabel - ? Object.entries( availableBindings ).find( ( [ key ] ) => key === contentField )?.[ 1 ]?.name + ? Object.entries( availableBindings ).find( ( [ key ] ) => key === args?.field )?.[ 1 ]?.name : undefined; - updateBinding( 'content', { ...contentArgs, field: contentField, label } ); + updateBinding( 'content', { ...args, label } ); sendTracksEvent( 'remote_data_container_actions', { action: showLabel ? 'show_label' : 'hide_label', data_source_type: getBlockDataSourceType( remoteDataName ), @@ -110,11 +123,11 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { label="Content" target="content" updateFieldBinding={ updateFieldBinding } - value={ contentField } + value={ args?.field } /> ); @@ -152,7 +165,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { label="Button URL" target="url" updateFieldBinding={ updateFieldBinding } - value={ buttonUrlField } + value={ args?.field } /> ); @@ -174,7 +187,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { label="Raw HTML" target="content" updateFieldBinding={ updateFieldBinding } - value={ contentField } + value={ args?.field } /> ); diff --git a/src/blocks/remote-data-template/components/loop-template/LoopTemplate.tsx b/src/blocks/remote-data-template/components/loop-template/LoopTemplate.tsx index 0cf17e03d..087c25975 100644 --- a/src/blocks/remote-data-template/components/loop-template/LoopTemplate.tsx +++ b/src/blocks/remote-data-template/components/loop-template/LoopTemplate.tsx @@ -4,24 +4,31 @@ import { useBlockEditContext, } from '@wordpress/block-editor'; import { BlockInstance } from '@wordpress/blocks'; -import { useSelect } from '@wordpress/data'; -import { useState } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { Fragment, useState } from '@wordpress/element'; import { ItemPreview } from '@/blocks/remote-data-template/components/item-preview/ItemPreview'; import { LoopTemplateInnerBlocks } from '@/blocks/remote-data-template/components/loop-template/LoopTemplateInnerBlocks'; -import { PreviewIndexContext } from '@/blocks/remote-data-template/context/PreviewIndexContext'; +import { STORE_NAME as remoteDataBlocksStore } from '@/config/constants'; + +import type { ActionCreators } from '@/store'; interface LoopTemplateProps { getInnerBlocks: ( - result: RemoteDataApiResult + result: RemoteDataApiResult, + index: number ) => BlockInstance< RemoteDataInnerBlockAttributes >[]; remoteData: RemoteData; } export function LoopTemplate( props: LoopTemplateProps ) { - const [ activeBlockIndex, setActiveBlockIndex ] = useState< number >( 0 ); const { getInnerBlocks, remoteData } = props; + // Use local state instead of selecting the preview index from the store so + // that re-renders are limited to this component only. + const [ activeBlockIndex, setActiveBlockIndex ] = useState< number >( 0 ); + const { setPreviewIndex } = useDispatch< ActionCreators >( remoteDataBlocksStore, [] ); + // Hammer approach, forces re-render of the whole loop when user input is detected. const { clientId } = useBlockEditContext(); useSelect< BlockEditorStoreSelectors, BlockInstance[] >( @@ -29,6 +36,11 @@ export function LoopTemplate( props: LoopTemplateProps ) { [ clientId ] ); + function onSelect( index: number ): void { + setActiveBlockIndex( index ); + setPreviewIndex( remoteData.resultId, index ); + } + // To avoid flicker when switching active block contexts, a preview is rendered // for each block context, but the preview for the active block context is hidden. // This ensures that when it is displayed again, the cached rendering of the @@ -39,14 +51,14 @@ export function LoopTemplate( props: LoopTemplateProps ) { { remoteData.results.map( ( result, index ) => { const isActive = index === activeBlockIndex; return ( - + setActiveBlockIndex( index ) } + onSelect={ () => onSelect( index ) } /> - + ); } ) } diff --git a/src/blocks/remote-data-template/context/PreviewIndexContext.ts b/src/blocks/remote-data-template/context/PreviewIndexContext.ts deleted file mode 100644 index 19103a763..000000000 --- a/src/blocks/remote-data-template/context/PreviewIndexContext.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from '@wordpress/element'; - -export const PreviewIndexContext = createContext< number >( 0 ); diff --git a/src/blocks/remote-data-template/edit.tsx b/src/blocks/remote-data-template/edit.tsx index 6bff5c997..e42ee3380 100644 --- a/src/blocks/remote-data-template/edit.tsx +++ b/src/blocks/remote-data-template/edit.tsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useBlockProps } from '@wordpress/block-editor'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; import { BlockEditProps } from '@wordpress/blocks'; import { Placeholder } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -15,6 +15,7 @@ import './editor.scss'; export function Edit( props: BlockEditProps< RemoteDataTemplateBlockAttributes > ): JSX.Element { const { clientId, context, name } = props; const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps(); const { remoteData } = useRemoteDataContext( context ); const getInnerBlocks = useGetInnerBlocks( name, clientId, remoteData?.blockName ); @@ -38,5 +39,9 @@ export function Edit( props: BlockEditProps< RemoteDataTemplateBlockAttributes > return
; } + if ( 1 === remoteData.results.length ) { + return
; + } + return ; } diff --git a/src/blocks/remote-data-template/filters/index.ts b/src/blocks/remote-data-template/filters/index.ts deleted file mode 100644 index 5190990d5..000000000 --- a/src/blocks/remote-data-template/filters/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { addFilter } from '@wordpress/hooks'; - -import { withPreviewIndex } from './withPreviewIndex'; - -/** - * Use a filter to wrap the block edit component and inject the preview index - * when we are rendering the template block for collections. - */ -addFilter( 'editor.BlockEdit', 'remote-data-blocks/withPreviewIndex', withPreviewIndex ); diff --git a/src/blocks/remote-data-template/filters/withPreviewIndex.tsx b/src/blocks/remote-data-template/filters/withPreviewIndex.tsx deleted file mode 100644 index b9d7e6318..000000000 --- a/src/blocks/remote-data-template/filters/withPreviewIndex.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { BlockEditProps } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { useContext } from '@wordpress/element'; - -import { PreviewIndexContext } from '../context/PreviewIndexContext'; - -export const withPreviewIndex = createHigherOrderComponent( BlockEdit => { - return ( props: BlockEditProps< RemoteDataInnerBlockAttributes > ) => { - const previewIndex = useContext( PreviewIndexContext ); - return ; - }; -}, 'withPreviewIndex' ); diff --git a/src/blocks/remote-data-template/hooks/useGetInnerBlocks.ts b/src/blocks/remote-data-template/hooks/useGetInnerBlocks.ts index bacf38bf4..26d0a5849 100644 --- a/src/blocks/remote-data-template/hooks/useGetInnerBlocks.ts +++ b/src/blocks/remote-data-template/hooks/useGetInnerBlocks.ts @@ -15,9 +15,12 @@ export function useGetInnerBlocks( [ blockName, clientId ], ] ); - return ( result: RemoteDataApiResult ): BlockInstance< RemoteDataInnerBlockAttributes >[] => { + return ( + result: RemoteDataApiResult, + previewIndex: number + ): BlockInstance< RemoteDataInnerBlockAttributes >[] => { return getBlocks( clientId ).map( block => - cloneBlockForPreview( block, result, remoteDataBlockName ?? blockName ) + cloneBlockForPreview( block, result, remoteDataBlockName ?? blockName, previewIndex ) ); }; } diff --git a/src/blocks/remote-data-template/index.ts b/src/blocks/remote-data-template/index.ts index 36c3263b4..baad2b7ef 100644 --- a/src/blocks/remote-data-template/index.ts +++ b/src/blocks/remote-data-template/index.ts @@ -3,7 +3,6 @@ import { layout } from '@wordpress/icons'; import metadata from './block.json'; import { Edit } from './edit'; -import './filters'; import { Save } from './save'; registerBlockType< RemoteDataTemplateBlockAttributes >( metadata.name, { diff --git a/src/blocks/remote-html/edit.tsx b/src/blocks/remote-html/edit.tsx index 2206886c3..354c6273d 100644 --- a/src/blocks/remote-html/edit.tsx +++ b/src/blocks/remote-html/edit.tsx @@ -11,7 +11,6 @@ import { import { BlockEditProps } from '@wordpress/blocks'; import { SandBox, Placeholder } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; -import { useEffect } from '@wordpress/element'; import { __ } from '@/utils/i18n'; @@ -33,28 +32,10 @@ const DEFAULT_STYLES = ` } `; -interface RemoteHtmlAttributes extends RemoteDataInnerBlockAttributes { - saveContent?: string; -} - -export function Edit( props: BlockEditProps< RemoteHtmlAttributes > ): JSX.Element { - const { attributes, setAttributes, isSelected } = props; +export function Edit( props: BlockEditProps< RemoteDataInnerBlockAttributes > ): JSX.Element { + const { attributes, isSelected } = props; const blockProps = useBlockProps(); - - // HACK: - // The remote data binding passes merged remote attributes into the Edit component, - // but the Save component does not receive the same augmented attributes. In order to - // persist fallback content during save, we need to store the content in an attribute - // (saveContent) and call setAttributes to expose it to Save. - // - // This should be removed once we replace mergedAttributes/getMismatchedAttributes() - // with a getValues() implementation. const { content } = attributes; - useEffect( () => { - if ( content !== undefined ) { - setAttributes( { saveContent: content.toString() } ); - } - }, [ content, setAttributes ] ); const settingStyles = useSelect< BlockEditorStoreSelectors, EditorStyle[] >( select => select( blockEditorStore ).getSettings().styles, diff --git a/src/blocks/remote-html/save.tsx b/src/blocks/remote-html/save.tsx index 27665424c..91d58a60d 100644 --- a/src/blocks/remote-html/save.tsx +++ b/src/blocks/remote-html/save.tsx @@ -3,13 +3,8 @@ import { RawHTML } from '@wordpress/element'; interface RemoteDataHTMLSaveAttributes { content?: string | StringSeriablizable; - saveContent?: string | StringSeriablizable; } export function Save( props: BlockSaveProps< RemoteDataHTMLSaveAttributes > ) { - const { attributes } = props; - - const content = attributes?.saveContent ?? attributes?.content ?? ''; - - return { content?.toString() }; + return { props.attributes.content?.toString() ?? '' }; } diff --git a/src/config/constants.ts b/src/config/constants.ts index acf7a9a81..238c8abdf 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -2,4 +2,5 @@ export const BLOCK_BINDING_SOURCE = 'remote-data/binding'; export const PATTERN_BLOCK_TYPE_POST_META_KEY: string = '_remote_data_blocks_block_type'; // Must match PatternEditor::$block_type_meta_key. export const PATTERN_OVERRIDES_BINDING_SOURCE = 'core/pattern-overrides'; export const PATTERN_OVERRIDES_CONTEXT_KEY = 'pattern/overrides'; +export const STORE_NAME = 'remote-data-blocks/store'; export const TEXT_DOMAIN = 'remote-data-blocks'; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..d5e82540c --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,67 @@ +import { createReduxStore, register, type StoreDescriptor } from '@wordpress/data'; + +import { STORE_NAME } from '@/config/constants'; + +import type { ActionCreator, ReduxStoreConfig } from '@wordpress/data/build-types/types'; + +interface State { + previewIndexes: Map< string, number >; +} + +interface SetPreviewIndexAction { + type: 'SET_PREVIEW_INDEX'; + payload: { + resultsId: string; + index: number; + }; +} + +type ActionPayload = SetPreviewIndexAction; + +export interface ActionCreators extends Record< string, ActionCreator > { + setPreviewIndex: ( resultsId: string, index: number ) => SetPreviewIndexAction; +} + +export interface Selectors { + getPreviewIndex: ( resultsId: string ) => number; +} + +const actionCreators: ActionCreators = { + setPreviewIndex( resultsId: string, index: number ): SetPreviewIndexAction { + return { + type: 'SET_PREVIEW_INDEX', + payload: { resultsId, index }, + }; + }, +}; + +const selectors = { + getPreviewIndex( state: State = INITIAL_STATE, resultsId: string ): number { + return state.previewIndexes?.get( resultsId ) ?? 0; + }, +}; + +const INITIAL_STATE: State = { + previewIndexes: new Map(), +}; + +const remoteDataBlocksStoreConfig: ReduxStoreConfig< State, ActionCreators, typeof selectors > = { + actions: actionCreators, + reducer: ( state: State = INITIAL_STATE, action: ActionPayload ) => { + switch ( action.type ) { + case 'SET_PREVIEW_INDEX': + return { + ...state, + previewIndexes: new Map( state.previewIndexes ).set( + action.payload.resultsId, + action.payload.index + ), + }; + } + }, + selectors, +}; + +const store = createReduxStore( STORE_NAME, remoteDataBlocksStoreConfig ); + +( register as ( storeDescriptor: StoreDescriptor ) => void )( store ); diff --git a/src/utils/block-binding.ts b/src/utils/block-binding.ts index a282094a9..9cc37a111 100644 --- a/src/utils/block-binding.ts +++ b/src/utils/block-binding.ts @@ -1,64 +1,56 @@ import { cloneBlock } from '@wordpress/blocks'; import { BLOCK_BINDING_SOURCE } from '@/config/constants'; -import { getRemoteDataResultValue } from '@/utils/remote-data'; import { getClassName } from '@/utils/string'; -import { isObjectWithStringKeys } from '@/utils/type-narrowing'; import type { BlockPattern } from '@wordpress/block-editor'; import type { BlockInstance } from '@wordpress/blocks'; /** * Clone a block and inject remote data so that it can be previewed, either for - * a pattern preview or a template preview. + * a pattern preview or a template preview. Template previews may be previewing + * a specific result from the result set, so a preview index can be provided. */ export function cloneBlockForPreview( block: BlockInstance< RemoteDataInnerBlockAttributes >, result: RemoteDataApiResult, - remoteDataBlockName: string + remoteDataBlockName: string, + previewIndex?: number ): BlockInstance { const newInnerBlocks = block.innerBlocks?.map( innerBlock => - cloneBlockForPreview( innerBlock, result, remoteDataBlockName ) - ); - - const mismatchedAttributes = getMismatchedAttributes( - block.attributes, - [ result ], - remoteDataBlockName + cloneBlockForPreview( innerBlock, result, remoteDataBlockName, previewIndex ) ); - return cloneBlock( block, mismatchedAttributes, newInnerBlocks ); -} - -function getAttributeValue( attributes: unknown, key: string | undefined | null ): string { - if ( ! key || ! isObjectWithStringKeys( attributes ) ) { - return ''; - } - - // This .toString() call is important to handle RichTextData objects. We may - // set the attribute value as a string, but once loaded by the editor, it will - // be a RichTextData object. Currently, .toString() proxies to .toHTMLString(): - // - // https://github.com/WordPress/gutenberg/blob/7bca2fadddde7b2b2f62823b8a4b81378f117412/packages/rich-text/src/create.js#L157 - return attributes[ key ]?.toString() ?? ''; -} - -function getExpectedAttributeValue( - result?: RemoteDataApiResult, - args?: RemoteDataBlockBindingArgs -): string | null { - if ( ! args?.field || ! result?.result?.[ args.field ] ) { - return null; - } - - // See comment on toString() in getAttributeValue. - let expectedValue = getRemoteDataResultValue( result, args.field ); - if ( args.label ) { - const labelClass = getClassName( 'block-label' ); - expectedValue = `${ args.label } ${ expectedValue }`; + let previewArgs = {}; + if ( undefined !== previewIndex ) { + previewArgs = { isPreview: true, previewIndex }; } - return expectedValue; + const clonedAttributes = { + ...block.attributes, + metadata: { + ...block.attributes.metadata, + bindings: { + ...block.attributes.metadata?.bindings, + ...Object.fromEntries( + getBoundAttributeEntries( block.attributes, remoteDataBlockName ).map( + ( [ target, binding ] ) => [ + target, + { + ...binding, + args: { + ...binding.args, + ...previewArgs, + }, + }, + ] + ) + ), + }, + }, + }; + + return cloneBlock( block, clonedAttributes, newInnerBlocks ); } export function getBoundAttributeEntries( @@ -88,24 +80,6 @@ export function getBoundBlockClassName( return Array.from( classNames.values() ).filter( Boolean ).join( ' ' ); } -export function getMismatchedAttributes( - attributes: RemoteDataInnerBlockAttributes, - results: RemoteDataApiResult[], - remoteDataBlockName: string, - index = 0 -): Partial< RemoteDataInnerBlockAttributes > { - return Object.fromEntries( - getBoundAttributeEntries( attributes, remoteDataBlockName ) - .map( ( [ target, binding ] ) => [ - target, - getExpectedAttributeValue( results[ index ], binding.args ), - ] ) - .filter( - ( [ target, value ] ) => null !== value && value !== getAttributeValue( attributes, target ) - ) - ) as Partial< RemoteDataInnerBlockAttributes >; -} - /** * Recursively determine if a block or its inner blocks have any block bindings. */ diff --git a/src/utils/type-narrowing.ts b/src/utils/type-narrowing.ts deleted file mode 100644 index 32edb84a9..000000000 --- a/src/utils/type-narrowing.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/src/block-editor/filters/withBlockBinding.test.tsx b/tests/src/block-editor/filters/withBlockBinding.test.tsx deleted file mode 100644 index 209a74262..000000000 --- a/tests/src/block-editor/filters/withBlockBinding.test.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { useSelect } from '@wordpress/data'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { withBlockBinding } from '@/block-editor/filters/withBlockBinding'; -import { REMOTE_DATA_CONTEXT_KEY } from '@/blocks/remote-data-container/config/constants'; -import { BLOCK_BINDING_SOURCE, PATTERN_OVERRIDES_CONTEXT_KEY } from '@/config/constants'; -import { createRemoteDataResults as createResults } from '@/utils/remote-data'; - -vi.mock( '@/blocks/remote-data-container/utils/tracks', () => ( { - sendTracksEvent: vi.fn(), -} ) ); - -// Minimal mocking for WordPress dependencies -vi.mock( '@wordpress/block-editor', () => ( { - InspectorControls: ( { children }: { children: React.ReactNode } ) => ( -
{ children }
- ), - store: {}, -} ) ); - -vi.mock( '@wordpress/components', () => ( { - PanelBody: ( { children, title }: { children: React.ReactNode; title: string } ) => ( -
- { children } -
- ), -} ) ); - -vi.mock( '@wordpress/data' ); - -vi.mock( '@/hooks/useEditedPostAttribute', () => ( { - useEditedPostAttribute: () => ( { - postType: '', - } ), -} ) ); - -describe( 'withBlockBinding', () => { - const hasMultiSelection = vi.fn().mockReturnValue( false ); - const MockBlockEdit = vi.fn( () =>
); - const WrappedComponent = withBlockBinding( MockBlockEdit ); - const testBlockConfig: LocalizedBlockData = { - config: { - 'test/block': { - availableBindings: { field1: { name: 'Field 1', type: 'string' } }, - availableOverrides: [], - dataSourceType: 'test-source', - name: 'test/block', - patterns: { default: 'test/block/pattern' }, - selectors: [], - settings: { - category: 'widget', - title: 'Test block', - }, - }, - }, - rest_url: 'http://example.com/wp-json', - }; - - beforeEach( () => { - vi.useFakeTimers(); - vi.mocked( useSelect ).mockImplementation( () => ( { hasMultiSelection } ) ); - window.REMOTE_DATA_BLOCKS = testBlockConfig; - } ); - - afterEach( () => { - vi.useRealTimers(); - hasMultiSelection.mockClear(); - MockBlockEdit.mockClear(); - cleanup(); - } ); - - it( 'renders original BlockEdit when no remote data is available', () => { - render( - {} } - clientId="test-client-id" - isSelected={ false } - className="" - /> - ); - - expect( hasMultiSelection ).not.toHaveBeenCalled(); - expect( screen.getByTestId( 'mock-block-edit' ) ).toBeDefined(); - expect( screen.queryByTestId( 'inspector-controls' ) ).toBeNull(); - } ); - - it( 'renders original BlockEdit when multiple blocks are selected', () => { - hasMultiSelection.mockReturnValueOnce( true ); - - const remoteData = { - blockName: 'test/block', - results: createResults( [ { field1: 'value1' } ] ), - }; - - render( - {} } - clientId="test-client-id" - isSelected={ false } - className="" - /> - ); - - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( screen.getByTestId( 'mock-block-edit' ) ).toBeDefined(); - expect( screen.queryByTestId( 'inspector-controls' ) ).toBeNull(); - } ); - - it( 'renders BoundBlockEdit when remote data is available', async () => { - const remoteData = { - blockName: 'test/block', - results: createResults( [ { field1: 'value1' } ] ), - }; - render( - {} } - clientId="test-client-id" - isSelected={ false } - className="" - /> - ); - - await vi.runAllTimersAsync(); - - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( screen.getByTestId( 'mock-block-edit' ) ).toBeDefined(); - expect( screen.getByTestId( 'inspector-controls' ) ).toBeDefined(); - expect( screen.getByTestId( 'panel-body' ) ).toBeDefined(); - } ); - - it( 'does not render BoundBlockEdit for synced pattern without enabled overrides', () => { - const remoteData = { - blockName: 'test/block', - results: createResults( [ { field1: 'value1' } ] ), - }; - render( - {} } - clientId="test-client-id" - isSelected={ false } - className="" - /> - ); - - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( screen.getByTestId( 'mock-block-edit' ) ).toBeDefined(); - expect( screen.queryByTestId( 'inspector-controls' ) ).toBeNull(); - } ); - - it( 'updates attributes when mismatches are found', () => { - const mockSetAttributes = vi.fn(); - const props = { - attributes: { - content: 'Old Title', - metadata: { - bindings: { - content: { - source: BLOCK_BINDING_SOURCE, - args: { block: 'test/block', field: 'title' }, - }, - }, - }, - }, - context: { - [ REMOTE_DATA_CONTEXT_KEY ]: { - blockName: 'test/block', - results: createResults( [ { title: 'New Title' } ] ), - }, - }, - name: 'test/block', - setAttributes: mockSetAttributes, - clientId: 'test-client-id', - isSelected: false, - className: '', - }; - - render( ); - - expect( MockBlockEdit ).toHaveBeenCalledTimes( 1 ); - expect( MockBlockEdit ).toHaveBeenCalledWith( - { - ...props, - attributes: { ...props.attributes, content: 'New Title' }, - }, - {} - ); - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( mockSetAttributes ).not.toHaveBeenCalled(); - } ); - - it( 'does not update attributes when no mismatches are found', () => { - const mockSetAttributes = vi.fn(); - const props = { - attributes: { - content: 'Matching Title', - metadata: { - bindings: { - content: { - source: BLOCK_BINDING_SOURCE, - args: { block: 'test/block', field: 'title' }, - }, - }, - }, - }, - context: { - [ REMOTE_DATA_CONTEXT_KEY ]: { - blockName: 'test/block', - results: createResults( [ { title: 'Matching Title' } ] ), - }, - }, - name: 'test/block', - setAttributes: mockSetAttributes, - clientId: 'test-client-id', - isSelected: false, - className: '', - }; - - render( ); - - expect( MockBlockEdit ).toHaveBeenCalledTimes( 1 ); - expect( MockBlockEdit ).toHaveBeenCalledWith( props, {} ); - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( mockSetAttributes ).not.toHaveBeenCalled(); - } ); - - it( 'updates attributes even when binding ui is hidden', () => { - const mockSetAttributes = vi.fn(); - const props = { - attributes: { - content: 'Old Title', - metadata: { - bindings: { - content: { - source: BLOCK_BINDING_SOURCE, - args: { block: 'test/block', field: 'title' }, - }, - }, - }, - }, - context: { - [ REMOTE_DATA_CONTEXT_KEY ]: { - blockName: 'test/block', - results: createResults( [ { title: 'New Title' } ] ), - }, - }, - name: 'test/block', - setAttributes: mockSetAttributes, - clientId: 'test-client-id', - isSelected: false, - className: '', - }; - - hasMultiSelection.mockReturnValueOnce( true ); - - render( ); - - expect( MockBlockEdit ).toHaveBeenCalledTimes( 1 ); - expect( MockBlockEdit ).toHaveBeenCalledWith( - { - ...props, - attributes: { ...props.attributes, content: 'New Title' }, - }, - {} - ); - expect( hasMultiSelection ).toHaveBeenCalledTimes( 1 ); - expect( mockSetAttributes ).not.toHaveBeenCalled(); - } ); -} ); diff --git a/tests/src/blocks/remote-data-template/components/loop-template/LoopTemplate.test.tsx b/tests/src/blocks/remote-data-template/components/loop-template/LoopTemplate.test.tsx index 765392761..4259b7fdc 100644 --- a/tests/src/blocks/remote-data-template/components/loop-template/LoopTemplate.test.tsx +++ b/tests/src/blocks/remote-data-template/components/loop-template/LoopTemplate.test.tsx @@ -1,8 +1,13 @@ import { cleanup, render } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { LoopTemplate } from '@/blocks/remote-data-template/components/loop-template/LoopTemplate'; +vi.mock( '@wordpress/data', () => ( { + useDispatch: vi.fn( () => ( { setPreviewIndex: vi.fn() } ) ), + useSelect: vi.fn(), +} ) ); + describe( 'LoopTemplate', () => { const mockGetInnerBlocks = () => []; const mockRemoteData: RemoteData = { @@ -32,7 +37,7 @@ describe( 'LoopTemplate', () => { afterEach( cleanup ); - it( 'renders a list when there are results', () => { + it( 'renders a list when there are more than one result', () => { const { container } = render( ); diff --git a/tests/src/utils/block-binding.test.ts b/tests/src/utils/block-binding.test.ts index 4f52df9d4..f65c1fcf0 100644 --- a/tests/src/utils/block-binding.test.ts +++ b/tests/src/utils/block-binding.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BLOCK_BINDING_SOURCE } from '@/config/constants'; -import { getBoundAttributeEntries, getMismatchedAttributes } from '@/utils/block-binding'; -import { createRemoteDataResults as createResults } from '@/utils/remote-data'; +import { getBoundAttributeEntries } from '@/utils/block-binding'; describe( 'block-binding utils', () => { describe( 'getBoundAttributeEntries', () => { @@ -35,138 +34,4 @@ describe( 'block-binding utils', () => { expect( result ).toEqual( [] ); } ); } ); - - describe( 'getMismatchedAttributes', () => { - it( 'should return mismatched attributes', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: 'Old content', - url: 'https://old-url.com', - alt: 'Old alt', - metadata: { - bindings: { - content: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'title' } }, - url: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'link' } }, - }, - }, - }; - - const results = createResults( [ { title: 'New content', link: 'https://new-url.com' } ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( { - content: 'New content', - url: 'https://new-url.com', - } ); - } ); - - it( 'should return an empty object when no mismatches are found', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: 'Title Current content', - url: 'https://current-url.com', - metadata: { - bindings: { - content: { - source: BLOCK_BINDING_SOURCE, - args: { block, field: 'title', label: 'Title' }, - }, - url: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'link' } }, - }, - }, - }; - - const results = createResults( [ - { title: 'Current content', link: 'https://current-url.com' }, - ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( {} ); - } ); - - it( 'should handle missing results', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: 'Old content', - url: 'https://old-url.com', - metadata: { - bindings: { - content: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'title' } }, - url: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'link' } }, - }, - }, - }; - - const results = createResults( [ { title: 'New content' } ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( { - content: 'New content', - } ); - } ); - - it( 'should handle missing label', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: 'My Title', - metadata: { - bindings: { - content: { - source: BLOCK_BINDING_SOURCE, - args: { block, field: 'title', label: 'Title' }, - }, - }, - }, - }; - - const results = createResults( [ { title: 'My Title' } ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( { - content: 'Title My Title', - } ); - } ); - - it( 'should handle mismatched types', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: '123', - metadata: { - bindings: { - content: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'a_field' } }, - }, - }, - }; - - const results = createResults( [ { a_field: 123 } ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( {} ); - } ); - - it( 'should handle convert mismatched attributes to string', () => { - const block = 'test/block'; - const attributes: RemoteDataInnerBlockAttributes = { - content: '123', - metadata: { - bindings: { - content: { source: BLOCK_BINDING_SOURCE, args: { block, field: 'a_field' } }, - }, - }, - }; - - const results = createResults( [ { a_field: 1234 } ] ); - - const result = getMismatchedAttributes( attributes, results, block ); - - expect( result ).toEqual( { - content: '1234', - } ); - } ); - } ); } ); diff --git a/tests/src/utils/type-narrowing.test.ts b/tests/src/utils/type-narrowing.test.ts deleted file mode 100644 index d6fbee405..000000000 --- a/tests/src/utils/type-narrowing.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { isObjectWithStringKeys, removeNullValuesFromObject } from '@/utils/type-narrowing'; - -describe( 'type-narrowing utils', () => { - describe( 'isObjectWithStringKeys', () => { - it( 'should return true for objects with string keys', () => { - expect( isObjectWithStringKeys( { a: 1, b: 'two' } ) ).toBe( true ); - expect( isObjectWithStringKeys( { 'key-with-dash': true } ) ).toBe( true ); - expect( isObjectWithStringKeys( {} ) ).toBe( true ); - } ); - - it( 'should return false for arrays', () => { - expect( isObjectWithStringKeys( [] ) ).toBe( false ); - expect( isObjectWithStringKeys( [ 1, 2, 3 ] ) ).toBe( false ); - } ); - - it( 'should return false for null', () => { - expect( isObjectWithStringKeys( null ) ).toBe( false ); - } ); - - it( 'should return false for primitive types', () => { - expect( isObjectWithStringKeys( 'string' ) ).toBe( false ); - expect( isObjectWithStringKeys( 123 ) ).toBe( false ); - expect( isObjectWithStringKeys( true ) ).toBe( false ); - expect( isObjectWithStringKeys( undefined ) ).toBe( false ); - } ); - - it( 'should return false for functions', () => { - expect( isObjectWithStringKeys( () => {} ) ).toBe( false ); - } ); - - it( 'should return true for objects with symbol keys', () => { - const obj = { [ Symbol( 'test' ) ]: 'value' }; - 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 8364b6ca1..ce1d877c3 100644 --- a/types/remote-data.d.ts +++ b/types/remote-data.d.ts @@ -68,6 +68,8 @@ interface RemoteDataBlockBindingArgs { block: string; field: string; label?: string; + isPreview?: boolean; + previewIndex?: number; } interface RemoteDataBlockBinding { diff --git a/types/wordpress__block-editor/index.d.ts b/types/wordpress__block-editor/index.d.ts index d3f4df97b..9fed2ca2d 100644 --- a/types/wordpress__block-editor/index.d.ts +++ b/types/wordpress__block-editor/index.d.ts @@ -9,6 +9,12 @@ import { ReactElement } from '@wordpress/element/build-types/serialize'; declare module '@wordpress/block-editor' { function BlockContextProvider( props: { children: ReactElement; value: object } ): JSX.Element; + function useBlockBindingsUtils( clientId?: string ): { + updateBlockBindings: ( + bindings: Record< string, { args: object; source: string } | undefined > + ) => void; + }; + function useBlockEditContext(): { clientId: string; [ key: string ]: unknown; @@ -47,6 +53,7 @@ declare module '@wordpress/block-editor' { getBlocksByClientId: < T extends BlockAttributes >( clientId: string ) => BlockInstance< T >[]; getBlocksByName: ( name: string ) => string[]; getPatternsByBlockTypes: ( name: string | string[], clientId?: string ) => BlockPattern[]; + getSelectedBlock: < T extends BlockAttributes >() => BlockInstance< T > | undefined; getSettings: () => EditorSettings; hasMultiSelection: () => boolean; } diff --git a/types/wordpress__blocks/index.d.ts b/types/wordpress__blocks/index.d.ts index 38bad373d..746a1ff39 100644 --- a/types/wordpress__blocks/index.d.ts +++ b/types/wordpress__blocks/index.d.ts @@ -9,16 +9,30 @@ import type { Block, BlockEditProps as BlockEditPropsOriginal } from '@wordpress * The types provided by @wordpress/blocks are incomplete. */ -interface GetValuesPayload< Context, Values > { - bindings: Values; - clientId: string; +interface ContextSelectPayload< Context > { context: Context; - select: ( store: BlockEditorStoreDescriptor ) => BlockEditorStoreSelectors; + select: < Selectors >( store: StoreDescriptor ) => Selectors; +} + +interface GetValuesPayload< Context, Binding > extends ContextSelectPayload< Context > { + bindings: Record< string, Binding >; + clientId: string; } -interface SetValuesPayload< Context, Values > extends GetValuesPayload< Context, Values > { +interface SetValuesPayload< Context, Binding > extends GetValuesPayload< Context, Binding > { dispatch: ( store: BlockEditorStoreDescriptor ) => BlockEditorStoreActions; - values: Values; +} + +interface BaseBinding { + args: object; + source: string; +} + +interface BaseItem { + key?: string; + label: string; + type: string; + value?: string; } // Properly allow simplified block registration calls when register_block_type() is already called server-side. @@ -35,18 +49,39 @@ declare module '@wordpress/blocks' { name: string; } - interface BlockBindingsSource< Context = Record< string, unknown >, Values = unknown > { - canUserEditValue?: ( payload: GetValuesPayload< Context, Values > ) => boolean; - getValues?: ( payload: GetValuesPayload< Context, Values > ) => Values; + interface BlockBindingsSource< + Context = Record< string, unknown >, + Binding extends BaseBinding, + Values = Record< string, unknown > + > { + canUserEditValue?: ( payload: ContextSelectPayload< Context > ) => boolean; + editorUI?: < Item extends BaseItem >( + payload: ContextSelectPayload< Context > + ) => + | { + mode: 'dropdown'; + data: Item[]; + getArgs?: ( payload: ItemCallbackPayload< Item, Binding > ) => Binding[ 'args' ]; + isSelected?: ( payload: ItemCallbackPayload< Item, Binding > ) => boolean; + } + | { + mode: 'modal'; + data: Item[]; + renderModalContent: ( payload: { attribute: string } ) => void; + } + | {}; + getValues?: ( payload: GetValuesPayload< Context, Binding > ) => Values; label?: string; name: string; - setValues?: ( payload: SetValuesPayload< Context, Values > ) => void; + setValues?: ( payload: SetValuesPayload< Context, Binding > ) => void; usesContext?: string[]; } - function registerBlockBindingsSource< Context, Values >( - source: BlockBindingsSource< Context, Values > - ): void; + function registerBlockBindingsSource< + Context, + Binding extends BaseBinding, + Values = Record< string, unknown > + >( source: BlockBindingsSource< Context, Binding, Values > ): void; export function registerBlockType< TAttributes extends Record< string, any > = {} >( name: string, diff --git a/webpack.config.js b/webpack.config.js index 8a8f90324..05ce4141b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,7 @@ const additionalScripts = { 'dataviews/index': './src/dataviews/index', 'pattern-editor/index': './src/pattern-editor/index', 'settings/index': './src/settings/index', + 'store/index': './src/store/index', }; const { modernize, moduleConfig, scriptConfig } = require( './webpack.utils' );