diff --git a/packages/block-editor/src/components/block-popover/use-popover-scroll.js b/packages/block-editor/src/components/block-popover/use-popover-scroll.js index 5dfa74005a4081..2d3158e57d0ba7 100644 --- a/packages/block-editor/src/components/block-popover/use-popover-scroll.js +++ b/packages/block-editor/src/components/block-popover/use-popover-scroll.js @@ -17,14 +17,22 @@ function usePopoverScroll( contentRef ) { const effect = useRefEffect( ( node ) => { function onWheel( event ) { - const { deltaX, deltaY } = event; + const { deltaX, deltaY, target } = event; const contentEl = contentRef.current; let scrollContainer = scrollContainerCache.get( contentEl ); if ( ! scrollContainer ) { scrollContainer = getScrollContainer( contentEl ); scrollContainerCache.set( contentEl, scrollContainer ); } - scrollContainer.scrollBy( deltaX, deltaY ); + // Finds a scrollable ancestor of the event’s target. It's not cached because the + // it may not remain scrollable due to popover position changes. The cache is also + // less likely to be utilized because the target may be different every event. + const eventScrollContainer = getScrollContainer( target ); + // Scrolls “through” the popover only if another contained scrollable area isn’t + // in front of it. This is to avoid scrolling both containers simultaneously. + if ( ! node.contains( eventScrollContainer ) ) { + scrollContainer.scrollBy( deltaX, deltaY ); + } } // Tell the browser that we do not call event.preventDefault // See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners diff --git a/packages/block-editor/src/components/block-tools/use-show-block-tools.js b/packages/block-editor/src/components/block-tools/use-show-block-tools.js index 0b36786f54d5cf..533d1ffde4d108 100644 --- a/packages/block-editor/src/components/block-tools/use-show-block-tools.js +++ b/packages/block-editor/src/components/block-tools/use-show-block-tools.js @@ -36,7 +36,7 @@ export function useShowBlockTools() { const hasSelectedBlock = !! clientId && !! block; const isEmptyDefaultBlock = hasSelectedBlock && - isUnmodifiedDefaultBlock( block ) && + isUnmodifiedDefaultBlock( block, 'content' ) && getBlockMode( clientId ) !== 'html'; const _showEmptyBlockSideInserter = clientId && diff --git a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js index de814152c620b0..8e2deccb9ade66 100644 --- a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js +++ b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js @@ -151,7 +151,7 @@ function useInsertionPoint( { if ( ! isAppender && selectedBlock && - isUnmodifiedDefaultBlock( selectedBlock ) + isUnmodifiedDefaultBlock( selectedBlock, 'content' ) ) { replaceBlocks( selectedBlock.clientId, diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 2a4b64f3608874..e83a251ec7e671 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -144,8 +144,6 @@ export const getEnabledClientIdsTree = createRegistrySelector( ( select ) => state.derivedBlockEditingModes, state.derivedNavModeBlockEditingModes, state.blockEditingModes, - state.settings.templateLock, - state.blockListSettings, select( STORE_NAME ).__unstableGetEditorMode( state ), ] ) ); diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 5fc08ce7a37b89..ec82d5192dc6f7 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -2267,6 +2267,12 @@ function getDerivedBlockEditingModesForTree( syncedPatternClientIds.push( clientId ); } } ); + const contentOnlyTemplateLockedClientIds = Object.keys( + state.blockListSettings + ).filter( + ( clientId ) => + state.blockListSettings[ clientId ]?.templateLock === 'contentOnly' + ); traverseBlockTree( state, treeClientId, ( block ) => { const { clientId, name: blockName } = block; @@ -2457,6 +2463,23 @@ function getDerivedBlockEditingModesForTree( derivedBlockEditingModes.set( clientId, 'disabled' ); } } + + // `templateLock: 'contentOnly'` derived modes. + if ( contentOnlyTemplateLockedClientIds.length ) { + const hasContentOnlyTemplateLockedParent = + !! findParentInClientIdsList( + state, + clientId, + contentOnlyTemplateLockedClientIds + ); + if ( hasContentOnlyTemplateLockedParent ) { + if ( isContentBlock( blockName ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + } else { + derivedBlockEditingModes.set( clientId, 'disabled' ); + } + } + } } ); return derivedBlockEditingModes; @@ -2628,6 +2651,75 @@ export function withDerivedBlockEditingModes( reducer ) { } break; } + case 'UPDATE_BLOCK_LIST_SETTINGS': { + // Handle the addition and removal of contentOnly template locked blocks. + const addedBlocks = []; + const removedClientIds = []; + + const updates = + typeof action.clientId === 'string' + ? { [ action.clientId ]: action.settings } + : action.clientId; + + for ( const clientId in updates ) { + const isNewContentOnlyBlock = + state.blockListSettings[ clientId ]?.templateLock !== + 'contentOnly' && + nextState.blockListSettings[ clientId ] + ?.templateLock === 'contentOnly'; + + const wasContentOnlyBlock = + state.blockListSettings[ clientId ]?.templateLock === + 'contentOnly' && + nextState.blockListSettings[ clientId ] + ?.templateLock !== 'contentOnly'; + + if ( isNewContentOnlyBlock ) { + addedBlocks.push( + nextState.blocks.tree.get( clientId ) + ); + } else if ( wasContentOnlyBlock ) { + removedClientIds.push( clientId ); + } + } + + if ( ! addedBlocks.length && ! removedClientIds.length ) { + break; + } + + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } case 'SET_BLOCK_EDITING_MODE': case 'UNSET_BLOCK_EDITING_MODE': case 'SET_HAS_CONTROLLED_INNER_BLOCKS': { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 85ea65e5875d5c..1e18ca232a87fa 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -3076,62 +3076,36 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) { * @return {BlockEditingMode} The block editing mode. One of `'disabled'`, * `'contentOnly'`, or `'default'`. */ -export const getBlockEditingMode = createRegistrySelector( - ( select ) => - ( state, clientId = '' ) => { - // Some selectors that call this provide `null` as the default - // rootClientId, but the default rootClientId is actually `''`. - if ( clientId === null ) { - clientId = ''; - } +export function getBlockEditingMode( state, clientId = '' ) { + // Some selectors that call this provide `null` as the default + // rootClientId, but the default rootClientId is actually `''`. + if ( clientId === null ) { + clientId = ''; + } - const isNavMode = isNavigationMode( state ); - - // If the editor is currently not in navigation mode, check if the clientId - // has an editing mode set in the regular derived map. - // There may be an editing mode set here for synced patterns or in zoomed out - // mode. - if ( - ! isNavMode && - state.derivedBlockEditingModes?.has( clientId ) - ) { - return state.derivedBlockEditingModes.get( clientId ); - } + const isNavMode = isNavigationMode( state ); - // If the editor *is* in navigation mode, the block editing mode states - // are stored in the derivedNavModeBlockEditingModes map. - if ( - isNavMode && - state.derivedNavModeBlockEditingModes?.has( clientId ) - ) { - return state.derivedNavModeBlockEditingModes.get( clientId ); - } + // If the editor is currently not in navigation mode, check if the clientId + // has an editing mode set in the regular derived map. + // There may be an editing mode set here for synced patterns or in zoomed out + // mode. + if ( ! isNavMode && state.derivedBlockEditingModes?.has( clientId ) ) { + return state.derivedBlockEditingModes.get( clientId ); + } - // In normal mode, consider that an explicitly set editing mode takes over. - const blockEditingMode = state.blockEditingModes.get( clientId ); - if ( blockEditingMode ) { - return blockEditingMode; - } + // If the editor *is* in navigation mode, the block editing mode states + // are stored in the derivedNavModeBlockEditingModes map. + if ( isNavMode && state.derivedNavModeBlockEditingModes?.has( clientId ) ) { + return state.derivedNavModeBlockEditingModes.get( clientId ); + } - // In normal mode, top level is default mode. - if ( clientId === '' ) { - return 'default'; - } + // In normal mode, consider that an explicitly set editing mode takes over. + if ( state.blockEditingModes.has( clientId ) ) { + return state.blockEditingModes.get( clientId ); + } - const rootClientId = getBlockRootClientId( state, clientId ); - const templateLock = getTemplateLock( state, rootClientId ); - // If the parent of the block is contentOnly locked, check whether it's a content block. - if ( templateLock === 'contentOnly' ) { - const name = getBlockName( state, clientId ); - const { hasContentRoleAttribute } = unlock( - select( blocksStore ) - ); - const isContent = hasContentRoleAttribute( name ); - return isContent ? 'contentOnly' : 'disabled'; - } - return 'default'; - } -); + return 'default'; +} /** * Indicates if a block is ungroupable. diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index bf0e18c7a24d6a..20b21f2c76dd84 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -504,6 +504,10 @@ describe( 'private selectors', () => { [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'disabled' ], ] ), + derivedBlockEditingModes: new Map( [ + [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], + [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', 'disabled' ], + ] ), blockListSettings: {}, }; expect( diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index b0e67a5de53169..342f978d72ab3b 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -3575,6 +3575,7 @@ describe( 'state', () => { blocks, settings, zoomLevel, + blockListSettings, blockEditingModes, } ) ); @@ -3885,6 +3886,210 @@ describe( 'state', () => { } ); } ); + describe( 'contentOnly template locking', () => { + let initialState; + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + + // Simulates how the editor typically inserts controlled blocks, + // - first the pattern is inserted with no inner blocks. + // - next the pattern is marked as a controlled block. + // - finally, once the inner blocks of the pattern are received, they're inserted. + // This process is repeated for the two patterns in this test. + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'group-1', + settings: { + templateLock: 'contentOnly', + }, + }, + ], + testReducer, + initialState + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns the expected block editing modes for a parent block with contentOnly template locking', () => { + // Only the parent pattern and its own children that have bindings + // are in contentOnly mode. All other blocks are disabled. + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'paragraph-1': 'contentOnly', + 'group-2': 'disabled', + 'paragraph-2': 'contentOnly', + } ) + ) + ); + } ); + + it( 'removes block editing modes when template locking is removed', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'UPDATE_BLOCK_LIST_SETTINGS', + clientId: 'group-1', + settings: { + templateLock: false, + }, + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( new Map() ); + } ); + + it( 'allows explicitly set blockEditingModes to override the contentOnly template locking', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_BLOCK_EDITING_MODE', + clientId: 'group-1', + mode: 'disabled', + }, + { + type: 'SET_BLOCK_EDITING_MODE', + clientId: 'paragraph-2', + mode: 'disabled', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'paragraph-1': 'contentOnly', + 'group-2': 'disabled', + 'paragraph-2': 'contentOnly', + } ) + ) + ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to navigation mode', () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_EDITOR_MODE', + mode: 'navigation', + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + // Group 1 is now a section, so is set to contentOnly. + 'group-1': 'contentOnly', + 'group-2': 'disabled', + 'paragraph-1': 'contentOnly', + 'paragraph-2': 'contentOnly', + } ) + ) + ); + + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to zoomed out mode', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_ZOOM_LEVEL', + zoom: 'auto-scaled', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section. + 'group-2': 'disabled', + 'paragraph-1': 'disabled', + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + } ); + describe( 'navigation mode', () => { let initialState; diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 587e1036e405e2..d5fedb4ea7c0b8 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -4380,92 +4380,9 @@ describe( '__unstableGetClientIdsTree', () => { describe( 'getBlockEditingMode', () => { const baseState = { - settings: {}, - blocks: { - byClientId: new Map( [ - [ - '6cf70164-9097-4460-bcbf-200560546988', - { name: 'core/template-part' }, - ], // Header - [ - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', - { name: 'core/group' }, - ], // Group - [ - 'b26fc763-417d-4f01-b81c-2ec61e14a972', - { name: 'core/post-title' }, - ], // | Post Title - [ - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - { name: 'core/group' }, - ], // | Group - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', { name: 'core/p' } ], // | | Paragraph - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', { name: 'core/p' } ], // | | Paragraph - [ - '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', - { name: 'core/group' }, - ], // | | Group - ] ), - order: new Map( [ - [ - '', - [ - '6cf70164-9097-4460-bcbf-200560546988', - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', - ], - ], - [ '6cf70164-9097-4460-bcbf-200560546988', [] ], - [ - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', - [ - 'b26fc763-417d-4f01-b81c-2ec61e14a972', - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - ], - ], - [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', [] ], - [ - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - [ - 'b3247f75-fd94-4fef-97f9-5bfd162cc416', - 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', - '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', - ], - ], - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', [] ], - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', [] ], - [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', [] ], - ] ), - parents: new Map( [ - [ '6cf70164-9097-4460-bcbf-200560546988', '' ], - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', '' ], - [ - 'b26fc763-417d-4f01-b81c-2ec61e14a972', - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', - ], - [ - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', - ], - [ - 'b3247f75-fd94-4fef-97f9-5bfd162cc416', - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - ], - [ - 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - ], - [ - '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', - ], - ] ), - }, - blockListSettings: { - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337': {}, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': {}, - }, blockEditingModes: new Map( [] ), derivedBlockEditingModes: new Map( [] ), + derivedNavModeBlockEditingModes: new Map( [] ), }; const hasContentRoleAttribute = jest.fn( () => false ); @@ -4514,115 +4431,59 @@ describe( 'getBlockEditingMode', () => { ).toBe( 'contentOnly' ); } ); - it( 'should return disabled if explicitly set on a parent', () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], - ] ), - derivedBlockEditingModes: new Map( [ - [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', 'disabled' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'disabled' ], - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', 'disabled' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', 'disabled' ], - ] ), - }; - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'disabled' ); - } ); - - it( 'should return default if parent is set to contentOnly', () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'contentOnly' ], - ] ), - }; - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'default' ); - } ); - - it( 'should return disabled if overridden by a parent', () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ - [ '', 'disabled' ], - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'default' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'disabled' ], - ] ), - derivedBlockEditingModes: new Map( [ - [ '6cf70164-9097-4460-bcbf-200560546988', 'disabled' ], - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', 'disabled' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', 'disabled' ], - ] ), - }; - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'disabled' ); - } ); - - it( 'should return disabled if explicitly set on root', () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ [ '', 'disabled' ] ] ), - derivedBlockEditingModes: new Map( [ - [ '6cf70164-9097-4460-bcbf-200560546988', 'disabled' ], - [ 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', 'disabled' ], - [ 'b26fc763-417d-4f01-b81c-2ec61e14a972', 'disabled' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f', 'disabled' ], - [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], - [ 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c', 'disabled' ], - [ '9b9c5c3f-2e46-4f02-9e14-9fed515b958s', 'disabled' ], - ] ), - }; - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'disabled' ); - } ); - - it( 'should return default if root is contentOnly', () => { - const state = { - ...baseState, - blockEditingModes: new Map( [ [ '', 'contentOnly' ] ] ), - }; - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'default' ); - } ); + describe( 'derived block editing modes override standard block editing modes', () => { + it( 'should return default if explicitly set', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'contentOnly' ], + ] ), + derivedBlockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'default' ], + ] ), + }; + expect( + getBlockEditingMode( + state, + 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + ) + ).toBe( 'default' ); + } ); - it( 'should return disabled if parent is locked and the block has no content role', () => { - const state = { - ...baseState, - blockListSettings: { - ...baseState.blockListSettings, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { - templateLock: 'contentOnly', - }, - }, - }; - hasContentRoleAttribute.mockReturnValueOnce( false ); - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'disabled' ); - } ); + it( 'should return disabled if explicitly set', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'contentOnly' ], + ] ), + derivedBlockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'disabled' ], + ] ), + }; + expect( + getBlockEditingMode( + state, + 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + ) + ).toBe( 'disabled' ); + } ); - it( 'should return contentOnly if parent is locked and the block has a content role', () => { - const state = { - ...baseState, - blockListSettings: { - ...baseState.blockListSettings, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f': { - templateLock: 'contentOnly', - }, - }, - }; - hasContentRoleAttribute.mockReturnValueOnce( true ); - expect( - getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) - ).toBe( 'contentOnly' ); + it( 'should return contentOnly if explicitly set', () => { + const state = { + ...baseState, + blockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'default' ], + ] ), + derivedBlockEditingModes: new Map( [ + [ 'b3247f75-fd94-4fef-97f9-5bfd162cc416', 'contentOnly' ], + ] ), + }; + expect( + getBlockEditingMode( + state, + 'b3247f75-fd94-4fef-97f9-5bfd162cc416' + ) + ).toBe( 'contentOnly' ); + } ); } ); } ); diff --git a/packages/block-library/src/cover/test/edit.js b/packages/block-library/src/cover/test/edit.js index 16695f53f67466..9e5437fea61b72 100644 --- a/packages/block-library/src/cover/test/edit.js +++ b/packages/block-library/src/cover/test/edit.js @@ -51,11 +51,7 @@ async function createAndSelectBlock() { name: 'Black', } ) ); - await userEvent.click( - screen.getByRole( 'button', { - name: 'Select parent block: Cover', - } ) - ); + await selectBlock( 'Block: Cover' ); } describe( 'Cover block', () => { diff --git a/packages/block-library/src/navigation-link/block-inserter.js b/packages/block-library/src/navigation-link/block-inserter.js new file mode 100644 index 00000000000000..704641c3801e6f --- /dev/null +++ b/packages/block-library/src/navigation-link/block-inserter.js @@ -0,0 +1,65 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { + store as blockEditorStore, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import DialogWrapper from './dialog-wrapper'; +import { unlock } from '../lock-unlock'; + +const { PrivateQuickInserter: QuickInserter } = unlock( + blockEditorPrivateApis +); + +/** + * Component for inserting blocks within the Navigation Link UI. + * + * @param {Object} props Component props. + * @param {string} props.clientId Client ID of the navigation link block. + * @param {Function} props.onBack Callback when user wants to go back. + * @param {Function} props.onBlockInsert Callback when a block is inserted. + */ +function LinkUIBlockInserter( { clientId, onBack, onBlockInsert } ) { + const { rootBlockClientId } = useSelect( + ( select ) => { + const { getBlockRootClientId } = select( blockEditorStore ); + + return { + rootBlockClientId: getBlockRootClientId( clientId ), + }; + }, + [ clientId ] + ); + + if ( ! clientId ) { + return null; + } + + return ( + + + + ); +} + +export default LinkUIBlockInserter; diff --git a/packages/block-library/src/navigation-link/dialog-wrapper.js b/packages/block-library/src/navigation-link/dialog-wrapper.js new file mode 100644 index 00000000000000..e37166a1dfc7c2 --- /dev/null +++ b/packages/block-library/src/navigation-link/dialog-wrapper.js @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { Button, VisuallyHidden } from '@wordpress/components'; +import { __, isRTL } from '@wordpress/i18n'; +import { chevronLeftSmall, chevronRightSmall } from '@wordpress/icons'; +import { useInstanceId, useFocusOnMount } from '@wordpress/compose'; + +/** + * Shared BackButton component for consistent navigation across LinkUI sub-components. + * + * @param {Object} props Component props. + * @param {string} props.className CSS class name for the button. + * @param {Function} props.onBack Callback when user wants to go back. + */ +function BackButton( { className, onBack } ) { + return ( + + ); +} + +/** + * Shared DialogWrapper component for consistent dialog structure across LinkUI sub-components. + * + * @param {Object} props Component props. + * @param {string} props.className CSS class name for the dialog container. + * @param {string} props.title Dialog title for accessibility. + * @param {string} props.description Dialog description for accessibility. + * @param {Function} props.onBack Callback when user wants to go back. + * @param {Object} props.children Child components to render inside the dialog. + */ +function DialogWrapper( { className, title, description, onBack, children } ) { + const dialogTitleId = useInstanceId( + DialogWrapper, + 'link-ui-dialog-title' + ); + const dialogDescriptionId = useInstanceId( + DialogWrapper, + 'link-ui-dialog-description' + ); + const focusOnMountRef = useFocusOnMount( 'firstElement' ); + const backButtonClassName = `${ className }__back`; + + return ( +
+ +

{ title }

+

{ description }

+
+ + + + { children } +
+ ); +} + +export default DialogWrapper; diff --git a/packages/block-library/src/navigation-link/link-ui.js b/packages/block-library/src/navigation-link/link-ui.js index eca905d399c34e..c2dfccde84b2ad 100644 --- a/packages/block-library/src/navigation-link/link-ui.js +++ b/packages/block-library/src/navigation-link/link-ui.js @@ -8,13 +8,8 @@ import { VisuallyHidden, __experimentalVStack as VStack, } from '@wordpress/components'; -import { __, isRTL } from '@wordpress/i18n'; -import { - LinkControl, - store as blockEditorStore, - privateApis as blockEditorPrivateApis, - useBlockEditingMode, -} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { LinkControl, useBlockEditingMode } from '@wordpress/block-editor'; import { useMemo, useState, @@ -23,19 +18,14 @@ import { forwardRef, } from '@wordpress/element'; import { useResourcePermissions } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; -import { chevronLeftSmall, chevronRightSmall, plus } from '@wordpress/icons'; -import { useInstanceId, useFocusOnMount } from '@wordpress/compose'; +import { plus } from '@wordpress/icons'; +import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ -import { unlock } from '../lock-unlock'; import { LinkUIPageCreator } from './page-creator'; - -const { PrivateQuickInserter: QuickInserter } = unlock( - blockEditorPrivateApis -); +import LinkUIBlockInserter from './block-inserter'; /** * Given the Link block's type attribute, return the query params to give to @@ -75,74 +65,6 @@ export function getSuggestionsQuery( type, kind ) { } } -function LinkUIBlockInserter( { clientId, onBack, onBlockInsert } ) { - const { rootBlockClientId } = useSelect( - ( select ) => { - const { getBlockRootClientId } = select( blockEditorStore ); - - return { - rootBlockClientId: getBlockRootClientId( clientId ), - }; - }, - [ clientId ] - ); - - const focusOnMountRef = useFocusOnMount( 'firstElement' ); - - const dialogTitleId = useInstanceId( - LinkControl, - `link-ui-block-inserter__title` - ); - const dialogDescriptionId = useInstanceId( - LinkControl, - `link-ui-block-inserter__description` - ); - - if ( ! clientId ) { - return null; - } - - return ( -
- -

{ __( 'Add block' ) }

- -

- { __( 'Choose a block to add to your Navigation.' ) } -

-
- - - - -
- ); -} - function UnforwardedLinkUI( props, ref ) { const { label, url, opensInNewTab, type, kind } = props.link; const postType = type || 'page'; @@ -176,11 +98,11 @@ function UnforwardedLinkUI( props, ref ) { const dialogTitleId = useInstanceId( LinkUI, - `link-ui-link-control__title` + 'link-ui-link-control__title' ); const dialogDescriptionId = useInstanceId( LinkUI, - `link-ui-link-control__description` + 'link-ui-link-control__description' ); const blockEditingMode = useBlockEditingMode(); @@ -220,8 +142,13 @@ function UnforwardedLinkUI( props, ref ) { onChange={ props.onChange } onRemove={ props.onRemove } onCancel={ props.onCancel } - renderControlBottom={ () => - ! link?.url?.length && ( + renderControlBottom={ () => { + // Don't show the tools when there is submitted link (preview state). + if ( link?.url?.length ) { + return null; + } + + return ( - ) - } + ); + } } /> ) } @@ -277,8 +209,8 @@ const LinkUITools = ( { setAddingPage, focusAddBlockButton, focusAddPageButton, - canCreatePage, - blockEditingMode, + canAddPage, + canAddBlock, } ) => { const blockInserterAriaRole = 'listbox'; const addBlockButtonRef = useRef(); @@ -298,9 +230,14 @@ const LinkUITools = ( { } }, [ focusAddPageButton ] ); + // Don't render anything if neither button should be shown + if ( ! canAddPage && ! canAddBlock ) { + return null; + } + return ( - { canCreatePage && ( + { canAddPage && ( - +
@@ -159,6 +152,6 @@ export function LinkUIPageCreator( {
- +
); } diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 3fef01f62d15e1..f311905c891614 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -6,6 +6,10 @@ - `Modal`: Fix modal headings and labels to use proper editor text color instead of wp-admin colors ([#71311](https://github.com/WordPress/gutenberg/pull/71311)). +### Internal + +- `ValidatedCheckboxControl`: Expose the component under private API's ([#71505](https://github.com/WordPress/gutenberg/pull/71505/)). + ## 30.3.0 (2025-09-03) ### Bug Fixes diff --git a/packages/components/src/private-apis.ts b/packages/components/src/private-apis.ts index da0e12d4441eb6..f2dcf00cf02abe 100644 --- a/packages/components/src/private-apis.ts +++ b/packages/components/src/private-apis.ts @@ -13,6 +13,7 @@ import Badge from './badge'; import { DateCalendar, DateRangeCalendar, TZDate } from './calendar'; import { + ValidatedCheckboxControl, ValidatedNumberControl, ValidatedTextControl, ValidatedToggleControl, @@ -32,6 +33,7 @@ lock( privateApis, { DateCalendar, DateRangeCalendar, TZDate, + ValidatedCheckboxControl, ValidatedNumberControl, ValidatedTextControl, ValidatedToggleControl, diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 8cb94ca95e4669..810f6370ce3d56 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,10 +2,16 @@ ## Unreleased +### Breaking changes + +- Remove `boolean` form control. Fields using `Edit: 'boolean'` must now use `Edit: 'checkbox'` or `Edit: 'toggle'` instead. Boolean field types now use checkboxes by default. [#71505](https://github.com/WordPress/gutenberg/pull/71505) + ### Features - Introduce a new `DataViewsPicker` component. [#70971](https://github.com/WordPress/gutenberg/pull/70971) - Dataform: Add new `telephone` field type and field control. [#71498](https://github.com/WordPress/gutenberg/pull/71498) +- DataForm: introduce a new `row` layout, check the README for details. [#71124](https://github.com/WordPress/gutenberg/pull/71124) +- Dataform: Add new `url` field type and field control. [#71518](https://github.com/WordPress/gutenberg/pull/71518) ## 8.0.0 (2025-09-03) diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 1ab55a3339e924..e73f50479cf04d 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -1379,7 +1379,7 @@ Example: ### `layout` -Represents the type of layout used to render the field. It'll be one of Regular, Panel, or Card. This prop is the same as the `form.layout` prop. +Represents the type of layout used to render the field. It'll be one of Regular, Panel, Card, or Row. This prop is the same as the `form.layout` prop. #### Regular @@ -1433,6 +1433,25 @@ For example: } ``` +#### Row + +- `type`: `row`. Required. +- `alignment`: one of `start`, `center`, or `end`. Optional. `center` by default. + +The Row layout displays fields horizontally in a single row. It's particularly useful for grouping related fields that should be displayed side by side. This layout can be used both as a top-level form layout and for individual field groups. + +For example: + +```js +{ + id: 'field_id', + layout: { + type: 'row', + alignment: 'start' + }, +} +``` + ### `label` The label used when displaying a combined field, this requires the use of `children` as well. diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx index 06cf1f0216de8e..9f8241ae5f4340 100644 --- a/packages/dataviews/src/components/dataform/stories/index.story.tsx +++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx @@ -171,10 +171,8 @@ const fields: Field< SamplePost >[] = [ ]; const LayoutRegularComponent = ( { - type = 'default', labelPosition, }: { - type?: 'default' | 'regular' | 'panel' | 'card'; labelPosition: 'default' | 'top' | 'side' | 'none'; } ) => { const [ post, setPost ] = useState( { @@ -196,7 +194,7 @@ const LayoutRegularComponent = ( { const form: Form = useMemo( () => ( { layout: getLayoutFromStoryArgs( { - type, + type: 'regular', labelPosition, } ), fields: [ @@ -216,7 +214,7 @@ const LayoutRegularComponent = ( { 'tags', ], } ), - [ type, labelPosition ] + [ labelPosition ] ); return ( @@ -240,7 +238,7 @@ const getLayoutFromStoryArgs = ( { openAs, withHeader, }: { - type: 'default' | 'regular' | 'panel' | 'card'; + type: 'default' | 'regular' | 'panel' | 'card' | 'row'; labelPosition?: 'default' | 'top' | 'side' | 'none'; openAs?: 'default' | 'dropdown' | 'modal'; withHeader?: boolean; @@ -392,6 +390,7 @@ const ValidationComponent = ( { text: string; email: string; telephone: string; + url: string; integer: number; boolean: boolean; customEdit: string; @@ -401,6 +400,7 @@ const ValidationComponent = ( { text: 'Can have letters and spaces', email: 'hi@example.com', telephone: '+306978241796', + url: 'https://example.com', integer: 2, boolean: true, customEdit: 'custom control', @@ -427,6 +427,13 @@ const ValidationComponent = ( { return null; }; + const customUrlRule = ( value: ValidatedItem ) => { + if ( ! /^https:\/\/example\.com$/.test( value.url ) ) { + return 'URL must be from https://example.com domain.'; + } + + return null; + }; const customIntegerRule = ( value: ValidatedItem ) => { if ( value.integer % 2 !== 0 ) { return 'Integer must be an even number.'; @@ -469,6 +476,15 @@ const ValidationComponent = ( { custom: maybeCustomRule( customTelephoneRule ), }, }, + { + id: 'url', + type: 'url', + label: 'URL', + isValid: { + required, + custom: maybeCustomRule( customUrlRule ), + }, + }, { id: 'integer', type: 'integer', @@ -502,6 +518,7 @@ const ValidationComponent = ( { 'text', 'email', 'telephone', + 'url', 'integer', 'boolean', 'customEdit', @@ -778,6 +795,247 @@ const LayoutCardComponent = ( { withHeader }: { withHeader: boolean } ) => { ); }; +const LayoutRowComponent = ( { + alignment, +}: { + alignment: 'start' | 'center' | 'end'; +} ) => { + type Customer = { + name: string; + email: string; + phone: string; + plan: string; + shippingAddress: string; + shippingCity: string; + shippingPostalCode: string; + shippingCountry: string; + billingAddress: string; + billingCity: string; + billingPostalCode: string; + totalOrders: number; + totalRevenue: number; + averageOrderValue: number; + hasVat: boolean; + hasDiscount: boolean; + vat: number; + commission: number; + }; + + const customerFields: Field< Customer >[] = [ + { + id: 'name', + label: 'Customer Name', + type: 'text', + }, + { + id: 'phone', + label: 'Phone', + type: 'text', + }, + { + id: 'email', + label: 'Email', + type: 'email', + }, + { + id: 'shippingAddress', + label: 'Shipping Address', + type: 'text', + }, + { + id: 'shippingCity', + label: 'Shipping City', + type: 'text', + }, + { + id: 'shippingPostalCode', + label: 'Shipping Postal Code', + type: 'text', + }, + { + id: 'shippingCountry', + label: 'Shipping Country', + type: 'text', + }, + { + id: 'billingAddress', + label: 'Billing Address', + type: 'text', + }, + { + id: 'billingCity', + label: 'Billing City', + type: 'text', + }, + { + id: 'billingPostalCode', + label: 'Billing Postal Code', + type: 'text', + }, + { + id: 'vat', + label: 'VAT', + type: 'integer', + }, + { + id: 'commission', + label: 'Commission', + type: 'integer', + }, + { + id: 'hasDiscount', + label: 'Has Discount?', + type: 'boolean', + }, + { + id: 'plan', + label: 'Plan', + type: 'text', + Edit: 'toggleGroup', + elements: [ + { value: 'basic', label: 'Basic' }, + { value: 'business', label: 'Business' }, + { value: 'vip', label: 'VIP' }, + ], + }, + { + id: 'renewal', + label: 'Renewal', + type: 'text', + Edit: 'radio', + elements: [ + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, + { value: 'yearly', label: 'Yearly' }, + ], + }, + ]; + + const [ customer, setCustomer ] = useState< Customer >( { + name: 'Danyka Romaguera', + email: 'aromaguera@example.org', + phone: '1-828-352-1250', + plan: 'Business', + shippingAddress: 'N/A', + shippingCity: 'N/A', + shippingPostalCode: 'N/A', + shippingCountry: 'N/A', + billingAddress: 'Danyka Romaguera, West Myrtiehaven, 80240-4282, BI', + billingCity: 'City', + billingPostalCode: 'PC', + totalOrders: 2, + totalRevenue: 1430, + averageOrderValue: 715, + hasVat: true, + vat: 10, + commission: 5, + hasDiscount: true, + } ); + + const form: Form = useMemo( + () => ( { + fields: [ + { + id: 'customer', + label: 'Customer', + layout: { + type: 'row', + alignment, + }, + children: [ 'name', 'phone', 'email' ], + }, + { + id: 'addressRow', + label: 'Billing & Shipping Addresses', + layout: { + type: 'row', + alignment, + }, + children: [ + { + id: 'billingAddress', + children: [ + 'billingAddress', + 'billingCity', + 'billingPostalCode', + ], + }, + { + id: 'shippingAddress', + children: [ + 'shippingAddress', + 'shippingCity', + 'shippingPostalCode', + 'shippingCountry', + ], + }, + ], + }, + { + id: 'payments-and-tax', + label: 'Payments & Taxes', + layout: { + type: 'row', + alignment, + }, + children: [ 'vat', 'commission', 'hasDiscount' ], + }, + { + id: 'planRow', + label: 'Subscription', + layout: { + type: 'row', + alignment, + }, + children: [ 'plan', 'renewal' ], + }, + ], + } ), + [ alignment ] + ); + + const topLevelLayout: Form = useMemo( + () => ( { + layout: { + type: 'row', + alignment, + }, + fields: [ 'name', 'phone', 'email' ], + } ), + [ alignment ] + ); + + return ( + <> +

Row Layout

+

As top-level layout

+ + setCustomer( ( prev ) => ( { + ...prev, + ...edits, + } ) ) + } + /> +

Per field layout

+ + setCustomer( ( prev ) => ( { + ...prev, + ...edits, + } ) ) + } + /> + + ); +}; + const LayoutMixedComponent = () => { const [ post, setPost ] = useState< SamplePost >( { title: 'Hello, World!', @@ -794,14 +1052,18 @@ const LayoutMixedComponent = () => { const form: Form = { fields: [ { - id: 'title', + id: 'title-and-status', + children: [ + { + id: 'title', + layout: { type: 'panel' }, + }, + 'status', + ], layout: { - type: 'panel', - labelPosition: 'top', - openAs: 'dropdown', + type: 'row', }, }, - 'status', { id: 'order', layout: { @@ -892,6 +1154,20 @@ export const LayoutRegular = { }, }; +export const LayoutRow = { + render: LayoutRowComponent, + argTypes: { + alignment: { + control: { type: 'select' }, + description: 'The alignment of the fields.', + options: [ 'start', 'center', 'end' ], + }, + }, + args: { + alignment: 'center', + }, +}; + export const LayoutMixed = { render: LayoutMixedComponent, }; diff --git a/packages/dataviews/src/dataform-controls/checkbox.tsx b/packages/dataviews/src/dataform-controls/checkbox.tsx index 54e2103726cf3c..610979bac2d765 100644 --- a/packages/dataviews/src/dataform-controls/checkbox.tsx +++ b/packages/dataviews/src/dataform-controls/checkbox.tsx @@ -1,12 +1,16 @@ /** * WordPress dependencies */ -import { CheckboxControl } from '@wordpress/components'; +import { privateApis } from '@wordpress/components'; +import { useState } from '@wordpress/element'; /** * Internal dependencies */ import type { DataFormControlProps } from '../types'; +import { unlock } from '../lock-unlock'; + +const { ValidatedCheckboxControl } = unlock( privateApis ); export default function Checkbox< Item >( { field, @@ -15,10 +19,36 @@ export default function Checkbox< Item >( { hideLabelFromVision, }: DataFormControlProps< Item > ) { const { id, getValue, label, description } = field; + const [ customValidity, setCustomValidity ] = + useState< + React.ComponentProps< + typeof ValidatedCheckboxControl + >[ 'customValidity' ] + >( undefined ); return ( - { + const message = field.isValid?.custom?.( + { + ...data, + [ id ]: newValue, + }, + field + ); + + if ( message ) { + setCustomValidity( { + type: 'invalid', + message, + } ); + return; + } + + setCustomValidity( undefined ); + } } + customValidity={ customValidity } hidden={ hideLabelFromVision } label={ label } help={ description } diff --git a/packages/dataviews/src/dataform-controls/index.tsx b/packages/dataviews/src/dataform-controls/index.tsx index f49e5427fa3323..964444913839a6 100644 --- a/packages/dataviews/src/dataform-controls/index.tsx +++ b/packages/dataviews/src/dataform-controls/index.tsx @@ -16,12 +16,13 @@ import datetime from './datetime'; import date from './date'; import email from './email'; import telephone from './telephone'; +import url from './url'; import integer from './integer'; import radio from './radio'; import select from './select'; import text from './text'; +import toggle from './toggle'; import toggleGroup from './toggle-group'; -import boolean from './boolean'; import array from './array'; interface FormControls { @@ -30,16 +31,17 @@ interface FormControls { const FORM_CONTROLS: FormControls = { array, - boolean, checkbox, datetime, date, email, telephone, + url, integer, radio, select, text, + toggle, toggleGroup, }; diff --git a/packages/dataviews/src/dataform-controls/boolean.tsx b/packages/dataviews/src/dataform-controls/toggle.tsx similarity index 91% rename from packages/dataviews/src/dataform-controls/boolean.tsx rename to packages/dataviews/src/dataform-controls/toggle.tsx index 6baf9c4d45dc27..aeee564f8815b7 100644 --- a/packages/dataviews/src/dataform-controls/boolean.tsx +++ b/packages/dataviews/src/dataform-controls/toggle.tsx @@ -12,13 +12,13 @@ import { unlock } from '../lock-unlock'; const { ValidatedToggleControl } = unlock( privateApis ); -export default function Boolean< Item >( { +export default function Toggle< Item >( { field, onChange, data, hideLabelFromVision, }: DataFormControlProps< Item > ) { - const { id, getValue, label } = field; + const { id, getValue, label, description } = field; const [ customValidity, setCustomValidity ] = useState< React.ComponentProps< @@ -52,6 +52,7 @@ export default function Boolean< Item >( { hidden={ hideLabelFromVision } __nextHasNoMarginBottom label={ label } + help={ description } checked={ getValue( { item: data } ) } onChange={ () => onChange( { [ id ]: ! getValue( { item: data } ) } ) diff --git a/packages/dataviews/src/dataform-controls/url.tsx b/packages/dataviews/src/dataform-controls/url.tsx new file mode 100644 index 00000000000000..c1687a7bf0fc16 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/url.tsx @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import type { DataFormControlProps } from '../types'; +import ValidatedText from './utils/validated-text'; + +export default function Url< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + return ( + + ); +} diff --git a/packages/dataviews/src/dataform-controls/utils/validated-text.tsx b/packages/dataviews/src/dataform-controls/utils/validated-text.tsx index 73804facc1c4c2..1246462025ddde 100644 --- a/packages/dataviews/src/dataform-controls/utils/validated-text.tsx +++ b/packages/dataviews/src/dataform-controls/utils/validated-text.tsx @@ -17,7 +17,7 @@ export type DataFormValidatedTextControlProps< Item > = /** * The input type of the control. */ - type?: 'text' | 'email' | 'tel'; + type?: 'text' | 'email' | 'tel' | 'url'; }; export default function ValidatedText< Item >( { diff --git a/packages/dataviews/src/dataforms-layouts/card/index.tsx b/packages/dataviews/src/dataforms-layouts/card/index.tsx index 7fd0a26462ea8e..26af9225b01b2b 100644 --- a/packages/dataviews/src/dataforms-layouts/card/index.tsx +++ b/packages/dataviews/src/dataforms-layouts/card/index.tsx @@ -1,7 +1,3 @@ -/** - * External dependencies - */ - /** * WordPress dependencies */ diff --git a/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx b/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx index a6b0a694c4d130..43f7dd5c740524 100644 --- a/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx +++ b/packages/dataviews/src/dataforms-layouts/data-form-layout.tsx @@ -11,13 +11,18 @@ import type { Form, FormField, SimpleFormField } from '../types'; import { getFormFieldLayout } from './index'; import DataFormContext from '../components/dataform-context'; import { isCombinedField } from './is-combined-field'; -import normalizeFormFields from '../normalize-form-fields'; +import normalizeFormFields, { normalizeLayout } from '../normalize-form-fields'; + +const DEFAULT_WRAPPER = ( { children }: { children: React.ReactNode } ) => ( + { children } +); export function DataFormLayout< Item >( { data, form, onChange, children, + as, }: { data: Item; form: Form; @@ -31,6 +36,7 @@ export function DataFormLayout< Item >( { } ) => React.JSX.Element | null, field: FormField ) => React.JSX.Element; + as?: React.ComponentType< { children: React.ReactNode } >; } ) { const { fields: fieldDefinitions } = useContext( DataFormContext ); @@ -47,8 +53,14 @@ export function DataFormLayout< Item >( { [ form ] ); + const normalizedFormLayout = normalizeLayout( form.layout ); + const Wrapper = + as ?? + getFormFieldLayout( normalizedFormLayout.type )?.wrapper ?? + DEFAULT_WRAPPER; + return ( - + { normalizedFormFields.map( ( formField ) => { const FieldLayout = getFormFieldLayout( formField.layout.type ) ?.component; @@ -82,6 +94,6 @@ export function DataFormLayout< Item >( { /> ); } ) } - + ); } diff --git a/packages/dataviews/src/dataforms-layouts/index.tsx b/packages/dataviews/src/dataforms-layouts/index.tsx index e7331ae8275b04..9d642783a16a6a 100644 --- a/packages/dataviews/src/dataforms-layouts/index.tsx +++ b/packages/dataviews/src/dataforms-layouts/index.tsx @@ -1,9 +1,19 @@ +/** + * WordPress dependencies + */ +import { + __experimentalVStack as VStack, + __experimentalHStack as HStack, +} from '@wordpress/components'; + /** * Internal dependencies */ +import type { Layout, RowLayout } from '../types'; import FormRegularField from './regular'; import FormPanelField from './panel'; import FormCardField from './card'; +import FormRowField from './row'; const FORM_FIELD_LAYOUTS = [ { @@ -13,11 +23,36 @@ const FORM_FIELD_LAYOUTS = [ { type: 'panel', component: FormPanelField, + wrapper: ( { children }: { children: React.ReactNode } ) => ( + { children } + ), }, { type: 'card', component: FormCardField, }, + { + type: 'row', + component: FormRowField, + wrapper: ( { + children, + layout, + }: { + children: React.ReactNode; + layout: Layout; + } ) => ( + +
+ + { children } + +
+
+ ), + }, ]; export function getFormFieldLayout( type: string ) { diff --git a/packages/dataviews/src/dataforms-layouts/row/index.tsx b/packages/dataviews/src/dataforms-layouts/row/index.tsx new file mode 100644 index 00000000000000..e8143351cf2c31 --- /dev/null +++ b/packages/dataviews/src/dataforms-layouts/row/index.tsx @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalSpacer as Spacer, + __experimentalVStack as VStack, + __experimentalHeading as Heading, +} from '@wordpress/components'; +import { useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { FieldLayoutProps, Form, NormalizedRowLayout } from '../../types'; +import DataFormContext from '../../components/dataform-context'; +import { DataFormLayout } from '../data-form-layout'; +import { isCombinedField } from '../is-combined-field'; +import { normalizeLayout } from '../../normalize-form-fields'; +import { getFormFieldLayout } from '..'; + +function Header( { title }: { title: string } ) { + return ( + + + + { title } + + + + + ); +} + +const EMPTY_WRAPPER = ( { children }: { children: React.ReactNode } ) => ( + <>{ children } +); + +export default function FormRowField< Item >( { + data, + field, + onChange, + hideLabelFromVision, +}: FieldLayoutProps< Item > ) { + const { fields } = useContext( DataFormContext ); + + const layout = normalizeLayout( { + ...field.layout, + type: 'row', + } ) as NormalizedRowLayout; + + if ( isCombinedField( field ) ) { + const form: Form = { + fields: field.children.map( ( child ) => { + if ( typeof child === 'string' ) { + return { id: child }; + } + return child; + } ), + }; + + return ( +
+ { ! hideLabelFromVision && field.label && ( +
+ ) } + + + { ( FieldLayout, nestedField ) => ( +
+ +
+ ) } +
+
+
+ ); + } + + const fieldDefinition = fields.find( ( f ) => f.id === field.id ); + + if ( ! fieldDefinition || ! fieldDefinition.Edit ) { + return null; + } + + const RegularLayout = getFormFieldLayout( 'regular' )?.component; + if ( ! RegularLayout ) { + return null; + } + + return ( + <> +
+ +
+ + ); +} diff --git a/packages/dataviews/src/dataforms-layouts/row/style.scss b/packages/dataviews/src/dataforms-layouts/row/style.scss new file mode 100644 index 00000000000000..0cfe214eb75563 --- /dev/null +++ b/packages/dataviews/src/dataforms-layouts/row/style.scss @@ -0,0 +1,3 @@ +.dataforms-layouts-row__field-control { + width: 100%; +} diff --git a/packages/dataviews/src/field-types/boolean.tsx b/packages/dataviews/src/field-types/boolean.tsx index 1dca83aa7d1f68..da4779cf254ef5 100644 --- a/packages/dataviews/src/field-types/boolean.tsx +++ b/packages/dataviews/src/field-types/boolean.tsx @@ -48,7 +48,7 @@ export default { return null; }, }, - Edit: 'boolean', + Edit: 'checkbox', render: ( { item, field }: DataViewRenderFieldProps< any > ) => { if ( field.elements ) { return renderFromElements( { item, field } ); diff --git a/packages/dataviews/src/field-types/index.tsx b/packages/dataviews/src/field-types/index.tsx index 34e8342c95c211..467cdfec7020d7 100644 --- a/packages/dataviews/src/field-types/index.tsx +++ b/packages/dataviews/src/field-types/index.tsx @@ -22,6 +22,7 @@ import { default as boolean } from './boolean'; import { default as media } from './media'; import { default as array } from './array'; import { default as telephone } from './telephone'; +import { default as url } from './url'; import { renderFromElements } from '../utils'; import { ALL_OPERATORS, OPERATOR_IS, OPERATOR_IS_NOT } from '../constants'; @@ -70,6 +71,10 @@ export default function getFieldTypeDefinition< Item >( return telephone; } + if ( 'url' === type ) { + return url; + } + // This is a fallback for fields that don't provide a type. // It can be removed when the field.type is mandatory. return { diff --git a/packages/dataviews/src/field-types/stories/index.story.tsx b/packages/dataviews/src/field-types/stories/index.story.tsx index ab82f1c7df9c90..43a6b03cd7de53 100644 --- a/packages/dataviews/src/field-types/stories/index.story.tsx +++ b/packages/dataviews/src/field-types/stories/index.story.tsx @@ -32,7 +32,6 @@ const meta = { options: [ 'default', 'array', - 'boolean', 'checkbox', 'date', 'datetime', @@ -41,7 +40,9 @@ const meta = { 'radio', 'select', 'telephone', + 'url', 'text', + 'toggle', 'toggleGroup', ], }, @@ -60,6 +61,7 @@ type DataType = { integer: number; integerWithElements: number; boolean: boolean; + booleanWithToggle: boolean; booleanWithElements: boolean; datetime: string; datetimeWithElements: string; @@ -69,6 +71,8 @@ type DataType = { emailWithElements: string; telephone: string; telephoneWithElements: string; + url: string; + urlWithElements: string; media: string; mediaWithElements: string; array: string[]; @@ -85,6 +89,7 @@ const data: DataType[] = [ integer: 1, integerWithElements: 1, boolean: true, + booleanWithToggle: true, booleanWithElements: true, datetime: '2021-01-01T14:30:00Z', datetimeWithElements: '2021-01-01T14:30:00Z', @@ -94,6 +99,8 @@ const data: DataType[] = [ emailWithElements: 'hi@example.com', telephone: '+1-555-123-4567', telephoneWithElements: '+1-555-123-4567', + url: 'https://example.com', + urlWithElements: 'https://example.com', media: 'https://live.staticflickr.com/7398/9458193857_e1256123e3_z.jpg', mediaWithElements: 'https://live.staticflickr.com/7398/9458193857_e1256123e3_z.jpg', @@ -145,6 +152,13 @@ const fields: Field< DataType >[] = [ label: 'Boolean', description: 'Help for boolean.', }, + { + id: 'booleanWithToggle', + type: 'boolean', + label: 'Boolean (with toggle)', + description: 'Help for boolean with toggle control.', + Edit: 'toggle', + }, { id: 'booleanWithElements', type: 'boolean', @@ -232,6 +246,23 @@ const fields: Field< DataType >[] = [ { value: '+81-3-1234-5678', label: '+81-3-1234-5678' }, ], }, + { + id: 'url', + type: 'url', + label: 'URL', + description: 'Help for URL.', + }, + { + id: 'urlWithElements', + type: 'url', + label: 'URL (with elements)', + description: 'Help for URL with elements.', + elements: [ + { value: 'https://example.com', label: 'https://example.com' }, + { value: 'https://wordpress.org', label: 'https://wordpress.org' }, + { value: 'https://github.com', label: 'https://github.com' }, + ], + }, { id: 'media', type: 'media', @@ -310,7 +341,6 @@ type PanelTypes = 'regular' | 'panel'; type ControlTypes = | 'default' | 'array' - | 'boolean' | 'checkbox' | 'date' | 'datetime' @@ -319,7 +349,9 @@ type ControlTypes = | 'radio' | 'select' | 'telephone' + | 'url' | 'text' + | 'toggle' | 'toggleGroup'; interface FieldTypeStoryProps { @@ -569,6 +601,21 @@ export const Telephone = ( { ); }; +export const Url = ( { + type, + Edit, +}: { + type: PanelTypes; + Edit: ControlTypes; +} ) => { + const urlFields = useMemo( + () => fields.filter( ( field ) => field.type === 'url' ), + [] + ); + + return ; +}; + export const Media = ( { type, Edit, diff --git a/packages/dataviews/src/field-types/url.tsx b/packages/dataviews/src/field-types/url.tsx new file mode 100644 index 00000000000000..a26d3d30d3ea1f --- /dev/null +++ b/packages/dataviews/src/field-types/url.tsx @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { + DataViewRenderFieldProps, + SortDirection, + NormalizedField, + FieldTypeDefinition, +} from '../types'; +import { renderFromElements } from '../utils'; +import { + OPERATOR_IS, + OPERATOR_IS_ALL, + OPERATOR_IS_NOT_ALL, + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, + OPERATOR_IS_NOT, + OPERATOR_CONTAINS, + OPERATOR_NOT_CONTAINS, + OPERATOR_STARTS_WITH, +} from '../constants'; + +function sort( valueA: any, valueB: any, direction: SortDirection ) { + return direction === 'asc' + ? valueA.localeCompare( valueB ) + : valueB.localeCompare( valueA ); +} + +export default { + sort, + isValid: { + custom: ( item: any, field: NormalizedField< any > ) => { + const value = field.getValue( { item } ); + if ( field?.elements ) { + const validValues = field.elements.map( ( f ) => f.value ); + if ( ! validValues.includes( value ) ) { + return __( 'Value must be one of the elements.' ); + } + } + + return null; + }, + }, + Edit: 'url', + render: ( { item, field }: DataViewRenderFieldProps< any > ) => { + return field.elements + ? renderFromElements( { item, field } ) + : field.getValue( { item } ); + }, + enableSorting: true, + filterBy: { + defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], + validOperators: [ + OPERATOR_IS, + OPERATOR_IS_NOT, + OPERATOR_CONTAINS, + OPERATOR_NOT_CONTAINS, + OPERATOR_STARTS_WITH, + // Multiple selection + OPERATOR_IS_ANY, + OPERATOR_IS_NONE, + OPERATOR_IS_ALL, + OPERATOR_IS_NOT_ALL, + ], + }, +} satisfies FieldTypeDefinition< any >; diff --git a/packages/dataviews/src/normalize-form-fields.ts b/packages/dataviews/src/normalize-form-fields.ts index 0338ddafb4eba7..2488b8787d334f 100644 --- a/packages/dataviews/src/normalize-form-fields.ts +++ b/packages/dataviews/src/normalize-form-fields.ts @@ -8,6 +8,7 @@ import type { NormalizedRegularLayout, NormalizedPanelLayout, NormalizedCardLayout, + NormalizedRowLayout, } from './types'; interface NormalizedFormField { @@ -59,6 +60,11 @@ export function normalizeLayout( layout?: Layout ): NormalizedLayout { : true, } satisfies NormalizedCardLayout; } + } else if ( layout?.type === 'row' ) { + normalizedLayout = { + type: 'row', + alignment: layout?.alignment ?? 'center', + } satisfies NormalizedRowLayout; } return normalizedLayout; diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss index c29ea0e74bb96e..fa327f3a464e02 100644 --- a/packages/dataviews/src/style.scss +++ b/packages/dataviews/src/style.scss @@ -17,3 +17,4 @@ @import "./dataforms-layouts/panel/style.scss"; @import "./dataforms-layouts/regular/style.scss"; @import "./dataforms-layouts/card/style.scss"; +@import "./dataforms-layouts/row/style.scss"; diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index 07056d809fff84..655ae484659a82 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -104,6 +104,7 @@ export type FieldType = | 'boolean' | 'email' | 'telephone' + | 'url' | 'array'; /** @@ -713,7 +714,7 @@ export interface SupportedLayouts { /** * DataForm layouts. */ -export type LayoutType = 'regular' | 'panel' | 'card'; +export type LayoutType = 'regular' | 'panel' | 'card' | 'row'; export type LabelPosition = 'top' | 'side' | 'none'; export type RegularLayout = { @@ -763,11 +764,21 @@ export type NormalizedCardLayout = isOpened: boolean; }; -export type Layout = RegularLayout | PanelLayout | CardLayout; +export type RowLayout = { + type: 'row'; + alignment?: 'start' | 'center' | 'end'; +}; +export type NormalizedRowLayout = { + type: 'row'; + alignment: 'start' | 'center' | 'end'; +}; + +export type Layout = RegularLayout | PanelLayout | CardLayout | RowLayout; export type NormalizedLayout = | NormalizedRegularLayout | NormalizedPanelLayout - | NormalizedCardLayout; + | NormalizedCardLayout + | NormalizedRowLayout; export type SimpleFormField = { id: string; diff --git a/packages/dataviews/src/validation.ts b/packages/dataviews/src/validation.ts index 3b3263c3ad7a0f..d60e3481e5775a 100644 --- a/packages/dataviews/src/validation.ts +++ b/packages/dataviews/src/validation.ts @@ -32,6 +32,9 @@ export function isItemValid< Item >( if ( ( field.type === 'text' && isEmptyNullOrUndefined( value ) ) || ( field.type === 'email' && isEmptyNullOrUndefined( value ) ) || + ( field.type === 'url' && isEmptyNullOrUndefined( value ) ) || + ( field.type === 'telephone' && + isEmptyNullOrUndefined( value ) ) || ( field.type === 'integer' && isEmptyNullOrUndefined( value ) ) || ( field.type === undefined && isEmptyNullOrUndefined( value ) ) diff --git a/packages/edit-post/src/components/editor-initialization/listener-hooks.js b/packages/edit-post/src/components/editor-initialization/listener-hooks.js index 968031d59adbc3..38d202b483f99c 100644 --- a/packages/edit-post/src/components/editor-initialization/listener-hooks.js +++ b/packages/edit-post/src/components/editor-initialization/listener-hooks.js @@ -4,6 +4,7 @@ import { useSelect } from '@wordpress/data'; import { useEffect, useRef } from '@wordpress/element'; import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -18,12 +19,17 @@ import { * post link in the admin bar. */ export const useUpdatePostLinkListener = () => { - const { newPermalink } = useSelect( - ( select ) => ( { - newPermalink: select( editorStore ).getCurrentPost().link, - } ), - [] - ); + const { isViewable, newPermalink } = useSelect( ( select ) => { + const { getPostType } = select( coreStore ); + const { getCurrentPost, getEditedPostAttribute } = + select( editorStore ); + const postType = getPostType( getEditedPostAttribute( 'type' ) ); + return { + isViewable: postType?.viewable, + newPermalink: getCurrentPost().link, + }; + }, [] ); + const nodeToUpdateRef = useRef(); useEffect( () => { @@ -36,6 +42,13 @@ export const useUpdatePostLinkListener = () => { if ( ! newPermalink || ! nodeToUpdateRef.current ) { return; } + + if ( ! isViewable ) { + nodeToUpdateRef.current.style.display = 'none'; + return; + } + + nodeToUpdateRef.current.style.display = ''; nodeToUpdateRef.current.setAttribute( 'href', newPermalink ); - }, [ newPermalink ] ); + }, [ newPermalink, isViewable ] ); }; diff --git a/packages/edit-post/src/components/editor-initialization/test/listener-hooks.js b/packages/edit-post/src/components/editor-initialization/test/listener-hooks.js index 21b7a045a0dd53..5c8085d4715cbc 100644 --- a/packages/edit-post/src/components/editor-initialization/test/listener-hooks.js +++ b/packages/edit-post/src/components/editor-initialization/test/listener-hooks.js @@ -33,6 +33,14 @@ describe( 'listener hook tests', () => { ...storeConfig, selectors: { getCurrentPost: jest.fn(), + getCurrentPostType: jest.fn(), + getEditedPostAttribute: jest.fn(), + }, + }, + core: { + ...storeConfig, + selectors: { + getPostType: jest.fn(), }, }, 'core/viewport': { @@ -69,6 +77,13 @@ describe( 'listener hook tests', () => { mockStores[ store ].selectors[ functionName ].mockReturnValue( value ); }; + const setupPostTypeScenario = ( postType, isViewable = true ) => { + setMockReturnValue( 'core/editor', 'getEditedPostAttribute', postType ); + setMockReturnValue( 'core', 'getPostType', { + viewable: isViewable, + } ); + }; + afterEach( () => { Object.values( mockStores ).forEach( ( storeMocks ) => { Object.values( storeMocks.selectors ).forEach( ( mock ) => { @@ -95,28 +110,36 @@ describe( 'listener hook tests', () => { }; const setAttribute = jest.fn(); + const mockElement = { + setAttribute, + style: { display: '' }, + }; const mockSelector = jest.fn(); beforeEach( () => { + // Reset the mock element style + mockElement.style.display = ''; // eslint-disable-next-line testing-library/no-node-access - document.querySelector = mockSelector.mockReturnValue( { - setAttribute, - } ); + document.querySelector = + mockSelector.mockReturnValue( mockElement ); } ); afterEach( () => { setAttribute.mockClear(); mockSelector.mockClear(); + mockElement.style.display = ''; } ); it( 'updates nothing if there is no view link available', () => { mockSelector.mockImplementation( () => null ); setMockReturnValue( 'core/editor', 'getCurrentPost', { link: 'foo', } ); + setupPostTypeScenario( 'post', true ); render( ); expect( setAttribute ).not.toHaveBeenCalled(); } ); it( 'updates nothing if there is no permalink', () => { setMockReturnValue( 'core/editor', 'getCurrentPost', { link: '' } ); + setupPostTypeScenario( 'post', true ); render( ); expect( setAttribute ).not.toHaveBeenCalled(); @@ -125,6 +148,7 @@ describe( 'listener hook tests', () => { setMockReturnValue( 'core/editor', 'getCurrentPost', { link: 'foo', } ); + setupPostTypeScenario( 'post', true ); const { rerender } = render( ); rerender( ); @@ -139,6 +163,7 @@ describe( 'listener hook tests', () => { setMockReturnValue( 'core/editor', 'getCurrentPost', { link: 'foo', } ); + setupPostTypeScenario( 'post', true ); render( ); expect( setAttribute ).toHaveBeenCalledTimes( 1 ); act( () => { @@ -150,6 +175,7 @@ describe( 'listener hook tests', () => { setMockReturnValue( 'core/editor', 'getCurrentPost', { link: 'foo', } ); + setupPostTypeScenario( 'post', true ); render( ); expect( setAttribute ).toHaveBeenCalledTimes( 1 ); expect( setAttribute ).toHaveBeenCalledWith( 'href', 'foo' ); @@ -163,5 +189,15 @@ describe( 'listener hook tests', () => { expect( setAttribute ).toHaveBeenCalledTimes( 2 ); expect( setAttribute ).toHaveBeenCalledWith( 'href', 'bar' ); } ); + it( 'hides the "View Post" link when editing non-viewable post types', () => { + setMockReturnValue( 'core/editor', 'getCurrentPost', { + link: 'foo', + } ); + setupPostTypeScenario( 'wp_block', false ); + render( ); + + expect( setAttribute ).not.toHaveBeenCalled(); + expect( mockElement ).toHaveProperty( 'style.display', 'none' ); + } ); } ); } ); diff --git a/packages/edit-site/src/components/global-styles/screen-root.js b/packages/edit-site/src/components/global-styles/screen-root.js index e15eae1cd30962..fa4ce66fe9fc91 100644 --- a/packages/edit-site/src/components/global-styles/screen-root.js +++ b/packages/edit-site/src/components/global-styles/screen-root.js @@ -16,7 +16,6 @@ import { isRTL, __ } from '@wordpress/i18n'; import { chevronLeft, chevronRight } from '@wordpress/icons'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies @@ -25,13 +24,8 @@ import { IconWithCurrentColor } from './icon-with-current-color'; import { NavigationButtonAsItem } from './navigation-button'; import RootMenu from './root-menu'; import PreviewStyles from './preview-styles'; -import { unlock } from '../../lock-unlock'; - -const { useGlobalStyle } = unlock( blockEditorPrivateApis ); function ScreenRoot() { - const [ customCSS ] = useGlobalStyle( 'css' ); - const { hasVariations, canEditCSS } = useSelect( ( select ) => { const { getEntityRecord, @@ -116,7 +110,7 @@ function ScreenRoot() { - { canEditCSS && !! customCSS && ( + { canEditCSS && ( <> diff --git a/packages/element/README.md b/packages/element/README.md index d4dd69186bc772..e7701fb0420b09 100755 --- a/packages/element/README.md +++ b/packages/element/README.md @@ -54,11 +54,11 @@ Concatenate two or more React children objects. _Parameters_ -- _childrenArguments_ `...?Object`: Array of children arguments (array of arrays/strings/objects) to concatenate. +- _childrenArguments_ `ReactNode[][]`: - Array of children arguments (array of arrays/strings/objects) to concatenate. _Returns_ -- `Array`: The concatenated value. +- `ReactNode[]`: The concatenated value. ### createContext @@ -110,11 +110,11 @@ You would have something like this as the conversionMap value: _Parameters_ - _interpolatedString_ `string`: The interpolation string to be parsed. -- _conversionMap_ `Record`: The map used to convert the string to a react element. +- _conversionMap_ `Record< string, ReactElement >`: The map used to convert the string to a react element. _Returns_ -- `Element`: A wp element. +- `ReactElement`: A wp element. ### createPortal @@ -209,7 +209,7 @@ Checks if the provided WP element is empty. _Parameters_ -- _element_ `*`: WP element to check. +- _element_ `unknown`: WP element to check. _Returns_ @@ -312,13 +312,9 @@ Serializes a React element to string. _Parameters_ -- _element_ `import('react').ReactNode`: Element to serialize. -- _context_ `[Object]`: Context object. -- _legacyContext_ `[Object]`: Legacy context object. - -_Returns_ - -- `string`: Serialized element. +- _element_ `React.ReactNode`: +- _context_ `any`: +- _legacyContext_ `Record< string, any >`: ### startTransition @@ -342,12 +338,12 @@ Switches the nodeName of all the elements in the children object. _Parameters_ -- _children_ `?Object`: Children object. +- _children_ `ReactNode`: Children object. - _nodeName_ `string`: Node name. _Returns_ -- `?Object`: The updated children object. +- `ReactNode`: The updated children object. ### unmountComponentAtNode diff --git a/packages/element/src/create-interpolate-element.js b/packages/element/src/create-interpolate-element.ts similarity index 69% rename from packages/element/src/create-interpolate-element.js rename to packages/element/src/create-interpolate-element.ts index 88f6254dad7d6e..754855a13458fc 100644 --- a/packages/element/src/create-interpolate-element.js +++ b/packages/element/src/create-interpolate-element.ts @@ -1,15 +1,18 @@ /** * Internal dependencies */ -import { createElement, cloneElement, Fragment, isValidElement } from './react'; +import { + createElement, + cloneElement, + Fragment, + isValidElement, + type Element as ReactElement, +} from './react'; -/** - * Object containing a React element. - * - * @typedef {import('react').ReactElement} Element - */ - -let indoc, offset, output, stack; +let indoc: string; +let offset: number; +let output: ( string | ReactElement )[]; +let stack: Frame[]; /** * Matches tags in the localized string @@ -23,28 +26,40 @@ let indoc, offset, output, stack; * isClosing: The closing slash, if it exists. * name: The name portion of the tag (strong, br) (if ) * isSelfClosed: The slash on a self closing tag, if it exists. - * - * @type {RegExp} */ const tokenizer = /<(\/)?(\w+)\s*(\/)?>/g; -/** - * The stack frame tracking parse progress. - * - * @typedef Frame - * - * @property {Element} element A parent element which may still have - * @property {number} tokenStart Offset at which parent element first - * appears. - * @property {number} tokenLength Length of string marking start of parent - * element. - * @property {number} [prevOffset] Running offset at which parsing should - * continue. - * @property {number} [leadingTextStart] Offset at which last closing element - * finished, used for finding text between - * elements. - * @property {Element[]} children Children. - */ +interface Frame { + /** + * A parent element which may still have nested children not yet parsed. + */ + element: ReactElement; + + /** + * Offset at which parent element first appears. + */ + tokenStart: number; + + /** + * Length of string marking start of parent element. + */ + tokenLength: number; + + /** + * Running offset at which parsing should continue. + */ + prevOffset?: number; + + /** + * Offset at which last closing element finished, used for finding text between elements. + */ + leadingTextStart?: number | null; + + /** + * Children. + */ + children: ( string | ReactElement )[]; +} /** * Tracks recursive-descent parse state. @@ -53,27 +68,27 @@ const tokenizer = /<(\/)?(\w+)\s*(\/)?>/g; * parsed. * * @private - * @param {Element} element A parent element which may still have - * nested children not yet parsed. - * @param {number} tokenStart Offset at which parent element first - * appears. - * @param {number} tokenLength Length of string marking start of parent - * element. - * @param {number} [prevOffset] Running offset at which parsing should - * continue. - * @param {number} [leadingTextStart] Offset at which last closing element - * finished, used for finding text between - * elements. + * @param element A parent element which may still have + * nested children not yet parsed. + * @param tokenStart Offset at which parent element first + * appears. + * @param tokenLength Length of string marking start of parent + * element. + * @param prevOffset Running offset at which parsing should + * continue. + * @param leadingTextStart Offset at which last closing element + * finished, used for finding text between + * elements. * - * @return {Frame} The stack frame tracking parse progress. + * @return The stack frame tracking parse progress. */ function createFrame( - element, - tokenStart, - tokenLength, - prevOffset, - leadingTextStart -) { + element: ReactElement, + tokenStart: number, + tokenLength: number, + prevOffset?: number, + leadingTextStart?: number | null +): Frame { return { element, tokenStart, @@ -105,13 +120,16 @@ function createFrame( * } * ``` * - * @param {string} interpolatedString The interpolation string to be parsed. - * @param {Record} conversionMap The map used to convert the string to - * a react element. + * @param interpolatedString The interpolation string to be parsed. + * @param conversionMap The map used to convert the string to + * a react element. * @throws {TypeError} - * @return {Element} A wp element. + * @return A wp element. */ -const createInterpolateElement = ( interpolatedString, conversionMap ) => { +const createInterpolateElement = ( + interpolatedString: string, + conversionMap: Record< string, ReactElement > +): ReactElement => { indoc = interpolatedString; offset = 0; output = []; @@ -138,35 +156,48 @@ const createInterpolateElement = ( interpolatedString, conversionMap ) => { * * @private * - * @param {Object} conversionMap The map being validated. + * @param conversionMap The map being validated. * - * @return {boolean} True means the map is valid. + * @return True means the map is valid. */ -const isValidConversionMap = ( conversionMap ) => { - const isObject = typeof conversionMap === 'object'; +const isValidConversionMap = ( + conversionMap: Record< string, ReactElement > +): boolean => { + const isObject = + typeof conversionMap === 'object' && conversionMap !== null; const values = isObject && Object.values( conversionMap ); return ( isObject && - values.length && + values.length > 0 && values.every( ( element ) => isValidElement( element ) ) ); }; +type TokenType = 'no-more-tokens' | 'self-closed' | 'opener' | 'closer'; +type TokenResult = + | [ TokenType & 'no-more-tokens' ] + | [ + TokenType & ( 'self-closed' | 'opener' | 'closer' ), + string, + number, + number, + ]; + /** * This is the iterator over the matches in the string. * * @private * - * @param {Object} conversionMap The conversion map for the string. + * @param conversionMap The conversion map for the string. * - * @return {boolean} true for continuing to iterate, false for finished. + * @return true for continuing to iterate, false for finished. */ -function proceed( conversionMap ) { +function proceed( conversionMap: Record< string, ReactElement > ): boolean { const next = nextToken(); const [ tokenType, name, startOffset, tokenLength ] = next; const stackDepth = stack.length; const leadingTextStart = startOffset > offset ? offset : null; - if ( ! conversionMap[ name ] ) { + if ( name && ! conversionMap[ name ] ) { addText(); return false; } @@ -254,9 +285,9 @@ function proceed( conversionMap ) { * * @private * - * @return {Array} An array of details for the token matched. + * @return An array of details for the token matched. */ -function nextToken() { +function nextToken(): TokenResult { const matches = tokenizer.exec( indoc ); // We have no more tokens. if ( null === matches ) { @@ -281,7 +312,7 @@ function nextToken() { * * @private */ -function addText() { +function addText(): void { const length = indoc.length - offset; if ( 0 === length ) { return; @@ -298,7 +329,7 @@ function addText() { * @param {Frame} frame The Frame containing the child element and it's * token information. */ -function addChild( frame ) { +function addChild( frame: Frame ): void { const { element, tokenStart, tokenLength, prevOffset, children } = frame; const parent = stack[ stack.length - 1 ]; const text = indoc.substr( @@ -326,7 +357,7 @@ function addChild( frame ) { * helps capture any remaining nested text nodes in * the element. */ -function closeOuterElement( endOffset ) { +function closeOuterElement( endOffset: number ): void { const { element, leadingTextStart, prevOffset, tokenStart, children } = stack.pop(); diff --git a/packages/element/src/index.js b/packages/element/src/index.ts similarity index 100% rename from packages/element/src/index.js rename to packages/element/src/index.ts diff --git a/packages/element/src/platform.js b/packages/element/src/platform.ts similarity index 61% rename from packages/element/src/platform.js rename to packages/element/src/platform.ts index 37960103b75464..22bcb563a9f15d 100644 --- a/packages/element/src/platform.js +++ b/packages/element/src/platform.ts @@ -6,11 +6,15 @@ * Copyright (c) 2015-present, Facebook, Inc. * */ -const Platform = { - OS: 'web', - select: ( spec ) => ( 'web' in spec ? spec.web : spec.default ), - isWeb: true, + +/** + * Specification for platform-specific value selection. + */ +type PlatformSelectSpec< T > = { + web?: T; + default?: T; }; + /** * Component used to detect the current Platform being used. * Use Platform.OS === 'web' to detect if running on web environment. @@ -30,4 +34,23 @@ const Platform = { * } ); * ``` */ +const Platform = { + /** Platform identifier. Will always be `'web'` in this module. */ + OS: 'web' as const, + + /** + * Select a value based on the platform. + * + * @template T + * @param spec - Object with optional platform-specific values. + * @return The selected value. + */ + select< T >( spec: PlatformSelectSpec< T > ): T | undefined { + return 'web' in spec ? spec.web : spec.default; + }, + + /** Whether the platform is web */ + isWeb: true, +}; + export default Platform; diff --git a/packages/element/src/raw-html.js b/packages/element/src/raw-html.ts similarity index 81% rename from packages/element/src/raw-html.js rename to packages/element/src/raw-html.ts index acd8c1e670cb10..0fdc1f61cb0480 100644 --- a/packages/element/src/raw-html.js +++ b/packages/element/src/raw-html.ts @@ -3,7 +3,12 @@ */ import { Children, createElement } from './react'; -/** @typedef {{children: string} & import('react').ComponentPropsWithoutRef<'div'>} RawHTMLProps */ +/** + * Props for the RawHTML component. + */ +export type RawHTMLProps = { + children: string | string[]; +} & React.ComponentPropsWithoutRef< 'div' >; /** * Component used to render unescaped HTML. @@ -24,9 +29,12 @@ import { Children, createElement } from './react'; * of strings. Other props will be passed through * to the div wrapper. * - * @return {JSX.Element} Dangerously-rendering component. + * @return Dangerously-rendering component. */ -export default function RawHTML( { children, ...props } ) { +export default function RawHTML( { + children, + ...props +}: RawHTMLProps ): JSX.Element { let rawHtml = ''; // Cast children as an array, and concatenate each element if it is a string. diff --git a/packages/element/src/react-platform.js b/packages/element/src/react-platform.ts similarity index 100% rename from packages/element/src/react-platform.js rename to packages/element/src/react-platform.ts diff --git a/packages/element/src/react.js b/packages/element/src/react.ts similarity index 82% rename from packages/element/src/react.js rename to packages/element/src/react.ts index cf2f9614bc3316..4b66ec2ae59c68 100644 --- a/packages/element/src/react.js +++ b/packages/element/src/react.ts @@ -34,45 +34,37 @@ import { lazy, Suspense, } from 'react'; +import type { ReactNode } from 'react'; /** * Object containing a React element. - * - * @typedef {import('react').ReactElement} Element */ +export type Element = React.ReactElement; /** * Object containing a React component. - * - * @typedef {import('react').ComponentType} ComponentType */ +export type ComponentType< T = any > = React.ComponentType< T >; /** * Object containing a React synthetic event. - * - * @typedef {import('react').SyntheticEvent} SyntheticEvent */ +export type SyntheticEvent< T = Element > = React.SyntheticEvent< T >; /** * Object containing a React ref object. - * - * @template T - * @typedef {import('react').RefObject} RefObject */ +export type RefObject< T > = React.RefObject< T >; /** * Object containing a React ref callback. - * - * @template T - * @typedef {import('react').RefCallback} RefCallback */ +export type RefCallback< T > = React.RefCallback< T >; /** * Object containing a React ref. - * - * @template T - * @typedef {import('react').Ref} Ref */ +export type Ref< T > = React.Ref< T >; /** * Object that provides utilities for dealing with React children. @@ -261,41 +253,52 @@ export { PureComponent }; /** * Concatenate two or more React children objects. * - * @param {...?Object} childrenArguments Array of children arguments (array of arrays/strings/objects) to concatenate. - * - * @return {Array} The concatenated value. - */ -export function concatChildren( ...childrenArguments ) { - return childrenArguments.reduce( ( accumulator, children, i ) => { - Children.forEach( children, ( child, j ) => { - if ( child && 'string' !== typeof child ) { - child = cloneElement( child, { - key: [ i, j ].join(), - } ); - } - - accumulator.push( child ); - } ); - - return accumulator; - }, [] ); + * @param childrenArguments - Array of children arguments (array of arrays/strings/objects) to concatenate. + * @return The concatenated value. + */ +export function concatChildren( + ...childrenArguments: ReactNode[][] +): ReactNode[] { + return childrenArguments.reduce< ReactNode[] >( + ( accumulator, children, i ) => { + Children.forEach( children, ( child, j ) => { + if ( isValidElement( child ) && typeof child !== 'string' ) { + child = cloneElement( child, { + key: [ i, j ].join(), + } ); + } + + accumulator.push( child ); + } ); + + return accumulator; + }, + [] + ); } /** * Switches the nodeName of all the elements in the children object. * - * @param {?Object} children Children object. - * @param {string} nodeName Node name. + * @param children Children object. + * @param nodeName Node name. * - * @return {?Object} The updated children object. + * @return The updated children object. */ -export function switchChildrenNodeName( children, nodeName ) { +export function switchChildrenNodeName( + children: ReactNode, + nodeName: string +): ReactNode { return ( children && Children.map( children, ( elt, index ) => { if ( typeof elt?.valueOf() === 'string' ) { return createElement( nodeName, { key: index }, elt ); } + if ( ! isValidElement( elt ) ) { + return elt; + } + const { children: childrenProp, ...props } = elt.props; return createElement( nodeName, diff --git a/packages/element/src/serialize.js b/packages/element/src/serialize.ts similarity index 77% rename from packages/element/src/serialize.js rename to packages/element/src/serialize.ts index 67ddd86675027f..c4807c0c00f220 100644 --- a/packages/element/src/serialize.js +++ b/packages/element/src/serialize.ts @@ -51,6 +51,32 @@ import RawHTML from './raw-html'; const Context = createContext( undefined ); Context.displayName = 'ElementContext'; +interface ComponentInstance { + render: () => React.ReactNode; + getChildContext?: () => Record< string, any >; +} + +interface RawHTMLProps { + children: string; + [ key: string ]: any; +} + +interface StyleObject { + [ property: string ]: string | number | null | undefined; +} + +interface HTMLProps { + dangerouslySetInnerHTML?: { + __html: string; + }; + children?: React.ReactNode; + value?: React.ReactNode; + style?: StyleObject | string; + className?: string; + htmlFor?: string; + [ key: string ]: any; +} + const { Provider, Consumer } = Context; const ForwardRef = forwardRef( () => { @@ -59,17 +85,13 @@ const ForwardRef = forwardRef( () => { /** * Valid attribute types. - * - * @type {Set} */ -const ATTRIBUTES_TYPES = new Set( [ 'string', 'boolean', 'number' ] ); +const ATTRIBUTES_TYPES = new Set< string >( [ 'string', 'boolean', 'number' ] ); /** * Element tags which can be self-closing. - * - * @type {Set} */ -const SELF_CLOSING_TAGS = new Set( [ +const SELF_CLOSING_TAGS = new Set< string >( [ 'area', 'base', 'br', @@ -100,10 +122,8 @@ const SELF_CLOSING_TAGS = new Set( [ * .reduce( ( result, tr ) => Object.assign( result, { * [ tr.firstChild.textContent.trim() ]: true * } ), {} ) ).sort(); - * - * @type {Set} */ -const BOOLEAN_ATTRIBUTES = new Set( [ +const BOOLEAN_ATTRIBUTES = new Set< string >( [ 'allowfullscreen', 'allowpaymentrequest', 'allowusermedia', @@ -151,10 +171,8 @@ const BOOLEAN_ATTRIBUTES = new Set( [ * Some notable omissions: * * - `alt`: https://blog.whatwg.org/omit-alt - * - * @type {Set} */ -const ENUMERATED_ATTRIBUTES = new Set( [ +const ENUMERATED_ATTRIBUTES = new Set< string >( [ 'autocapitalize', 'autocomplete', 'charset', @@ -194,10 +212,8 @@ const ENUMERATED_ATTRIBUTES = new Set( [ * ) ) * .map( ( [ key ] ) => key ) * .sort(); - * - * @type {Set} */ -const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set( [ +const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set< string >( [ 'animation', 'animationIterationCount', 'baselineShift', @@ -241,37 +257,28 @@ const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set( [ /** * Returns true if the specified string is prefixed by one of an array of * possible prefixes. - * - * @param {string} string String to check. - * @param {string[]} prefixes Possible prefixes. - * - * @return {boolean} Whether string has prefix. + * @param string + * @param prefixes */ -export function hasPrefix( string, prefixes ) { +export function hasPrefix( string: string, prefixes: string[] ): boolean { return prefixes.some( ( prefix ) => string.indexOf( prefix ) === 0 ); } /** * Returns true if the given prop name should be ignored in attributes * serialization, or false otherwise. - * - * @param {string} attribute Attribute to check. - * - * @return {boolean} Whether attribute should be ignored. + * @param attribute */ -function isInternalAttribute( attribute ) { +function isInternalAttribute( attribute: string ): boolean { return 'key' === attribute || 'children' === attribute; } /** * Returns the normal form of the element's attribute value for HTML. - * - * @param {string} attribute Attribute name. - * @param {*} value Non-normalized attribute value. - * - * @return {*} Normalized attribute value. + * @param attribute + * @param value */ -function getNormalAttributeValue( attribute, value ) { +function getNormalAttributeValue( attribute: string, value: any ): any { switch ( attribute ) { case 'style': return renderStyle( value ); @@ -279,13 +286,14 @@ function getNormalAttributeValue( attribute, value ) { return value; } + /** * This is a map of all SVG attributes that have dashes. Map(lower case prop => dashed lower case attribute). * We need this to render e.g strokeWidth as stroke-width. * * List from: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute. */ -const SVG_ATTRIBUTE_WITH_DASHES_LIST = [ +const SVG_ATTRIBUTE_WITH_DASHES_LIST: Record< string, string > = [ 'accentHeight', 'alignmentBaseline', 'arabicForm', @@ -359,11 +367,14 @@ const SVG_ATTRIBUTE_WITH_DASHES_LIST = [ 'writingMode', 'xmlnsXlink', 'xHeight', -].reduce( ( map, attribute ) => { - // The keys are lower-cased for more robust lookup. - map[ attribute.toLowerCase() ] = attribute; - return map; -}, {} ); +].reduce( + ( map, attribute ) => { + // The keys are lower-cased for more robust lookup. + map[ attribute.toLowerCase() ] = attribute; + return map; + }, + {} as Record< string, string > +); /** * This is a map of all case-sensitive SVG attributes. Map(lowercase key => proper case attribute). @@ -371,7 +382,7 @@ const SVG_ATTRIBUTE_WITH_DASHES_LIST = [ * Note that this list only contains attributes that contain at least one capital letter. * Lowercase attributes don't need mapping, since we lowercase all attributes by default. */ -const CASE_SENSITIVE_SVG_ATTRIBUTES = [ +const CASE_SENSITIVE_SVG_ATTRIBUTES: Record< string, string > = [ 'allowReorder', 'attributeName', 'attributeType', @@ -437,17 +448,20 @@ const CASE_SENSITIVE_SVG_ATTRIBUTES = [ 'viewTarget', 'xChannelSelector', 'yChannelSelector', -].reduce( ( map, attribute ) => { - // The keys are lower-cased for more robust lookup. - map[ attribute.toLowerCase() ] = attribute; - return map; -}, {} ); +].reduce( + ( map, attribute ) => { + // The keys are lower-cased for more robust lookup. + map[ attribute.toLowerCase() ] = attribute; + return map; + }, + {} as Record< string, string > +); /** * This is a map of all SVG attributes that have colons. * Keys are lower-cased and stripped of their colons for more robust lookup. */ -const SVG_ATTRIBUTES_WITH_COLONS = [ +const SVG_ATTRIBUTES_WITH_COLONS: Record< string, string > = [ 'xlink:actuate', 'xlink:arcrole', 'xlink:href', @@ -459,19 +473,19 @@ const SVG_ATTRIBUTES_WITH_COLONS = [ 'xml:lang', 'xml:space', 'xmlns:xlink', -].reduce( ( map, attribute ) => { - map[ attribute.replace( ':', '' ).toLowerCase() ] = attribute; - return map; -}, {} ); +].reduce( + ( map, attribute ) => { + map[ attribute.replace( ':', '' ).toLowerCase() ] = attribute; + return map; + }, + {} as Record< string, string > +); /** * Returns the normal form of the element's attribute name for HTML. - * - * @param {string} attribute Non-normalized attribute name. - * - * @return {string} Normalized attribute name. + * @param attribute */ -function getNormalAttributeName( attribute ) { +function getNormalAttributeName( attribute: string ): string { switch ( attribute ) { case 'htmlFor': return 'for'; @@ -500,12 +514,9 @@ function getNormalAttributeName( attribute ) { * - Converts property names to kebab-case, e.g. 'backgroundColor' → 'background-color' * - Leaves custom attributes alone, e.g. '--myBackgroundColor' → '--myBackgroundColor' * - Converts vendor-prefixed property names to -kebab-case, e.g. 'MozTransform' → '-moz-transform' - * - * @param {string} property Property name. - * - * @return {string} Normalized property name. + * @param property */ -function getNormalStylePropertyName( property ) { +function getNormalStylePropertyName( property: string ): string { if ( property.startsWith( '--' ) ) { return property; } @@ -520,13 +531,13 @@ function getNormalStylePropertyName( property ) { /** * Returns the normal form of the style property value for HTML. Appends a * default pixel unit if numeric, not a unitless property, and not zero. - * - * @param {string} property Property name. - * @param {*} value Non-normalized property value. - * - * @return {*} Normalized property value. + * @param property + * @param value */ -function getNormalStylePropertyValue( property, value ) { +function getNormalStylePropertyValue( + property: string, + value: any +): string | number { if ( typeof value === 'number' && 0 !== value && @@ -541,14 +552,15 @@ function getNormalStylePropertyValue( property, value ) { /** * Serializes a React element to string. - * - * @param {import('react').ReactNode} element Element to serialize. - * @param {Object} [context] Context object. - * @param {Object} [legacyContext] Legacy context object. - * - * @return {string} Serialized element. + * @param element + * @param context + * @param legacyContext */ -export function renderElement( element, context, legacyContext = {} ) { +export function renderElement( + element: React.ReactNode, + context?: any, + legacyContext: Record< string, any > = {} +): string { if ( null === element || undefined === element || false === element ) { return ''; } @@ -565,9 +577,10 @@ export function renderElement( element, context, legacyContext = {} ) { return element.toString(); } - const { type, props } = /** @type {{type?: any, props?: any}} */ ( - element - ); + const { type, props } = element as { + type?: any; + props?: any; + }; switch ( type ) { case StrictMode: @@ -575,7 +588,7 @@ export function renderElement( element, context, legacyContext = {} ) { return renderChildren( props.children, context, legacyContext ); case RawHTML: - const { children, ...wrapperProps } = props; + const { children, ...wrapperProps } = props as RawHTMLProps; return renderNativeComponent( ! Object.keys( wrapperProps ).length ? null : 'div', @@ -631,21 +644,17 @@ export function renderElement( element, context, legacyContext = {} ) { /** * Serializes a native component type to string. - * - * @param {?string} type Native component type to serialize, or null if - * rendering as fragment of children content. - * @param {Object} props Props object. - * @param {Object} [context] Context object. - * @param {Object} [legacyContext] Legacy context object. - * - * @return {string} Serialized element. + * @param type + * @param props + * @param context + * @param legacyContext */ export function renderNativeComponent( - type, - props, - context, - legacyContext = {} -) { + type: string | null, + props: HTMLProps, + context?: any, + legacyContext: Record< string, any > = {} +): string { let content = ''; if ( type === 'textarea' && props.hasOwnProperty( 'value' ) ) { // Textarea children can be assigned as value prop. If it is, render in @@ -677,41 +686,23 @@ export function renderNativeComponent( return '<' + type + attributes + '>' + content + ''; } -/** @typedef {import('react').ComponentType} ComponentType */ - /** * Serializes a non-native component type to string. - * - * @param {ComponentType} Component Component type to serialize. - * @param {Object} props Props object. - * @param {Object} [context] Context object. - * @param {Object} [legacyContext] Legacy context object. - * - * @return {string} Serialized element + * @param Component + * @param props + * @param context + * @param legacyContext */ export function renderComponent( - Component, - props, - context, - legacyContext = {} -) { - const instance = new /** @type {import('react').ComponentClass} */ ( - Component - )( props, legacyContext ); - - if ( - typeof ( - // Ignore reason: Current prettier reformats parens and mangles type assertion - // prettier-ignore - /** @type {{getChildContext?: () => unknown}} */ ( instance ).getChildContext - ) === 'function' - ) { - Object.assign( - legacyContext, - /** @type {{getChildContext?: () => unknown}} */ ( - instance - ).getChildContext() - ); + Component: React.ComponentClass, + props: Record< string, any >, + context?: any, + legacyContext: Record< string, any > = {} +): string { + const instance = new Component( props, legacyContext ) as ComponentInstance; + + if ( typeof instance.getChildContext === 'function' ) { + Object.assign( legacyContext, instance.getChildContext() ); } const html = renderElement( instance.render(), context, legacyContext ); @@ -721,20 +712,21 @@ export function renderComponent( /** * Serializes an array of children to string. - * - * @param {ReadonlyArray} children Children to serialize. - * @param {Object} [context] Context object. - * @param {Object} [legacyContext] Legacy context object. - * - * @return {string} Serialized children. + * @param children + * @param context + * @param legacyContext */ -function renderChildren( children, context, legacyContext = {} ) { +function renderChildren( + children: React.ReactNode, + context?: any, + legacyContext: Record< string, any > = {} +): string { let result = ''; - children = Array.isArray( children ) ? children : [ children ]; + const childrenArray = Array.isArray( children ) ? children : [ children ]; - for ( let i = 0; i < children.length; i++ ) { - const child = children[ i ]; + for ( let i = 0; i < childrenArray.length; i++ ) { + const child = childrenArray[ i ]; result += renderElement( child, context, legacyContext ); } @@ -744,12 +736,9 @@ function renderChildren( children, context, legacyContext = {} ) { /** * Renders a props object as a string of HTML attributes. - * - * @param {Object} props Props object. - * - * @return {string} Attributes string. + * @param props */ -export function renderAttributes( props ) { +export function renderAttributes( props: Record< string, any > ): string { let result = ''; for ( const key in props ) { @@ -807,21 +796,19 @@ export function renderAttributes( props ) { /** * Renders a style object as a string attribute value. - * - * @param {Object} style Style object. - * - * @return {string} Style attribute value. + * @param style */ -export function renderStyle( style ) { +export function renderStyle( style: StyleObject | string ): string | undefined { // Only generate from object, e.g. tolerate string value. if ( ! isPlainObject( style ) ) { - return style; + return style as string; } - let result; + let result: string | undefined; - for ( const property in style ) { - const value = style[ property ]; + const styleObj = style as StyleObject; + for ( const property in styleObj ) { + const value = styleObj[ property ]; if ( null === value || undefined === value ) { continue; } diff --git a/packages/element/src/utils.js b/packages/element/src/utils.ts similarity index 51% rename from packages/element/src/utils.js rename to packages/element/src/utils.ts index 580094b78c4916..507086d6baa660 100644 --- a/packages/element/src/utils.js +++ b/packages/element/src/utils.ts @@ -1,16 +1,16 @@ /** * Checks if the provided WP element is empty. * - * @param {*} element WP element to check. - * @return {boolean} True when an element is considered empty. + * @param element WP element to check. + * @return True when an element is considered empty. */ -export const isEmptyElement = ( element ) => { +export const isEmptyElement = ( element: unknown ): boolean => { if ( typeof element === 'number' ) { return false; } if ( typeof element?.valueOf() === 'string' || Array.isArray( element ) ) { - return ! element.length; + return ! ( element as { length: number } ).length; } return ! element; diff --git a/test/e2e/specs/editor/plugins/post-type-locking.spec.js b/test/e2e/specs/editor/plugins/post-type-locking.spec.js index ff02d18c51464b..68cbdfb89f4537 100644 --- a/test/e2e/specs/editor/plugins/post-type-locking.spec.js +++ b/test/e2e/specs/editor/plugins/post-type-locking.spec.js @@ -50,7 +50,8 @@ test.describe( 'Post-type locking', () => { name: 'Empty block', } ) .first() - .click(); + .fill( 'p1' ); + await editor.showBlockToolbar(); await expect( page @@ -166,18 +167,15 @@ test.describe( 'Post-type locking', () => { ).toBeHidden(); } ); - test( 'should allow blocks to be moved', async ( { editor, page } ) => { + test( 'should allow blocks to be moved', async ( { editor } ) => { await editor.canvas .getByRole( 'document', { name: 'Empty block', } ) .first() - .click(); + .fill( 'p1' ); - await page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Move up' } ) - .click(); + await editor.clickBlockToolbarButton( 'Move up' ); await expect.poll( editor.getBlocks ).toMatchObject( [ { @@ -248,18 +246,15 @@ test.describe( 'Post-type locking', () => { ] ); } ); - test( 'should allow blocks to be moved', async ( { editor, page } ) => { + test( 'should allow blocks to be moved', async ( { editor } ) => { await editor.canvas .getByRole( 'document', { name: 'Empty block', } ) .first() - .click(); + .fill( 'p1' ); - await page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Move up' } ) - .click(); + await editor.clickBlockToolbarButton( 'Move up' ); await expect.poll( editor.getBlocks ).toMatchObject( [ { @@ -304,18 +299,15 @@ test.describe( 'Post-type locking', () => { ] ); } ); - test( 'should allow blocks to be moved', async ( { editor, page } ) => { + test( 'should allow blocks to be moved', async ( { editor } ) => { await editor.canvas .getByRole( 'document', { name: 'Empty block', } ) .last() - .click(); + .fill( 'p1' ); - await page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Move up' } ) - .click(); + await editor.clickBlockToolbarButton( 'Move up' ); await expect.poll( editor.getBlocks ).toMatchObject( [ { @@ -409,7 +401,8 @@ test.describe( 'Post-type locking', () => { name: 'Empty block', } ) .last() - .click(); + .fill( 'p1' ); + await editor.showBlockToolbar(); await expect( page @@ -453,7 +446,8 @@ test.describe( 'Post-type locking', () => { name: 'Empty block', } ) .last() - .click(); + .fill( 'p1' ); + await editor.showBlockToolbar(); await expect( page