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 = + '\nHost
\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(