Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions assets/src/js/bindings/block-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
useBlockBindingsUtils,
} from '@wordpress/block-editor';
import {
Button,
Notice,
ComboboxControl,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
Expand Down Expand Up @@ -48,8 +50,12 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {

// Get fields based on editor context
const postEditorFields = usePostEditorFields();
const { fields: siteEditorFields } =
useSiteEditorFields( templatePostType );
const {
fields: siteEditorFields,
isLoading: isLoadingSiteEditorFields,
error: siteEditorFieldsError,
refresh: refreshSiteEditorFields,
} = useSiteEditorFields( templatePostType );

// Use appropriate fields based on context
const activeFields = isSiteEditor ? siteEditorFields : postEditorFields;
Expand Down Expand Up @@ -156,6 +162,24 @@ const withCustomControls = createHigherOrderComponent( ( BlockEdit ) => {
) }
resetAll={ handleReset }
>
{ isSiteEditor && siteEditorFieldsError && (
<Notice status="error" isDismissible={ false }>
<p>
{ __(
'Unable to load custom fields. Please try again.',
'secure-custom-fields'
) }
</p>
<Button
variant="secondary"
onClick={ refreshSiteEditorFields }
disabled={ isLoadingSiteEditorFields }
isBusy={ isLoadingSiteEditorFields }
Comment thread
jasalt marked this conversation as resolved.
>
{ __( 'Retry', 'secure-custom-fields' ) }
</Button>
</Notice>
) }
{ canUseAllAttributesMode ? (
<ToolsPanelItem
hasValue={ () =>
Expand Down
96 changes: 43 additions & 53 deletions assets/src/js/bindings/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@
* Custom hooks for block bindings
*/

import { useState, useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';

import { extractPostTypeFromTemplate, formatFieldGroupsData } from './utils';
import { addFieldMetadata } from './fieldMetadataCache';
Expand Down Expand Up @@ -94,63 +92,55 @@ export function usePostEditorFields() {
*
* @since 6.7.0
* @param {string|null} postType The post type to fetch fields for.
* @return {Object} Object containing fields, isLoading, and error.
* @return {Object} Fields, loading/error state, and an explicit refresh callback.
*/
export function useSiteEditorFields( postType ) {
const [ fields, setFields ] = useState( {} );
const [ isLoading, setIsLoading ] = useState( false );
const [ error, setError ] = useState( null );

useEffect( () => {
if ( ! postType ) {
setFields( {} );
setIsLoading( false );
setError( null );
return;
}

let isCancelled = false;
setIsLoading( true );
setError( null );

const fetchFields = async () => {
try {
const path = addQueryArgs( `/wp/v2/types/${ postType }`, {
context: 'edit',
} );

const postTypeData = await apiFetch( { path } );

if ( isCancelled ) {
return;
}
const { invalidateResolution } = useDispatch( coreDataStore );
// Share one getPostType resolution per type within this registry.
// root/postType records default to context=edit, which includes
// scf_field_groups; use the same selector key for status and refresh.
const { record, isLoading, error } = useSelect(
( select ) => {
if ( ! postType ) {
return { record: null, isLoading: false, error: null };
}

const fieldsMap = formatFieldGroupsData(
postTypeData.scf_field_groups
);
const core = select( coreDataStore );

// Store field metadata in the data store
addFieldMetadata( fieldsMap );
return {
record: core.getPostType( postType ),
isLoading: ! core.hasFinishedResolution( 'getPostType', [
postType,
] ),
error:
core.getResolutionError( 'getPostType', [ postType ] ) ||
null,
};
},
[ postType ]
);

setFields( fieldsMap );
setIsLoading( false );
} catch ( err ) {
if ( ! isCancelled ) {
setError( err );
setIsLoading( false );
}
}
};
// Keep the stored fields visible while a refetch is in progress.
const fields = useMemo(
() => formatFieldGroupsData( record?.scf_field_groups ),
[ record ]
);

fetchFields();
useEffect( () => {
if ( record ) {
addFieldMetadata( fields );
}
}, [ record, fields ] );

// Cleanup function to prevent state updates after unmount
return () => {
isCancelled = true;
};
}, [ postType ] );
// One consumer must not cancel work shared by the rest of the registry.
// Explicit invalidation also permits retry without a per-mount retry storm.
const refresh = useCallback( () => {
if ( postType ) {
invalidateResolution( 'getPostType', [ postType ] );
}
}, [ postType, invalidateResolution ] );

return { fields, isLoading, error };
return { fields, isLoading, error, refresh };
}

/**
Expand Down
11 changes: 7 additions & 4 deletions assets/src/js/bindings/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ export function getAllowedFieldTypes( blockName, attribute = null ) {
* Filters field options based on allowed field types.
*
* @since 6.7.0
* @param {Array} fieldOptions Array of field option objects with value, label, and type.
* @param {string} blockName The name of the block.
* @param {string|null} attribute The attribute name, or null for all types.
* @param {Array} fieldOptions Array of field option objects with value, label, and type.
* @param {string} blockName The name of the block.
* @param {string|null} attribute The attribute name, or null for all types.
* @return {Array} Filtered array of field options.
*/
export function getFilteredFieldOptions(
Expand Down Expand Up @@ -142,8 +142,11 @@ export function formatFieldGroupsData( fieldGroups ) {
}

fieldGroups.forEach( ( group ) => {
if ( Array.isArray( group.fields ) ) {
if ( Array.isArray( group?.fields ) ) {
group.fields.forEach( ( field ) => {
if ( ! field?.name ) {
return;
}
fieldsMap[ field.name ] = {
label: field.label,
type: field.type,
Expand Down
120 changes: 120 additions & 0 deletions tests/js/bindings/block-editor-recovery.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Inspector recovery from shared Site Editor schema failures.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { addFilter } from '@wordpress/hooks';
import {
useSiteEditorContext,
useSiteEditorFields,
} from '../../../assets/src/js/bindings/hooks';

jest.mock( '@wordpress/hooks', () => ( { addFilter: jest.fn() } ) );
jest.mock( '@wordpress/compose', () => ( {
createHigherOrderComponent: ( callback ) => callback,
} ) );
jest.mock( '@wordpress/block-editor', () => ( {
InspectorControls: ( { children } ) => <div>{ children }</div>,
useBlockBindingsUtils: () => ( {
updateBlockBindings: jest.fn(),
removeAllBlockBindings: jest.fn(),
} ),
} ) );
jest.mock( '@wordpress/components', () => ( {
Notice: ( { children } ) => <div role="alert">{ children }</div>,
Button: ( { children, onClick, disabled, isBusy } ) => (
<button onClick={ onClick } disabled={ disabled } aria-busy={ isBusy }>
{ children }
</button>
),
ComboboxControl: ( { options } ) => (
<ul>
{ options.map( ( option ) => (
<li key={ option.value }>{ option.label }</li>
) ) }
</ul>
),
__experimentalToolsPanel: ( { children } ) => <div>{ children }</div>,
__experimentalToolsPanelItem: ( { children } ) => <div>{ children }</div>,
} ) );
jest.mock( '../../../assets/src/js/bindings/hooks', () => ( {
useSiteEditorContext: jest.fn(),
useSiteEditorFields: jest.fn(),
usePostEditorFields: () => ( {
field1: { label: 'Field 1', type: 'text' },
} ),
useBoundFields: () => ( { boundFields: {}, setBoundFields: jest.fn() } ),
} ) );

require( '../../../assets/src/js/bindings/block-editor' );
const withControls = addFilter.mock.calls[ 0 ][ 2 ];
const BlockEdit = () => <div>Block content</div>;
const EnhancedBlock = withControls( BlockEdit );
const renderBlock = () => (
<EnhancedBlock name="core/paragraph" attributes={ {} } />
);

describe( 'Site Editor field recovery', () => {
let state;

beforeEach( () => {
state = {
fields: {},
isLoading: false,
error: new Error( 'Private REST error details' ),
refresh: jest.fn(),
};
useSiteEditorFields.mockImplementation( () => state );
useSiteEditorContext.mockReturnValue( {
isSiteEditor: true,
templatePostType: 'product',
} );
} );

it( 'shows a generic error and retries only on user action', () => {
const { rerender } = render( renderBlock() );
expect( screen.getByRole( 'alert' ).textContent ).toContain(
'Unable to load custom fields. Please try again.'
);
expect( screen.queryByText( 'Private REST error details' ) ).toBeNull();
rerender( renderBlock() );
expect( state.refresh ).not.toHaveBeenCalled();
fireEvent.click( screen.getByRole( 'button', { name: 'Retry' } ) );
expect( state.refresh ).toHaveBeenCalledTimes( 1 );
} );

it( 'disables retry while loading and keeps cached field options', () => {
state.isLoading = true;
state.fields = { field1: { label: 'Field 1', type: 'text' } };
render( renderBlock() );
const retry = screen.getByRole( 'button', { name: 'Retry' } );
expect( retry.disabled ).toBe( true );
expect( retry.getAttribute( 'aria-busy' ) ).toBe( 'true' );
fireEvent.click( retry );
expect( state.refresh ).not.toHaveBeenCalled();
expect( screen.getByText( 'Field 1' ) ).toBeTruthy();
} );

it( 'removes the notice and shows field options after successful recovery', () => {
const { rerender } = render( renderBlock() );
fireEvent.click( screen.getByRole( 'button', { name: 'Retry' } ) );
state.error = null;
state.fields = { field1: { label: 'Field 1', type: 'text' } };
rerender( renderBlock() );
expect( screen.queryByRole( 'alert' ) ).toBeNull();
expect( screen.queryByRole( 'button', { name: 'Retry' } ) ).toBeNull();
expect( screen.getByText( 'Field 1' ) ).toBeTruthy();
expect( state.refresh ).toHaveBeenCalledTimes( 1 );
} );

it( 'does not show the Site Editor error in the Post Editor', () => {
useSiteEditorContext.mockReturnValue( {
isSiteEditor: false,
templatePostType: null,
} );
render( renderBlock() );
expect( screen.queryByRole( 'alert' ) ).toBeNull();
expect( screen.queryByRole( 'button', { name: 'Retry' } ) ).toBeNull();
expect( screen.getByText( 'Field 1' ) ).toBeTruthy();
expect( state.refresh ).not.toHaveBeenCalled();
} );
} );
Loading
Loading