本部分介绍如何为我们的应用添加一个 编辑 功能。这是我们即将构建的内容预览:

构建编辑表单

步骤 1:添加编辑按钮

如果没有 编辑 按钮,我们就无法拥有 编辑 表单,因此让我们首先在我们的 PagesList 组件中添加一个:

import { Button } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';

const PageEditButton = () => (
    <Button variant="primary">
        Edit
    </Button>
)

function PagesList( { hasResolved, pages } ) {
    if ( ! hasResolved ) {
        return <Spinner />;
    }
    if ( ! pages?.length ) {
        return <div>No results</div>;
    }

    return (
        <table className="wp-list-table widefat fixed striped table-view-list">
            <thead>
                <tr>
                    <td>Title</td>
                    <td style={{width: 120}}>Actions</td>
                </tr>
            </thead>
            <tbody>
                { pages?.map( ( page ) => (
                    <tr key={page.id}>
                        <td>{ decodeEntities( page.title.rendered ) }</td>
                        <td>
                            <PageEditButton pageId={ page.id } />
                        </td>
                    </tr>
                ) ) }
            </tbody>
        </table>
    );
}

PagesList 中唯一的更改是添加了标记为 操作 的额外列:

构建编辑表单

步骤 2:显示编辑表单

我们的按钮看起来不错,但目前还没有任何功能。要显示编辑表单,我们需要先创建一个 – 让我们创建它:

import { Button, TextControl } from '@wordpress/components';
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    return (
        <div className="my-gutenberg-form">
            <TextControl
                value=''
                label='Page title:'
            />
            <div className="form-buttons">
                <Button onClick={ onSaveFinished } variant="primary">
                    Save
                </Button>
                <Button onClick={ onCancel } variant="tertiary">
                    Cancel
                </Button>
            </div>
        </div>
    );
}

现在让我们使按钮显示我们刚刚创建的表单。由于本教程不专注于网页设计,我们将使用需要最少代码的组件将两者连接起来:Modal。让我们相应地更新 PageEditButton:

import { Button, Modal, TextControl } from '@wordpress/components';

function PageEditButton({ pageId }) {
    const [ isOpen, setOpen ] = useState( false );
    const openModal = () => setOpen( true );
    const closeModal = () => setOpen( false );
    return (
        <>
            <Button
                onClick={ openModal }
                variant="primary"
            >
                Edit
            </Button>
            { isOpen && (
                <Modal onRequestClose={ closeModal } title="Edit page">
                    <EditPageForm
                        pageId={pageId}
                        onCancel={closeModal}
                        onSaveFinished={closeModal}
                    />
                </Modal>
            ) }
        </>
    )
}

现在点击 编辑 按钮,您应该看到以下模态框:

构建编辑表单

太好了!我们现在有了一个基本的工作用户界面。

步骤 3:用页面详情填充表单

我们希望 EditPageForm 显示当前编辑页面的标题。您可能注意到它没有接收 page prop,只接收 pageId。没关系。Gutenberg Data 允许我们轻松从任何组件访问实体记录。

在这种情况下,我们需要使用 getEntityRecord 选择器。记录列表已经通过 MyFirstApp 中的 getEntityRecords 调用可用,因此甚至不会涉及任何额外的 HTTP 请求 – 我们会立即获取缓存的记录。

这是如何在浏览器的开发工具中尝试它的:

wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 );  // Replace 9 with an actual page ID

让我们相应地更新 EditPageForm:

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    const page = useSelect(
        select => select( coreDataStore ).getEntityRecord( 'postType', 'page', pageId ),
        [pageId]
    );
    return (
        <div className="my-gutenberg-form">
            <TextControl
                label='Page title:'
                value={ page.title.rendered }
            />
            { /* ... */ }
        </div>
    );
}

现在它应该看起来像这样:

构建编辑表单

步骤 4:使页面标题字段可编辑

我们的 页面标题 字段有一个问题:您无法编辑它。它接收一个固定的 value,但在输入时不会更新它。我们需要一个 onChange 处理程序。

您可能在其他 React 应用中见过类似的模式。这被称为 “受控组件”:

function VanillaReactForm({ initialTitle }) {
    const [title, setTitle] = useState( initialTitle );
    return (
        <TextControl
            value={ title }
            onChange={ setTitle }
        />
    );
}

在 Gutenberg Data 中更新实体记录是类似的,但不同于使用 setTitle 存储到本地(组件级别)状态,我们使用 editEntityRecord 操作将更新存储在 Redux 状态中。这是如何在浏览器的开发工具中尝试它的:

// We need a valid page ID to call editEntityRecord, so let's get the first available one using getEntityRecords.
const pageId = wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )[0].id;

// Update the title
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', pageId, { title: 'updated title' } );

此时,您可能想知道 如何 editEntityRecord 比 useState 更好?答案是它提供了一些您否则无法获得的功能。

首先,我们可以像检索数据一样轻松保存更改,并确保所有缓存都会正确更新。

其次,通过 editEntityRecord 应用的更改可以通过 undo 和 redo 操作轻松撤销。

