Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,40 @@
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import { select } from '@wordpress/data';

vi.mock('@wordpress/block-editor', () => ({
useBlockProps: () => ({ className: 'fallback-wrapper' }),
// Minimal PlainText stub: renders a controlled textarea so the
// editable post-title path can be asserted with RTL queries.
PlainText: ({
value,
onChange,
placeholder,
tagName: _tagName,
__experimentalVersion: _version,
...rest
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
tagName?: string;
__experimentalVersion?: number;
[key: string]: unknown;
}) => {
const className =
typeof rest.className === 'string' ? rest.className : undefined;
return (
<textarea
aria-label="Post title"
value={value}
placeholder={placeholder}
className={className}
onChange={(event) => onChange(event.target.value)}
/>
);
},
}));

import {
Expand Down Expand Up @@ -98,17 +128,18 @@ function seedSiteEntity(record: Record<string, unknown>): void {
}

describe('post-* live entity preview (#481)', () => {
it('post-title reads the live page entity title when block context provides postId/postType', () => {
it('post-title renders an editable input bound to the live page entity title', () => {
seedPostEntity('page', 42, { title: 'About us' });

const { getByText, queryByText } = render(
const { getByDisplayValue, queryByText } = render(
<PostTitleEdit
attributes={{}}
context={{ postId: 42, postType: 'page' }}
/>
);

expect(getByText('About us')).not.toBeNull();
// Editable PlainText surfaces the loaded title as its value (#546).
expect(getByDisplayValue('About us')).not.toBeNull();
// The placeholder label must not appear when the entity resolves.
expect(queryByText('Post Title')).toBeNull();
});
Expand All @@ -118,15 +149,15 @@ describe('post-* live entity preview (#481)', () => {
title: { raw: 'Raw title', rendered: 'Rendered title' },
});

const { getByText } = render(
const { getByDisplayValue } = render(
<PostTitleEdit
attributes={{}}
context={{ postId: 42, postType: 'page' }}
/>
);

// `readEntityString` prefers the `raw` form.
expect(getByText('Raw title')).not.toBeNull();
expect(getByDisplayValue('Raw title')).not.toBeNull();
});

it('post-excerpt reads the live entity excerpt', () => {
Expand Down Expand Up @@ -276,14 +307,71 @@ describe('post-* live entity preview (#481)', () => {
it('accepts a numeric-string `postId` from block context', () => {
seedPostEntity('page', 42, { title: 'About us' });

const { getByText } = render(
const { getByDisplayValue } = render(
<PostTitleEdit
attributes={{}}
context={{ postId: '42', postType: 'page' }}
/>
);

expect(getByText('About us')).not.toBeNull();
expect(getByDisplayValue('About us')).not.toBeNull();
});

it('post-title typing updates the displayed value and stages an entity edit (#546)', () => {
seedPostEntity('post', 1, { title: 'Hello' });

const { getByDisplayValue } = render(
<PostTitleEdit
attributes={{}}
context={{ postId: 1, postType: 'post' }}
/>
);

const textarea = getByDisplayValue('Hello') as HTMLTextAreaElement;

act(() => {
fireEvent.change(textarea, { target: { value: 'Hello, world' } });
});

// The mounted consumer must reflect the new value on re-render
// — this is the regression #546 addresses.
expect(
(getByDisplayValue('Hello, world') as HTMLTextAreaElement).value
).toBe('Hello, world');

// And the typed value must round-trip through `editEntityRecord`
// so the metadata-save loop picks it up.
const coreStore = select('core') as {
getEntityRecordEdits: (
kind: string,
name: string,
id: number
) => Record<string, unknown> | null;
};
expect(coreStore.getEntityRecordEdits('postType', 'post', 1)).toEqual({
title: 'Hello, world',
});
});

it('renders a readonly preview (not editable) when the block is inside a query loop (#546)', () => {
seedPostEntity('page', 42, { title: 'About us' });

const { container } = render(
<PostTitleEdit
attributes={{}}
context={{
postId: 42,
postType: 'page',
queryId: 3,
[previewKey]: { id: 99, title: 'Query preview title' },
}}
/>
);

// Query-loop preview wins over the editable live-entity path and
// renders as a non-editable element.
expect(container.querySelector('textarea')).toBeNull();
expect(container.textContent).toContain('Query preview title');
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ import { render } from '@testing-library/react';

vi.mock('@wordpress/block-editor', () => ({
useBlockProps: () => ({ className: 'fallback-wrapper' }),
// Minimal PlainText stub; the query-preview tests never reach the
// editable post-title path, but the import has to resolve when the
// edit module loads.
PlainText: ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label="Post title"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
),
}));

import { createEntityPlaceholderEdit } from '../entity-placeholder-edit';
Expand Down
199 changes: 173 additions & 26 deletions resources/js/visual-editor/blocks/post-title/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,184 @@
/**
* Post Title — edit component.
*
* Server-rendered display block: the real markup is produced by the
* Blade / React / Vue renderers from stamped `_resolved*` attributes.
* The fork previews through `createEntityPlaceholderEdit`:
* Hybrid edit: editable inline against the live post entity when a
* single page/post is in scope (#546), readonly preview otherwise.
*
* 1. The stamped `_resolvedTitle` attribute (front-end / saved-tree
* path) wins when present.
* 2. Falls back to the resolved post in the `artisanpack/postPreview`
* block context — query-loop case (#483).
* 3. Falls back to the live page entity (`postType` / `postId` block
* context, fetched through the core-data shim) so a block placed
* at the page level previews the page's actual title (#481).
* 4. Falls back to a clearly-labelled placeholder.
* Resolution priority, highest first:
*
* Phase I5 entity cluster (#413), live-preview path #481.
* 1. The stamped `_resolvedTitle` attribute (front-end / saved-tree
* path) — render readonly.
* 2. `artisanpack/postPreview` block context — readonly query-loop
* preview (#483).
* 3. Live page entity via `postType` / `postId` block context AND no
* `queryId` in scope — editable `PlainText` wired through the
* core-data shim's `useEntityProp` so typing stages an
* `editEntityRecord` edit and the canvas updates in real time
* (#546). Falls through to (4) when `useEntityProp` returns
* `undefined` for the title (resolver in flight, no record).
* 4. Live page entity readonly preview (#481) — keeps the existing
* "shows the loaded title" behavior for query-loop-adjacent or
* unresolved cases.
* 5. Placeholder label.
*/

import type { CSSProperties, ReactElement } from 'react';
import { useBlockProps, PlainText } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';

import { useEntityProp } from '../../vendor/core-data-shim';
import { TEXT_DOMAIN } from '../../vendor/i18n';
import {
createEntityPlaceholderEdit,
PREVIEW_CONTEXT_KEY,
readEntityString,
} from '../_shared/entity-placeholder-edit';

export default createEntityPlaceholderEdit( {
label: 'Post Title',
resolvedKey: '_resolvedTitle',
kind: 'text',
fromQueryPreview: ( post ) =>
typeof post.title === 'string' && post.title !== ''
? { text: post.title }
: null,
fromPostEntity: ( entity ) => {
const title = readEntityString( entity.title );
return title !== '' ? { text: title } : null;
},
} );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyProps = Record<string, any>;

interface PostEntityIdentity {
readonly postType: string;
readonly postId: number;
}

const placeholderStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: '0.5em',
minHeight: '1.5em',
padding: '0.25em 0.6em',
border: '1px dashed currentColor',
borderRadius: '4px',
opacity: 0.55,
fontStyle: 'italic',
};

function asString( value: unknown ): string {
return typeof value === 'string' ? value : '';
}

function readPostEntityIdentity( context: unknown ): PostEntityIdentity | null {
if ( context === null || typeof context !== 'object' ) {
return null;
}

const record = context as Record<string, unknown>;
const postType = record.postType;
const rawPostId = record.postId;

if ( typeof postType !== 'string' || postType === '' ) {
return null;
}

if (
typeof rawPostId === 'number'
&& Number.isInteger( rawPostId )
&& rawPostId > 0
) {
return { postType, postId: rawPostId };
}

if ( typeof rawPostId === 'string' && /^[1-9]\d*$/.test( rawPostId ) ) {
return { postType, postId: Number.parseInt( rawPostId, 10 ) };
}

return null;
}

function readQueryPreviewTitle( context: unknown ): string | null {
if ( context === null || typeof context !== 'object' ) {
return null;
}

const value = ( context as Record<string, unknown> )[ PREVIEW_CONTEXT_KEY ];

if ( value === null || typeof value !== 'object' ) {
return null;
}

const title = ( value as Record<string, unknown> ).title;
return typeof title === 'string' && title !== '' ? title : null;
}

function isInQueryLoop( context: unknown ): boolean {
if ( context === null || typeof context !== 'object' ) {
return false;
}

return Number.isFinite( ( context as Record<string, unknown> ).queryId );
}

function tagNameForLevel( level: unknown ): string {
if ( typeof level === 'number' && level >= 1 && level <= 6 ) {
return `h${ level }`;
}

if ( level === 0 ) {
return 'p';
}

return 'h2';
}

export default function PostTitleEdit( props: AnyProps ): ReactElement {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const blockProps = ( useBlockProps as any )();
const attributes = props.attributes ?? {};
const context = props.context ?? {};

const identity = readPostEntityIdentity( context );
const inQueryLoop = isInQueryLoop( context );
const TagName = tagNameForLevel( attributes.level );

// Hooks must fire unconditionally. The shim returns `[undefined,
// noop, undefined]` when any of the args are missing, so the
// call is cheap when the block has no live-entity context.
const [ editedTitle, setTitle ] = useEntityProp<
string | { raw?: string; rendered?: string }
>(
identity !== null && ! inQueryLoop ? 'postType' : undefined,
identity !== null && ! inQueryLoop ? identity.postType : undefined,
identity !== null && ! inQueryLoop ? 'title' : undefined,
identity !== null && ! inQueryLoop ? identity.postId : null,
);

// 1. Stamped `_resolvedTitle` — server-rendered preview.
const stamped = asString( attributes._resolvedTitle );
if ( stamped !== '' ) {
return <div { ...blockProps }>{ stamped }</div>;
}

// 2. Query-loop preview — readonly first-post title.
const queryPreviewTitle = readQueryPreviewTitle( context );
if ( queryPreviewTitle !== null ) {
return <div { ...blockProps }>{ queryPreviewTitle }</div>;
}

// 3. Live entity, not in a query loop — editable inline.
if ( identity !== null && ! inQueryLoop ) {
const titleText = readEntityString( editedTitle );

if ( titleText !== '' || editedTitle !== undefined ) {
return (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<PlainText
tagName={ TagName as never }
placeholder={ __( '(no title)', TEXT_DOMAIN ) }
value={ titleText }
onChange={ ( next: string ) => setTitle( next ) }
__experimentalVersion={ 2 }
{ ...blockProps }
/>
);
}
}

// 4. Placeholder.
return (
<div { ...blockProps }>
<span style={ placeholderStyle } contentEditable={ false }>
{ __( 'Post Title', TEXT_DOMAIN ) }
</span>
</div>
);
}
Loading
Loading