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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions e2e/new-post-template.spec.js
Original file line number Diff line number Diff line change
@@ -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:
'<!-- wp:paragraph -->\n<p>Local draft</p>\n<!-- /wp:paragraph -->',
},
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( [] );
} );
} );
43 changes: 42 additions & 1 deletion src/components/editor/test/use-host-bridge.test.jsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -250,6 +250,47 @@ describe( 'useHostBridge', () => {
expect( result.changed ).toBe( false );
} );

it( 'getTitleAndContent reports changed: false for an untouched template', () => {
const template =
'<!-- wp:heading -->\n<h2>From the template</h2>\n<!-- /wp:heading -->';
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 =
'<!-- wp:paragraph -->\n<p>Host</p>\n<!-- /wp:paragraph -->';
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( {
Expand Down
6 changes: 5 additions & 1 deletion src/components/editor/use-host-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
)
);
Comment thread
dcalhoun marked this conversation as resolved.
Resolved
}

Expand Down
45 changes: 37 additions & 8 deletions src/utils/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
78 changes: 77 additions & 1 deletion src/utils/bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Day 1</p>',
} ),
},
},
};
window.GBKit = {
post: { id: -1, type: 'post', status: 'draft' },
};

const result = await getPost();

expect( result.status ).toBe( 'draft' );
} );
} );

describe( 'fallback to GBKit', () => {
Expand Down Expand Up @@ -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' );
} );
} );

Expand All @@ -482,7 +558,7 @@ describe( 'getPost', () => {
expect( result ).toEqual( {
id: -1,
type: 'post',
status: 'draft',
status: 'auto-draft',
title: { raw: '' },
content: { raw: '' },
restBase: 'posts',
Expand Down
Loading
Loading