最后,由于更改存在于 Redux 状态中,它们是“全局”的,可以被其他组件访问。例如,我们可以让 PagesList 显示当前编辑的标题。

关于最后一点,让我们看看当我们使用 getEntityRecord 访问我们刚刚更新的实体记录时会发生什么:

wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title

它没有反映编辑。怎么了?

嗯,<PagesList /> 渲染 getEntityRecord() 返回的数据。如果 getEntityRecord() 反映了更新的标题,那么用户在 TextControl 中输入的任何内容也会立即显示在 <PagesList /> 中。这不是我们想要的。编辑不应在用户决定保存它们之前泄漏到表单之外。

Gutenberg Data 通过区分 实体记录 和 已编辑的实体记录 来解决这个问题。实体记录 反映来自 API 的数据并忽略任何本地编辑,而 已编辑的实体记录 还具有所有本地编辑应用在其上。两者同时存在于 Redux 状态中。

让我们看看如果我们调用 getEditedEntityRecord 会发生什么:

wp.data.select( 'core' ).getEditedEntityRecord( 'postType', 'page', pageId ).title
// "updated title"

wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
// { "rendered": "<original, unchanged title>", "raw": "..." }

如您所见,实体记录的 title 是一个对象,而已编辑的实体记录的 title 是一个字符串。

这并非偶然。像 title、excerpt 和 content 这样的字段可能包含 短代码 或 动态块,这意味着它们只能在服务器上渲染。对于此类字段,REST API 同时提供 raw 标记和 rendered 字符串。例如,在块编辑器中,content.rendered 可用于视觉预览,而 content.raw 可用于填充代码编辑器。

那么为什么“已编辑实体记录”的 content 是一个字符串呢?由于 JavaScript 无法正确渲染任意的块标记,它仅存储不带 rendered 部分的 raw 标记。既然这是一个字符串,整个字段就变成了字符串。

我们现在可以相应地更新 EditPageForm。我们可以使用 useDispatch hook 来访问操作,类似于我们使用 useSelect 来访问选择器:

import { useDispatch } from '@wordpress/data';

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    const page = useSelect(
        select => select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
        [ pageId ]
    );
    const { editEntityRecord } = useDispatch( coreDataStore );
    const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title } );

    return (
        <div className="my-gutenberg-form">
            <TextControl
                label="Page title:"
                value={ page.title }
                onChange={ handleChange }
            />
            <div className="form-buttons">
                <Button onClick={ onSaveFinished } variant="primary">
                    Save
                </Button>
                <Button onClick={ onCancel } variant="tertiary">
                    Cancel
                </Button>
            </div>
        </div>
    );
}

我们添加了一个 onChange 处理器来通过 editEntityRecord action 跟踪编辑,然后将选择器更改为 getEditedEntityRecord,以便 page.title 始终反映更改。

现在的样子如下:

构建编辑表单

第 5 步:保存表单数据

现在我们可以编辑页面标题,让我们也确保可以保存它。在 Gutenberg 数据中,我们使用 saveEditedEntityRecord action 将更改保存到 WordPress REST API。它会发送请求,处理结果,并更新 Redux 状态中的缓存数据。

这是一个您可以在浏览器开发工具中尝试的示例:

// Replace 9 with an actual page ID
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', 9, { title: 'updated title' } );
wp.data.dispatch( 'core' ).saveEditedEntityRecord( 'postType', 'page', 9 );

上面的代码片段保存了一个新标题。与之前不同,getEntityRecord 现在反映了更新的标题:

// Replace 9 with an actual page ID
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ).title.rendered
// "updated title"

实体记录会在 REST API 请求完成后立即更新以反映任何保存的更改。

这就是带有功能 保存 按钮的 EditPageForm 的样子:

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    // ...
    const { saveEditedEntityRecord } = useDispatch( coreDataStore );
    const handleSave = () => saveEditedEntityRecord( 'postType', 'page', pageId );

    return (
        <div className="my-gutenberg-form">
            {/* ... */}
            <div className="form-buttons">
                <Button onClick={ handleSave } variant="primary">
                    Save
                </Button>
                {/* ... */}
            </div>
        </div>
    );
}

它有效,但还有一个问题需要修复:表单模态框不会自动关闭,因为我们从未调用 onSaveFinished。幸运的是,saveEditedEntityRecord 返回一个 promise,在保存操作完成后解析。让我们在 EditPageForm 中利用它:

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    // ...
    const handleSave = async () => {
        await saveEditedEntityRecord( 'postType', 'page', pageId );
        onSaveFinished();
    };
    // ...
}

第 6 步:处理错误

我们乐观地假设 保存 操作总是会成功。不幸的是,它可能会以多种方式失败:

  • 网站可能已关闭
  • 更新可能无效
  • 页面可能已被其他人删除

为了在这些情况发生时通知用户,我们需要进行两项调整。我们不想在更新失败时关闭表单模态框。saveEditedEntityRecord 返回的 promise 仅在更新实际成功时才以更新的记录解析。当出现问题时,它会以空值解析。让我们利用它来保持模态框打开:

function EditPageForm( { pageId, onSaveFinished } ) {
    // ...
    const handleSave = async () => {
        const updatedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
        if ( updatedRecord ) {
            onSaveFinished();
        }
    };
    // ...
}

