diff --git a/.gitignore b/.gitignore index fef1eeb8a..baa328e60 100644 --- a/.gitignore +++ b/.gitignore @@ -217,6 +217,7 @@ wp-env/mu-plugins/* !wp-env/mu-plugins/gutenbergkit-cors.php !wp-env/mu-plugins/gutenbergkit-jetpack-blocks.php !wp-env/mu-plugins/gutenbergkit-media-failure.php +!wp-env/mu-plugins/gutenbergkit-templated-post-type.php # Claude .claude/settings.local.json diff --git a/e2e/new-post-template.spec.js b/e2e/new-post-template.spec.js new file mode 100644 index 000000000..337a692d7 --- /dev/null +++ b/e2e/new-post-template.spec.js @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; +import EditorPage from './editor-page'; + +/** A new post of the type `wp-env` registers with a block template. */ +const NEW_TEMPLATED_POST = { + id: -1, + type: 'templated_post', + restBase: 'templated-posts', + restNamespace: 'wp/v2', + status: 'draft', + title: '', + content: '', +}; + +test.describe( 'New post template', () => { + test( 'applies the post type template to an empty new post', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + // An empty preload makes the editor request the post types, as hosts + // preload the site's real ones. + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + const blocks = await editor.getBlocks(); + expect( blocks.map( ( { name } ) => name ) ).toEqual( [ + 'core/heading', + 'core/paragraph', + ] ); + expect( blocks[ 0 ].attributes.content ).toBe( + 'Prefilled heading from the template' + ); + expect( blocks[ 1 ].attributes.placeholder ).toBe( + 'Placeholder paragraph from the template' + ); + + const state = await page.evaluate( () => { + const editorSelect = window.wp.data.select( 'core/editor' ); + return { + isNew: editorSelect.isEditedPostNew(), + status: editorSelect.getEditedPostAttribute( 'status' ), + }; + } ); + expect( state ).toEqual( { isNew: true, status: 'auto-draft' } ); + } ); + + test( 'does not report an untouched template as a change', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + const { content, changed } = await page.evaluate( () => + window.editor.getTitleAndContent() + ); + expect( content ).toContain( 'Prefilled heading from the template' ); + expect( changed ).toBe( false ); + } ); + + test( "locks the post type's template", async ( { page } ) => { + await page.route( + /\/wp\/v2\/types\/templated_post\b/, + async ( route ) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill( { + response, + json: { ...body, template_lock: 'all' }, + } ); + } + ); + + const editor = new EditorPage( page ); + await editor.setup( { post: NEW_TEMPLATED_POST, preloadData: {} } ); + + await expect + .poll( () => + page.evaluate( () => + window.wp.data + .select( 'core/block-editor' ) + .getTemplateLock() + ) + ) + .toBe( 'all' ); + } ); + + test( 'keeps the content of a new post that has some', async ( { + page, + } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { + ...NEW_TEMPLATED_POST, + content: + '\n

Local draft

\n', + }, + preloadData: {}, + } ); + + const blocks = await editor.getBlocks(); + expect( blocks.map( ( { name } ) => name ) ).toEqual( [ + 'core/paragraph', + ] ); + await expect( page.getByText( 'Local draft' ) ).toBeVisible(); + } ); + + test( 'keeps the title of a new post that has one', async ( { page } ) => { + const editor = new EditorPage( page ); + await editor.setup( { + post: { ...NEW_TEMPLATED_POST, title: 'Trip notes' }, + preloadData: {}, + } ); + + await expect( + page.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( 'Trip notes' ); + expect( await editor.getBlocks() ).toEqual( [] ); + } ); +} ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 2511278f7..9713f5371 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -import { getBlockType } from '@wordpress/blocks'; +import { getBlockType, parse, serialize } from '@wordpress/blocks'; import { useHostBridge } from '../use-host-bridge'; const mockGetEditedPostAttribute = vi.fn(); @@ -250,6 +250,47 @@ describe( 'useHostBridge', () => { expect( result.changed ).toBe( false ); } ); + it( 'getTitleAndContent reports changed: false for an untouched template', () => { + const template = + '\n

From the template

\n'; + parse.mockImplementationOnce( ( content ) => content ); + serialize.mockImplementationOnce( ( blocks ) => blocks ); + mockGetEditedPostAttribute.mockReturnValue( '' ); + mockGetEditedPostContent.mockReturnValue( template ); + + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result ).toEqual( { + title: '', + content: template, + changed: false, + } ); + } ); + + it( "getTitleAndContent starts from the host's content when the editor has none", () => { + const content = + '\n

Host

\n'; + parse.mockImplementationOnce( ( value ) => value ); + serialize.mockImplementationOnce( ( blocks ) => blocks ); + mockGetEditedPostAttribute.mockReturnValue( '' ); + mockGetEditedPostContent.mockReturnValueOnce( '' ); + mockGetEditedPostContent.mockReturnValue( content ); + + renderHook( () => + useHostBridge( + { ...defaultPost, content: { raw: content } }, + editorRef, + markBridgeReady + ) + ); + + const result = window.editor.getTitleAndContent(); + expect( result.changed ).toBe( false ); + } ); + it( 'appendTextAtCursor normalizes object-shaped block content', () => { mockGetSelectedBlockClientId.mockReturnValue( 'block-1' ); mockGetBlock.mockReturnValue( { diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index a72f15ab6..9f8f7bd46 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -32,8 +32,12 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { const postTitleRef = useRef( normalizeAttribute( post.title ) ); const postContentRef = useRef( null ); if ( postContentRef.current === null ) { + // Start from the content the editor set up, which includes the template + // applied to a new post, so an untouched post doesn't report a change. postContentRef.current = serialize( - parse( normalizeAttribute( post.content ) ) + parse( + normalizeAttribute( getEditedPostContent() || post.content ) + ) ); } diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 8a8f43ef3..62e0952dd 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -391,32 +391,61 @@ export async function getPost() { if ( hostContent ) { debug( 'Using content from native host' ); + const reference = getPostReference( post ); + const { title, content } = hostContent; return { - ...getPostReference( post ), - status: post?.status || POST_FALLBACKS.status, - title: { raw: hostContent.title }, - content: { raw: hostContent.content }, + ...reference, + status: getPostStatus( reference.id, post?.status, title, content ), + title: { raw: title }, + content: { raw: content }, }; } if ( post ) { debug( 'Native bridge unavailable, using GBKit initial content' ); + const reference = getPostReference( post ); + const title = decodeURIComponent( post.title ); + const content = decodeURIComponent( post.content ); return { - ...getPostReference( post ), - status: post.status || POST_FALLBACKS.status, - title: { raw: decodeURIComponent( post.title ) }, - content: { raw: decodeURIComponent( post.content ) }, + ...reference, + status: getPostStatus( reference.id, post.status, title, content ), + title: { raw: title }, + content: { raw: content }, }; } // Fallback to default empty post return { ...POST_FALLBACKS, + status: 'auto-draft', title: { raw: '' }, content: { raw: '' }, }; } +/** + * Returns the status to seed the post with. + * + * An empty post the server doesn't have yet is an auto-draft, as WP Admin seeds + * new posts, so core treats it as new. A local draft keeps the host's status, + * as core blanks the title of auto-drafts and applies the post type's template + * to them. The status stays local: the editor never saves the post, and no + * bridge message sends it to the host. + * + * @param {number} id The post ID. + * @param {string} [status] The status the host sent. + * @param {string} title The post title. + * @param {string} content The post content. + * @return {string} The status. + */ +function getPostStatus( id, status, title, content ) { + if ( id === POST_FALLBACKS.id && ! title?.trim() && ! content?.trim() ) { + return 'auto-draft'; + } + + return status || POST_FALLBACKS.status; +} + /** * Logs an error to the host app. * diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 86791e3b1..ead172178 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -397,6 +397,65 @@ describe( 'getPost', () => { expect( result.id ).toBe( -1 ); } ); + + it( 'should seed a post without a server ID as an auto-draft', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi + .fn() + .mockResolvedValue( { title: '', content: '' } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'auto-draft' ); + } ); + + it( "should keep the host's status for a local draft with a title", async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( { + title: 'Trip notes', + content: '', + } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'draft' ); + } ); + + it( "should keep the host's status for a local draft with content", async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( { + title: '', + content: '

Day 1

', + } ), + }, + }, + }; + window.GBKit = { + post: { id: -1, type: 'post', status: 'draft' }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'draft' ); + } ); } ); describe( 'fallback to GBKit', () => { @@ -470,6 +529,23 @@ describe( 'getPost', () => { const result = await getPost(); expect( result.id ).toBe( -1 ); + expect( result.status ).toBe( 'draft' ); + } ); + + it( 'should seed an empty post without a server ID as an auto-draft', async () => { + window.GBKit = { + post: { + id: -1, + type: 'post', + status: 'draft', + title: '', + content: '', + }, + }; + + const result = await getPost(); + + expect( result.status ).toBe( 'auto-draft' ); } ); } ); @@ -482,7 +558,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'draft', + status: 'auto-draft', title: { raw: '' }, content: { raw: '' }, restBase: 'posts', diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 1a6c3a09d..8abb8d3a7 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -1,5 +1,5 @@ import { createRoot, StrictMode } from '@wordpress/element'; -import { dispatch, select } from '@wordpress/data'; +import { dispatch, resolveSelect, select } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; import { store as preferencesStore } from '@wordpress/preferences'; @@ -8,6 +8,7 @@ import Layout from '../components/layout'; import { unregisterDisallowedBlocks } from './blocks'; import { getGBKit, getPost } from './bridge'; import { getDefaultEditorSettings } from './editor-settings'; +import { warn } from './logger'; import { primePostEntity } from './prime-post-entity'; /** @@ -54,9 +55,15 @@ export async function initializeEditor( { post.id ); if ( record ) { - dispatch( editorStore ).setupEditor( record, {} ); + dispatch( editorStore ).setupEditor( + record, + {}, + getNewPostTemplate( record ) + ); } + applyPostTypeTemplateSettings( post.type ); + createRoot( document.getElementById( 'root' ) ).render( ); } + +/** + * Returns the post type's template to apply to a new post, if any. + * + * Only auto-drafts get the template, as in core. GBK seeds that status only for + * empty new posts (see `getPost`), so a local draft keeps its blocks. Post types + * without a template have an empty one, which core would still apply as an edit. + * + * @param {Object} post The post. + * @return {Array|undefined} The template, or undefined to leave the post as is. + */ +function getNewPostTemplate( post ) { + if ( post.status !== 'auto-draft' ) { + return undefined; + } + + const template = select( coreStore ).getPostType( post.type )?.template; + return template?.length ? template : undefined; +} + +/** + * Adds the post type's template and its lock to the editor settings, as WP + * Admin does for every post of the type, so the editor enforces a locked + * template. + * + * Only new posts wait for the post type before rendering (see + * `primePostEntity`), so other posts get the lock once it loads. + * + * @param {string} postType The post type. + * @return {Promise} Resolves once the settings are updated or skipped. + */ +async function applyPostTypeTemplateSettings( postType ) { + let type; + try { + type = await resolveSelect( coreStore ).getPostType( postType ); + } catch ( err ) { + warn( `Failed to load post type "${ postType }"`, err ); + return; + } + + if ( ! type?.template?.length ) { + return; + } + + dispatch( editorStore ).updateEditorSettings( { + template: type.template, + templateLock: type.template_lock || false, + } ); +} diff --git a/src/utils/editor.test.jsx b/src/utils/editor.test.jsx index a5b4d790f..37ece3a63 100644 --- a/src/utils/editor.test.jsx +++ b/src/utils/editor.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { dispatch, select } from '@wordpress/data'; +import { dispatch, resolveSelect, select } from '@wordpress/data'; import * as element from '@wordpress/element'; import { registerCoreBlocks } from '@wordpress/block-library'; import { initializeEditor } from './editor'; @@ -18,6 +18,7 @@ vi.mock( '@wordpress/block-library' ); vi.mock( './blocks' ); vi.mock( './bridge' ); vi.mock( './editor-settings' ); +vi.mock( './logger' ); vi.mock( './prime-post-entity', () => ( { primePostEntity: vi.fn() } ) ); vi.mock( '../components/layout', () => ( { default: () => null, @@ -28,6 +29,8 @@ describe( 'initializeEditor', () => { let mockPreferenceDispatch; let mockEditorDispatch; let mockGetEntityRecord; + let mockGetPostType; + let mockResolvePostType; beforeEach( () => { vi.clearAllMocks(); @@ -55,12 +58,21 @@ describe( 'initializeEditor', () => { dispatch.mockImplementation( mockDispatch ); mockGetEntityRecord = vi.fn(); + mockGetPostType = vi.fn(); select.mockImplementation( ( store ) => store.name === 'core' - ? { getEntityRecord: mockGetEntityRecord } + ? { + getEntityRecord: mockGetEntityRecord, + getPostType: mockGetPostType, + } : {} ); + mockResolvePostType = vi.fn().mockResolvedValue( undefined ); + resolveSelect.mockImplementation( () => ( { + getPostType: mockResolvePostType, + } ) ); + getGBKit.mockReturnValue( { themeStyles: true, hideTitle: false, @@ -190,8 +202,114 @@ describe( 'initializeEditor', () => { ); expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( record, - {} + {}, + undefined + ); + expect( render ).toHaveBeenCalled(); + } ); + + it( "should set up a new post with its type's template", async () => { + const record = { + id: -1, + type: 'post', + status: 'auto-draft', + content: { raw: '' }, + }; + const template = [ [ 'core/heading' ] ]; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template } ); + + await initializeEditor(); + + expect( mockGetPostType ).toHaveBeenCalledWith( 'post' ); + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + template + ); + } ); + + it( "should not apply the type's template to a local draft", async () => { + const record = { + id: -1, + type: 'post', + status: 'draft', + title: { raw: 'Trip notes' }, + content: { raw: '' }, + }; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template: [ [ 'core/heading' ] ] } ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + undefined + ); + } ); + + it( "should not apply a post type's empty template", async () => { + const record = { + id: -1, + type: 'post', + status: 'auto-draft', + content: { raw: '' }, + }; + mockGetEntityRecord.mockReturnValue( record ); + mockGetPostType.mockReturnValue( { template: [] } ); + + await initializeEditor(); + + expect( mockEditorDispatch.setupEditor ).toHaveBeenCalledWith( + record, + {}, + undefined + ); + } ); + + it( "should lock the post type's template as WP Admin does", async () => { + const template = [ [ 'core/heading' ] ]; + mockResolvePostType.mockResolvedValue( { + template, + template_lock: 'all', + } ); + + await initializeEditor(); + + expect( mockResolvePostType ).toHaveBeenCalledWith( 'post' ); + await vi.waitFor( () => + expect( + mockEditorDispatch.updateEditorSettings + ).toHaveBeenCalledWith( { template, templateLock: 'all' } ) ); + } ); + + it( 'should leave a post type without a template unlocked', async () => { + mockResolvePostType.mockResolvedValue( { + template: [], + template_lock: false, + } ); + + await initializeEditor(); + await vi.waitFor( () => + expect( mockResolvePostType ).toHaveBeenCalled() + ); + + expect( + mockEditorDispatch.updateEditorSettings + ).not.toHaveBeenCalledWith( + expect.objectContaining( { templateLock: expect.anything() } ) + ); + } ); + + it( 'should render when the post type fails to load', async () => { + const render = vi.fn(); + vi.spyOn( element, 'createRoot' ).mockReturnValueOnce( { render } ); + mockResolvePostType.mockRejectedValue( new Error( 'Offline' ) ); + + await initializeEditor(); + expect( render ).toHaveBeenCalled(); } ); diff --git a/src/utils/prime-post-entity.js b/src/utils/prime-post-entity.js index a23c21bcb..6416b0644 100644 --- a/src/utils/prime-post-entity.js +++ b/src/utils/prime-post-entity.js @@ -21,17 +21,49 @@ import { warn } from './logger'; * @return {Promise} Resolves once the post is seeded. */ export async function primePostEntity( post ) { - // Core builds the configurations from the preloaded `/wp/v2/types` - // response, which `withEditedPostType` ensures includes the post's type. + await Promise.all( [ + resolvePostTypeEntity( post ), + post.status === 'auto-draft' && resolvePostType( post ), + ] ); + + const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); + receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); + finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); +} + +/** + * Resolves core-data's entity configurations for post types. + * + * Core builds the configurations from the preloaded `/wp/v2/types` response, + * which `withEditedPostType` ensures includes the post's type. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the configurations are resolved. + */ +async function resolvePostTypeEntity( post ) { await resolveSelect( coreStore ).getEntitiesConfig( 'postType' ); if ( ! select( coreStore ).getEntityConfig( 'postType', post.type ) ) { warn( `No entity configuration for post type "${ post.type }"` ); } +} - const { receiveEntityRecords, finishResolution } = dispatch( coreStore ); - receiveEntityRecords( 'postType', post.type, buildPostRecord( post ) ); - finishResolution( 'getEntityRecord', [ 'postType', post.type, post.id ] ); +/** + * Loads the post's type, whose template the editor setup applies to new posts. + * + * Only new posts wait for it, as hosts may not preload the type and other posts + * don't need it to render. A failure is logged rather than thrown, as the + * editor works without it. + * + * @param {Post} post The post from the host. + * @return {Promise} Resolves once the post type is loaded or failed. + */ +async function resolvePostType( post ) { + try { + await resolveSelect( coreStore ).getPostType( post.type ); + } catch ( err ) { + warn( `Failed to load post type "${ post.type }"`, err ); + } } /** diff --git a/src/utils/prime-post-entity.test.js b/src/utils/prime-post-entity.test.js index dc1cf9606..b3e624463 100644 --- a/src/utils/prime-post-entity.test.js +++ b/src/utils/prime-post-entity.test.js @@ -40,6 +40,7 @@ describe( 'primePostEntity', () => { let coreDispatch; let getEntityConfig; let getEntitiesConfig; + let getPostType; beforeEach( () => { vi.clearAllMocks(); @@ -50,13 +51,35 @@ describe( 'primePostEntity', () => { }; getEntityConfig = vi.fn( () => ( { name: 'post' } ) ); getEntitiesConfig = vi.fn( () => Promise.resolve( [] ) ); + getPostType = vi.fn( () => Promise.resolve( {} ) ); dispatch.mockReturnValue( coreDispatch ); select.mockReturnValue( { getEntityConfig } ); - resolveSelect.mockReturnValue( { getEntitiesConfig } ); + resolveSelect.mockReturnValue( { getEntitiesConfig, getPostType } ); getGBKit.mockReturnValue( {} ); } ); + it( 'loads the post type of a new post before seeding', async () => { + await primePostEntity( buildPost( { status: 'auto-draft' } ) ); + + expect( getPostType ).toHaveBeenCalledWith( 'post' ); + } ); + + it( 'seeds other posts without waiting for the post type', async () => { + await primePostEntity( buildPost() ); + + expect( getPostType ).not.toHaveBeenCalled(); + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalled(); + } ); + + it( 'seeds the post when the post type fails to load', async () => { + getPostType.mockRejectedValue( new Error( 'Offline' ) ); + + await primePostEntity( buildPost( { status: 'auto-draft' } ) ); + + expect( coreDispatch.receiveEntityRecords ).toHaveBeenCalled(); + } ); + it( "loads core's post type entities before seeding", async () => { await primePostEntity( buildPost() ); diff --git a/wp-env/mu-plugins/gutenbergkit-templated-post-type.php b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php new file mode 100644 index 000000000..66e2bf962 --- /dev/null +++ b/wp-env/mu-plugins/gutenbergkit-templated-post-type.php @@ -0,0 +1,23 @@ + array( + 'name' => 'Templated Posts', + 'singular_name' => 'Templated Post', + ), + 'description' => 'For testing that new posts apply the post type template.', + 'public' => true, + 'show_in_rest' => true, + 'rest_base' => 'templated-posts', + 'supports' => array( 'title', 'editor' ), + 'template' => array( + array( 'core/heading', array( 'content' => 'Prefilled heading from the template' ) ), + array( 'core/paragraph', array( 'placeholder' => 'Placeholder paragraph from the template' ) ), + ), + ) ); +} );