太好了!现在,让我们显示错误消息。可以使用 getLastEntitySaveError 选择器获取失败详情:

// Replace 9 with an actual page ID
wp.data.select( 'core' ).getLastEntitySaveError( 'postType', 'page', 9 )

以下是如何在 EditPageForm 中使用它:

function EditPageForm( { pageId, onSaveFinished } ) {
    // ...
    const { lastError, page } = useSelect(
        select => ({
            page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
            lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId )
        }),
        [ pageId ]
    )
    // ...
    return (
        <div className="my-gutenberg-form">
            {/* ... */}
            { lastError ? (
                <div className="form-error">
                    Error: { lastError.message }
                </div>
            ) : false }
            {/* ... */}
        </div>
    );
}

太好了!EditPageForm 现在完全了解错误。

让我们看看这个错误消息的实际效果。我们将触发一个无效的更新并让它失败。帖子标题很难破坏,所以让我们将 date 属性设置为 -1——这是一个保证的验证错误:

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    // ...
    const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title, date: -1 } );
    // ...
}

一旦您刷新页面,打开表单,更改标题并点击保存,您应该看到以下错误消息:

构建编辑表单

太棒了!我们现在可以恢复 handleChange 的先前版本,然后进入下一步。

第 7 步:状态指示器

我们的表单还有一个最后的问题:没有视觉反馈。我们无法确定 保存 按钮是否有效,直到表单消失或显示错误消息。

我们将澄清并向用户传达两种状态:保存中 和 未检测到更改。相关的选择器是 isSavingEntityRecord 和 hasEditsForEntityRecord。与 getEntityRecord 不同,它们从不发出任何 HTTP 请求,而只返回当前实体记录状态。

让我们在 EditPageForm 中使用它们:

function EditPageForm( { pageId, onSaveFinished } ) {
    // ...
    const { isSaving, hasEdits, /* ... */ } = useSelect(
        select => ({
            isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
            hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
            // ...
        }),
        [ pageId ]
    )
}

我们现在可以使用 isSaving 和 hasEdits 在保存进行时显示加载动画,并在没有编辑时将保存按钮置灰:

function EditPageForm( { pageId, onSaveFinished } ) {
    // ...
    return (
        // ...
        <div className="form-buttons">
            <Button onClick={ handleSave } variant="primary" disabled={ ! hasEdits || isSaving }>
                { isSaving ? (
                    <>
                        <Spinner/>
                        保存中
                    </>
                ) : '保存' }
            </Button>
            <Button
                onClick={ onCancel }
                variant="tertiary"
                disabled={ isSaving }
            >
                取消
            </Button>
        </div>
        // ...
    );
}

请注意,当没有编辑内容且页面当前正在保存时,我们会禁用 保存 按钮。这是为了防止用户意外双击该按钮。

此外,中断正在进行的 保存 操作不受 @wordpress/data 支持,因此我们也条件性地禁用了 取消 按钮。

以下是实际运行效果:

构建编辑表单
构建编辑表单

将所有组件整合在一起

所有部分都已就位,太棒了!以下是本章中构建的所有内容汇总:

import { useDispatch } from '@wordpress/data';
import { Button, Modal, TextControl } from '@wordpress/components';

function PageEditButton( { pageId } ) {
    const [ isOpen, setOpen ] = useState( false );
    const openModal = () => setOpen( true );
    const closeModal = () => setOpen( false );
    return (
        <>
            <Button onClick={ openModal } variant="primary">
                编辑
            </Button>
            { isOpen && (
                <Modal onRequestClose={ closeModal } title="编辑页面">
                    <EditPageForm
                        pageId={ pageId }
                        onCancel={ closeModal }
                        onSaveFinished={ closeModal }
                    />
                </Modal>
            ) }
        </>
    );
}

function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
    const { page, lastError, isSaving, hasEdits } = useSelect(
        ( select ) => ( {
            page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
            lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId ),
            isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
            hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
        } ),
        [ pageId ]
    );

    const { saveEditedEntityRecord, editEntityRecord } = useDispatch( coreDataStore );
    const handleSave = async () => {
        const savedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
        if ( savedRecord ) {
            onSaveFinished();
        }
    };
    const handleChange = ( title ) =>  editEntityRecord( 'postType', 'page', page.id, { title } );

    return (
        <div className="my-gutenberg-form">
            <TextControl
                label="页面标题:"
                value={ page.title }
                onChange={ handleChange }
            />
            { lastError ? (
                <div className="form-error">错误:{ lastError.message }</div>
            ) : (
                false
            ) }
            <div className="form-buttons">
                <Button
                    onClick={ handleSave }
                    variant="primary"
                    disabled={ ! hasEdits || isSaving }
                >
                    { isSaving ? (
                        <>
                            <Spinner/>
                            保存中
                        </>
                    ) : '保存' }
                </Button>
                <Button
                    onClick={ onCancel }
                    variant="tertiary"
                    disabled={ isSaving }
                >
                    取消
                </Button>
            </div>
        </div>
    );
}

下一步是什么?