docs
216
docs/how-to-guides/data-basics/1-data-basics-setup.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 环境设置
|
||||
|
||||
我们将把应用程序构建为WordPress插件,这意味着您需要先安装WordPress本体。其中一种安装方式是按照[快速入门](/docs/contributors/code/getting-started-with-code-contribution.md)页面的说明进行操作。完成环境配置后,您就可以继续学习本教程的后续内容。
|
||||
|
||||
另外,本教程将大量涉及Redux相关概念,例如状态(state)、操作(actions)和选择器(selectors)。如果您对这些概念不熟悉,建议先阅读[Redux入门指南](https://redux.js.org/introduction/getting-started)。
|
||||
|
||||
## 创建插件
|
||||
|
||||
我们将在WordPress插件中完成所有开发工作。首先在本地WordPress环境的`wp-content/plugins/my-first-gutenberg-app`目录中创建以下四个文件:
|
||||
|
||||
- my-first-gutenberg-app.php - 用于创建新的管理页面
|
||||
- src/index.js - 存放JavaScript应用程序代码
|
||||
- src/style.css - 存放基础样式表
|
||||
- package.json - 用于构建流程配置
|
||||
|
||||
请使用以下代码片段创建这些文件:
|
||||
|
||||
**src/index.js:**
|
||||
|
||||
```js
|
||||
import { createRoot } from 'react-dom';
|
||||
import './style.css';
|
||||
|
||||
function MyFirstApp() {
|
||||
return <span>Hello from JavaScript!</span>;
|
||||
}
|
||||
|
||||
const root = createRoot( document.getElementById( 'my-first-gutenberg-app' ) );
|
||||
window.addEventListener(
|
||||
'load',
|
||||
function () {
|
||||
root.render(
|
||||
<MyFirstApp />,
|
||||
);
|
||||
},
|
||||
false
|
||||
);
|
||||
```
|
||||
|
||||
**src/style.css:**
|
||||
|
||||
```css
|
||||
.toplevel_page_my-first-gutenberg-app #wpcontent {
|
||||
background: #fff;
|
||||
height: 1000px;
|
||||
}
|
||||
button .components-spinner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
}
|
||||
.my-gutenberg-form .form-buttons {
|
||||
margin-top: 20px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
.form-error {
|
||||
color: #cc1818;
|
||||
}
|
||||
.form-buttons button {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.form-buttons .components-spinner {
|
||||
margin-top: 0;
|
||||
}
|
||||
#my-first-gutenberg-app {
|
||||
max-width: 500px;
|
||||
}
|
||||
#my-first-gutenberg-app ul,
|
||||
#my-first-gutenberg-app ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
#my-first-gutenberg-app ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
#my-first-gutenberg-app .components-search-control__input {
|
||||
height: 36px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
#my-first-gutenberg-app .list-controls {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#my-first-gutenberg-app .list-controls .components-search-control {
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
**my-first-gutenberg-app.php:**
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: My first Gutenberg App
|
||||
*
|
||||
*/
|
||||
|
||||
function my_admin_menu() {
|
||||
// 为我们的应用创建新的管理页面
|
||||
add_menu_page(
|
||||
__( 'My first Gutenberg app', 'gutenberg' ),
|
||||
__( 'My first Gutenberg app', 'gutenberg' ),
|
||||
'manage_options',
|
||||
'my-first-gutenberg-app',
|
||||
function () {
|
||||
echo '
|
||||
<h2>Pages</h2>
|
||||
<div id="my-first-gutenberg-app"></div>
|
||||
';
|
||||
},
|
||||
'dashicons-schedule',
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
add_action( 'admin_menu', 'my_admin_menu' );
|
||||
|
||||
function load_custom_wp_admin_scripts( $hook ) {
|
||||
// 仅在 ?page=my-first-gutenberg-app 页面加载
|
||||
if ( 'toplevel_page_my-first-gutenberg-app' !== $hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载必需的WordPress包
|
||||
|
||||
// 自动加载导入的依赖项和资源版本
|
||||
$asset_file = include plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
|
||||
|
||||
// 入队CSS依赖
|
||||
foreach ( $asset_file['dependencies'] as $style ) {
|
||||
wp_enqueue_style( $style );
|
||||
}
|
||||
|
||||
// 加载我们的app.js
|
||||
wp_register_script(
|
||||
'my-first-gutenberg-app',
|
||||
plugins_url( 'build/index.js', __FILE__ ),
|
||||
$asset_file['dependencies'],
|
||||
$asset_file['version']
|
||||
);
|
||||
wp_enqueue_script( 'my-first-gutenberg-app' );
|
||||
|
||||
// 加载我们的style.css
|
||||
wp_register_style(
|
||||
'my-first-gutenberg-app',
|
||||
plugins_url( 'build/style-index.css', __FILE__ ),
|
||||
array(),
|
||||
$asset_file['version']
|
||||
);
|
||||
wp_enqueue_style( 'my-first-gutenberg-app' );
|
||||
}
|
||||
|
||||
add_action( 'admin_enqueue_scripts', 'load_custom_wp_admin_scripts' );
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "09-code-data-basics-esnext",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "My first Gutenberg App",
|
||||
"author": "The WordPress Contributors",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"keywords": [
|
||||
"WordPress",
|
||||
"block"
|
||||
],
|
||||
"homepage": "https://github.com/WordPress/gutenberg-examples/",
|
||||
"repository": "git+https://github.com/WordPress/gutenberg-examples.git",
|
||||
"bugs": {
|
||||
"url": "https://github.com/WordPress/gutenberg-examples/issues"
|
||||
},
|
||||
"main": "build/index.js",
|
||||
"devDependencies": {
|
||||
"@wordpress/scripts": "^24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "wp-scripts build",
|
||||
"format": "wp-scripts format",
|
||||
"lint:js": "wp-scripts lint-js",
|
||||
"packages-update": "wp-scripts packages-update",
|
||||
"start": "wp-scripts start"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配置构建流程
|
||||
|
||||
本教程假设读者熟悉ESNext语法和构建工具(如webpack)的概念。如果这些概念让您感到困惑,建议先阅读[JavaScript构建环境配置入门指南](/docs/how-to-guides/javascript/js-build-setup.md)。
|
||||
|
||||
要安装构建工具,请使用终端进入插件目录并运行`npm install`命令。
|
||||
|
||||
所有依赖项安装完成后,只需运行`npm start`即可!终端中将运行一个监听器。之后您可以在文本编辑器中随意编辑代码,每次保存后都会自动构建。
|
||||
|
||||
## 测试运行效果
|
||||
|
||||
现在进入插件页面,您应该能看到名为**My first Gutenberg App**的插件。请激活该插件,此时会出现一个标为_My first Gutenberg app_的新菜单项。点击该菜单项后,您将看到一个显示_Hello from JavaScript!_的页面:
|
||||
|
||||

|
||||
|
||||
恭喜!您现在可以开始构建应用程序了!
|
||||
|
||||
## 后续步骤
|
||||
|
||||
- 上一部分:[介绍](/docs/how-to-guides/data-basics/README.md)
|
||||
- 下一部分:[构建基础页面列表](/docs/how-to-guides/data-basics/2-building-a-list-of-pages.md)
|
||||
- (可选)在block-development-examples仓库中查看[完整应用示例](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)
|
||||
448
docs/how-to-guides/data-basics/2-building-a-list-of-pages.md
Normal file
@@ -0,0 +1,448 @@
|
||||
### 整合所有模块
|
||||
|
||||
所有组件已就位,太棒了!以下是我们应用的完整JavaScript代码:
|
||||
|
||||
```js
|
||||
import { useState } from 'react';
|
||||
import { createRoot } from 'react-dom';
|
||||
import { SearchControl, Spinner } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import './style.css';
|
||||
|
||||
function MyFirstApp() {
|
||||
const [ searchTerm, setSearchTerm ] = useState( '' );
|
||||
const { pages, hasResolved } = useSelect(
|
||||
( select ) => {
|
||||
const query = {};
|
||||
if ( searchTerm ) {
|
||||
query.search = searchTerm;
|
||||
}
|
||||
const selectorArgs = [ 'postType', 'page', query ];
|
||||
return {
|
||||
pages: select( coreDataStore ).getEntityRecords(
|
||||
...selectorArgs
|
||||
),
|
||||
hasResolved: select( coreDataStore ).hasFinishedResolution(
|
||||
'getEntityRecords',
|
||||
selectorArgs
|
||||
),
|
||||
};
|
||||
},
|
||||
[ searchTerm ]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchControl onChange={ setSearchTerm } value={ searchTerm } />
|
||||
<PagesList hasResolved={ hasResolved } pages={ pages } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesList( { hasResolved, pages } ) {
|
||||
if ( ! hasResolved ) {
|
||||
return <Spinner />;
|
||||
}
|
||||
if ( ! pages?.length ) {
|
||||
return <div>暂无结果</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped table-view-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>标题</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ pages?.map( ( page ) => (
|
||||
<tr key={ page.id }>
|
||||
<td>{ decodeEntities( page.title.rendered ) }</td>
|
||||
</tr>
|
||||
) ) }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const root = createRoot(
|
||||
document.querySelector( '#my-first-gutenberg-app' )
|
||||
);
|
||||
window.addEventListener(
|
||||
'load',
|
||||
function () {
|
||||
root.render(
|
||||
<MyFirstApp />
|
||||
);
|
||||
},
|
||||
false
|
||||
);
|
||||
```
|
||||
|
||||
现在只需刷新页面即可体验全新的状态指示器:
|
||||
|
||||

|
||||

|
||||
|
||||
## 后续步骤
|
||||
|
||||
* **上一部分:** [环境设置](/docs/how-to-guides/data-basics/1-data-basics-setup.md)
|
||||
* **下一部分:** [构建编辑表单](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)
|
||||
* (可选)在block-development-examples代码库中查看[完整应用](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)
|
||||
|
||||
### 使用核心数据替代直接调用API
|
||||
|
||||
让我们稍作停顿,思考一下另一种可能采用的方法——直接操作API——所带来的弊端。设想我们直接发送API请求:
|
||||
|
||||
```js
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
const [pages, setPages] = useState( [] );
|
||||
useEffect( () => {
|
||||
const url = '/wp-json/wp/v2/pages?search=' + searchTerm;
|
||||
apiFetch( { url } )
|
||||
.then( setPages )
|
||||
}, [searchTerm] );
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
在核心数据之外进行操作,我们需要解决两个问题。
|
||||
|
||||
首先,乱序更新。搜索“About”会触发五个API请求,分别过滤`A`、`Ab`、`Abo`、`Abou`和`About`。这些请求的完成顺序可能与启动顺序不同。有可能_search=A_在_search=About_之后才解析完成,从而导致我们显示错误的数据。
|
||||
|
||||
Gutenberg数据通过在幕后处理异步部分来解决这个问题。`useSelect`会记住最近的调用,并仅返回我们预期的数据。
|
||||
|
||||
其次,每次按键都会触发一个API请求。如果你输入`About`,删除它,然后重新输入,即使我们可以重用数据,也会总共发出10个请求。
|
||||
|
||||
Gutenberg数据通过缓存由`getEntityRecords()`触发的API请求的响应,并在后续调用中重用它们来解决这个问题。当其他组件依赖相同的实体记录时,这一点尤其重要。
|
||||
|
||||
总而言之,核心数据内置的工具旨在解决典型问题,以便你可以专注于应用程序本身。
|
||||
|
||||
## 步骤5:加载指示器
|
||||
|
||||
我们的搜索功能存在一个问题。我们无法完全确定它仍在搜索还是显示无结果:
|
||||
|
||||

|
||||
|
||||
像“加载中...”或“无结果”这样的几条消息可以澄清状态。让我们来实现它们!首先,`PagesList`需要了解当前状态:
|
||||
|
||||
```js
|
||||
import { SearchControl, Spinner } from '@wordpress/components';
|
||||
function PagesList( { hasResolved, pages } ) {
|
||||
if ( !hasResolved ) {
|
||||
return <Spinner/>
|
||||
}
|
||||
if ( !pages?.length ) {
|
||||
return <div>无结果</div>
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
|
||||
return (
|
||||
<div>
|
||||
// ...
|
||||
<PagesList hasResolved={ hasResolved } pages={ pages }/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
请注意,我们没有构建自定义的加载指示器,而是利用了[Spinner](https://developer.wordpress.org/block-editor/reference-guides/components/spinner/)组件。
|
||||
|
||||
我们仍然需要知道页面选择器`hasResolved`与否。我们可以使用`hasFinishedResolution`选择器来查明:
|
||||
|
||||
`wp.data.select('core').hasFinishedResolution( 'getEntityRecords', [ 'postType', 'page', { search: 'home' } ] )`
|
||||
|
||||
它接受选择器的名称和_你传递给该选择器的完全相同参数_,如果数据已加载则返回`true`,如果我们仍在等待则返回`false`。让我们将其添加到`useSelect`中:
|
||||
|
||||
```js
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
const { pages, hasResolved } = useSelect( select => {
|
||||
// ...
|
||||
return {
|
||||
pages: select( coreDataStore ).getEntityRecords( 'postType', 'page', query ),
|
||||
hasResolved:
|
||||
select( coreDataStore ).hasFinishedResolution( 'getEntityRecords', ['postType', 'page', query] ),
|
||||
}
|
||||
}, [searchTerm] );
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
还有最后一个问题。很容易出现拼写错误,最终传递给`getEntityRecords`和`hasFinishedResolution`的参数不同。确保它们完全相同至关重要。我们可以通过将参数存储在变量中来消除这种风险:
|
||||
|
||||
```js
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
const { pages, hasResolved } = useSelect( select => {
|
||||
// ...
|
||||
const selectorArgs = [ 'postType', 'page', query ];
|
||||
return {
|
||||
pages: select( coreDataStore ).getEntityRecords( ...selectorArgs ),
|
||||
hasResolved:
|
||||
select( coreDataStore ).hasFinishedResolution( 'getEntityRecords', selectorArgs ),
|
||||
}
|
||||
}, [searchTerm] );
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
瞧!大功告成。
|
||||
|
||||
# 构建页面列表
|
||||
|
||||
在这一部分,我们将构建一个可筛选的WordPress页面列表。本节完成后,应用将呈现如下效果:
|
||||
|
||||

|
||||
|
||||
让我们逐步了解实现过程。
|
||||
|
||||
## 步骤一:构建PagesList组件
|
||||
|
||||
首先构建一个基础React组件来展示页面列表:
|
||||
|
||||
```js
|
||||
function MyFirstApp() {
|
||||
const pages = [{ id: 'mock', title: '示例页面' }]
|
||||
return <PagesList pages={ pages }/>;
|
||||
}
|
||||
|
||||
function PagesList( { pages } ) {
|
||||
return (
|
||||
<ul>
|
||||
{ pages?.map( page => (
|
||||
<li key={ page.id }>
|
||||
{ page.title }
|
||||
</li>
|
||||
) ) }
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
注意该组件尚未获取真实数据,仅展示预设的页面列表。刷新页面后你将看到:
|
||||
|
||||

|
||||
|
||||
## 步骤二:获取数据
|
||||
|
||||
预设的示例页面并不实用。我们需要从[WordPress REST API](https://developer.wordpress.org/rest-api/)获取真实的页面列表。
|
||||
|
||||
首先请确保存在可获取的页面数据。在WPAdmin中通过侧边栏菜单进入“页面”栏目,确认至少存在四到五个页面:
|
||||
|
||||

|
||||
|
||||
若页面不足,请创建新页面(可参考上图所示标题),注意务必执行*发布*操作而非仅*保存*。
|
||||
|
||||
接下来我们使用[`@wordpress/core-data`](https://github.com/WordPress/gutenberg/tree/trunk/packages/core-data)包来处理WordPress核心API,该包基于[`@wordpress/data`](https://github.com/WordPress/gutenberg/tree/trunk/packages/data)包构建。
|
||||
|
||||
通过[`getEntityRecords`](/docs/reference-guides/data/data-core/#getentityrecords)选择器获取页面列表,该选择器会自动发起API请求、缓存结果并返回记录列表:
|
||||
|
||||
```js
|
||||
wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )
|
||||
```
|
||||
|
||||
在浏览器开发者工具中运行此代码会返回`null`,因为首次运行选择器后,`getEntityRecords`解析器才会请求页面数据。稍等片刻再次运行即可获取完整页面列表。
|
||||
|
||||
*注意:直接运行此命令需确保浏览器当前显示区块编辑器界面(任意页面均可),否则`select( 'core' )`函数将不可用并报错。*
|
||||
|
||||
同理,`MyFirstApp`组件需要在数据就绪后重新运行选择器,这正是`useSelect`钩子的作用:
|
||||
|
||||
```js
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
|
||||
function MyFirstApp() {
|
||||
const pages = useSelect(
|
||||
select =>
|
||||
select( coreDataStore ).getEntityRecords( 'postType', 'page' ),
|
||||
[]
|
||||
);
|
||||
// ...
|
||||
}
|
||||
|
||||
function PagesList({ pages }) {
|
||||
// ...
|
||||
<li key={page.id}>
|
||||
{page.title.rendered}
|
||||
</li>
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
注意我们在index.js中使用`import`语句,这使得插件能通过`wp_enqueue_script`自动加载依赖。所有对`coreDataStore`的引用都会被编译为浏览器开发工具中使用的`wp.data`引用。
|
||||
|
||||
`useSelect`接收两个参数:回调和依赖项。其作用是在依赖项或底层数据存储变更时重新执行回调。可在[数据模块文档](/packages/data/README.md#useselect)中深入了解[useSelect](/packages/data/README.md#useselect)。
|
||||
|
||||
完整代码如下:
|
||||
|
||||
```js
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
function MyFirstApp() {
|
||||
const pages = useSelect(
|
||||
select =>
|
||||
select( coreDataStore ).getEntityRecords( 'postType', 'page' ),
|
||||
[]
|
||||
);
|
||||
return <PagesList pages={ pages }/>;
|
||||
}
|
||||
|
||||
function PagesList( { pages } ) {
|
||||
return (
|
||||
<ul>
|
||||
{ pages?.map( page => (
|
||||
<li key={ page.id }>
|
||||
{ decodeEntities( page.title.rendered ) }
|
||||
</li>
|
||||
) ) }
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
注意文章标题可能包含HTML实体(如`á`),需要使用[`decodeEntities`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-html-entities/)函数将其转换为对应符号(如`á`)。
|
||||
|
||||
刷新页面后将显示类似这样的列表:
|
||||
|
||||

|
||||
|
||||
## 步骤三:转换为表格形式
|
||||
|
||||
```js
|
||||
function PagesList( { pages } ) {
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped table-view-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ pages?.map( page => (
|
||||
<tr key={ page.id }>
|
||||
<td>{ decodeEntities( page.title.rendered ) }</td>
|
||||
</tr>
|
||||
) ) }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## 步骤四:添加搜索框
|
||||
|
||||
当前页面列表虽然简短,但随着内容增长,操作会愈发困难。WordPress管理员通常通过搜索框解决这个问题——现在让我们也来实现一个!
|
||||
|
||||
首先添加搜索字段:
|
||||
|
||||
```js
|
||||
import { useState } from 'react';
|
||||
import { SearchControl } from '@wordpress/components';
|
||||
|
||||
function MyFirstApp() {
|
||||
const [searchTerm, setSearchTerm] = useState( '' );
|
||||
// ...
|
||||
return (
|
||||
<div>
|
||||
<SearchControl
|
||||
onChange={ setSearchTerm }
|
||||
value={ searchTerm }
|
||||
/>
|
||||
{/* ... */ }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
请注意,这里我们并未使用原生`input`标签,而是利用了[SearchControl](https://developer.wordpress.org/block-editor/reference-guides/components/search-control/)组件。其实际效果如下:
|
||||
|
||||

|
||||
|
||||
搜索框初始为空,输入内容会存储在`searchTerm`状态值中。若您不熟悉[useState](https://react.dev/reference/react/useState)钩子函数,可查阅[React官方文档](https://react.dev/reference/react/useState)了解更多。
|
||||
|
||||
现在我们可以仅请求匹配`searchTerm`的页面数据。查阅[WordPress API文档](https://developer.wordpress.org/rest-api/reference/pages/)可知,[/wp/v2/pages](https://developer.wordpress.org/rest-api/reference/pages/)接口支持`search`查询参数,用于_限定返回匹配字符串的结果_。具体使用方法如下:
|
||||
|
||||
```js
|
||||
wp.data.select( 'core' ).getEntityRecords( 'postType', 'page', { search: 'home' } )
|
||||
```
|
||||
|
||||
在浏览器开发者工具中运行此代码段,将触发请求至`/wp/v2/pages?search=home`(而非基础的`/wp/v2/pages`)。
|
||||
|
||||
接下来在`useSelect`调用中实现对应逻辑:
|
||||
|
||||
```js
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
const { pages } = useSelect( select => {
|
||||
const query = {};
|
||||
if ( searchTerm ) {
|
||||
query.search = searchTerm;
|
||||
}
|
||||
return {
|
||||
pages: select( coreDataStore ).getEntityRecords( 'postType', 'page', query )
|
||||
}
|
||||
}, [searchTerm] );
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
当存在搜索词时,`searchTerm`将作为`search`查询参数使用。请注意,`searchTerm`也被列入`useSelect`的依赖项数组,确保在搜索词变更时重新执行`getEntityRecords`。
|
||||
|
||||
最终整合后的`MyFirstApp`组件代码如下:
|
||||
|
||||
```js
|
||||
import { useState } from 'react';
|
||||
import { createRoot } from 'react-dom';
|
||||
import { SearchControl } from '@wordpress/components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { store as coreDataStore } from '@wordpress/core-data';
|
||||
|
||||
function MyFirstApp() {
|
||||
const [searchTerm, setSearchTerm] = useState( '' );
|
||||
const pages = useSelect( select => {
|
||||
const query = {};
|
||||
if ( searchTerm ) {
|
||||
query.search = searchTerm;
|
||||
}
|
||||
return select( coreDataStore ).getEntityRecords( 'postType', 'page', query );
|
||||
}, [searchTerm] );
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchControl
|
||||
onChange={ setSearchTerm }
|
||||
value={ searchTerm }
|
||||
/>
|
||||
<PagesList pages={ pages }/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
大功告成!现在我们可以对结果进行筛选了:
|
||||
|
||||

|
||||
552
docs/how-to-guides/data-basics/3-building-an-edit-form.md
Normal file
@@ -0,0 +1,552 @@
|
||||
## 接下来做什么?
|
||||
|
||||
* **上一篇:** [构建页面列表](/docs/how-to-guides/data-basics/2-building-a-list-of-pages.md)
|
||||
* **下一篇:** [构建创建页面表单](/docs/how-to-guides/data-basics/4-building-a-create-page-form.md)
|
||||
* (可选)在 block-development-examples 代码库中查看[完整示例应用](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)
|
||||
|
||||
# 构建编辑表单
|
||||
|
||||
本节内容将为我们的应用添加*编辑*功能。以下是即将构建功能的预览图:
|
||||
|
||||

|
||||
|
||||
### 步骤一:添加“编辑”按钮
|
||||
|
||||
要实现*编辑*功能首先需要添加编辑按钮,让我们从在`PagesList`组件中添加按钮开始:
|
||||
|
||||
```js
|
||||
import { Button } from '@wordpress/components';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
const PageEditButton = () => (
|
||||
<Button variant="primary">
|
||||
编辑
|
||||
</Button>
|
||||
)
|
||||
|
||||
function PagesList( { hasResolved, pages } ) {
|
||||
if ( ! hasResolved ) {
|
||||
return <Spinner />;
|
||||
}
|
||||
if ( ! pages?.length ) {
|
||||
return <div>暂无结果</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped table-view-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>标题</td>
|
||||
<td style={{width: 120}}>操作</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`组件中唯一的变化是新增了标为“操作”的列:
|
||||
|
||||

|
||||
|
||||
### 步骤二:显示编辑表单
|
||||
|
||||
我们的按钮外观不错但尚未实现功能。要显示编辑表单,首先需要创建它:
|
||||
|
||||
```js
|
||||
import { Button, TextControl } from '@wordpress/components';
|
||||
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
|
||||
return (
|
||||
<div className="my-gutenberg-form">
|
||||
<TextControl
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
value=''
|
||||
label='页面标题:'
|
||||
/>
|
||||
<div className="form-buttons">
|
||||
<Button onClick={ onSaveFinished } variant="primary">
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={ onCancel } variant="tertiary">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
现在让按钮触发显示刚创建的编辑表单。由于本教程不侧重网页设计,我们将使用需要最少代码量的[`Modal`](https://developer.wordpress.org/block-editor/reference-guides/components/modal/)组件将两者连接。更新`PageEditButton`如下:
|
||||
|
||||
```js
|
||||
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>
|
||||
) }
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
现在点击*编辑*按钮,您将看到如下模态框:
|
||||
|
||||

|
||||
|
||||
很好!我们现在有了可操作的基础用户界面。
|
||||
|
||||
### 步骤三:在表单中填充页面详情
|
||||
|
||||
我们需要让`EditPageForm`显示当前编辑页面的标题。您可能注意到它并未接收`page`属性,仅接收`pageId`。这没有问题,Gutenberg Data让我们能够轻松在任何组件中访问实体记录。
|
||||
|
||||
这里我们需要使用[`getEntityRecord`](/docs/reference-guides/data/data-core.md#getentityrecord)选择器。得益于`MyFirstApp`中的`getEntityRecords`调用,记录列表已准备就绪,甚至无需发起额外的HTTP请求——我们可以直接获取缓存记录。
|
||||
|
||||
您可以在浏览器开发工具中这样尝试:
|
||||
|
||||
```js
|
||||
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ); // 将9替换为实际页面ID
|
||||
```
|
||||
|
||||
接下来更新`EditPageForm`:
|
||||
|
||||
```js
|
||||
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
|
||||
const page = useSelect(
|
||||
select => select( coreDataStore ).getEntityRecord( 'postType', 'page', pageId ),
|
||||
[pageId]
|
||||
);
|
||||
return (
|
||||
<div className="my-gutenberg-form">
|
||||
<TextControl
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
label='页面标题:'
|
||||
value={ page.title.rendered }
|
||||
/>
|
||||
{ /* ... */ }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
现在效果应如图所示:
|
||||
|
||||

|
||||
|
||||
### 步骤五:保存表单数据
|
||||
|
||||
既然我们已经能够编辑页面标题,接下来要确保能够保存它。在 Gutenberg 数据系统中,我们使用 `saveEditedEntityRecord` 操作将变更保存到 WordPress REST API。该操作会发送请求、处理结果,并更新 Redux 状态中的缓存数据。
|
||||
|
||||
以下示例可在浏览器开发者工具中尝试:
|
||||
|
||||
```js
|
||||
// 将数字9替换为实际页面ID
|
||||
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', 9, { title: '更新后的标题' } );
|
||||
wp.data.dispatch( 'core' ).saveEditedEntityRecord( 'postType', 'page', 9 );
|
||||
```
|
||||
|
||||
以上代码片段保存了新标题。与之前不同,现在 `getEntityRecord` 会反映更新后的标题:
|
||||
|
||||
```js
|
||||
// 将数字9替换为实际页面ID
|
||||
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ).title.rendered
|
||||
// 返回:"更新后的标题"
|
||||
```
|
||||
|
||||
在 REST API 请求完成后,实体记录会立即更新以反映所有已保存的变更。
|
||||
|
||||
这是带有生效*保存*按钮的 `EditPageForm` 组件示例:
|
||||
|
||||
```js
|
||||
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">
|
||||
保存
|
||||
</Button>
|
||||
{/* ... */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
虽然功能已实现,但还需修复一个问题:表单模态框不会自动关闭,因为我们从未调用 `onSaveFinished`。幸运的是,`saveEditedEntityRecord` 返回的 Promise 会在保存操作完成后解析。让我们在 `EditPageForm` 中利用这个特性:
|
||||
|
||||
```js
|
||||
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
|
||||
// ...
|
||||
const handleSave = async () => {
|
||||
await saveEditedEntityRecord( 'postType', 'page', pageId );
|
||||
onSaveFinished();
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤六:错误处理
|
||||
|
||||
此前我们乐观地假设*保存*操作总能成功。但实际操作可能因以下原因失败:
|
||||
|
||||
* 网站可能宕机
|
||||
* 更新内容可能无效
|
||||
* 页面可能已被他人删除
|
||||
|
||||
为了在出现这些问题时通知用户,我们需要进行两处调整。当更新失败时,我们不希望关闭表单模态框。仅当更新确实成功时,`saveEditedEntityRecord` 返回的 Promise 才会解析为更新后的记录。若出现异常,则会解析为空值。我们可以利用这一点来保持模态框开启状态:
|
||||
|
||||
```js
|
||||
function EditPageForm( { pageId, onSaveFinished } ) {
|
||||
// ...
|
||||
const handleSave = async () => {
|
||||
const updatedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
|
||||
if ( updatedRecord ) {
|
||||
onSaveFinished();
|
||||
}
|
||||
};
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
很好!现在让我们来显示错误信息。可以通过 `getLastEntitySaveError` 选择器获取失败详情:
|
||||
|
||||
```js
|
||||
// 将数字9替换为实际页面ID
|
||||
wp.data.select( 'core' ).getLastEntitySaveError( 'postType', 'page', 9 )
|
||||
```
|
||||
|
||||
以下是在 `EditPageForm` 中的具体应用:
|
||||
|
||||
```js
|
||||
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">
|
||||
错误:{ lastError.message }
|
||||
</div>
|
||||
) : false }
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
太棒了!现在 `EditPageForm` 已能完全感知错误状态。
|
||||
|
||||
让我们通过触发无效更新来查看错误提示效果。由于文章标题很难引发错误,我们可以将 `date` 属性设置为 `-1` —— 这必定会触发验证错误:
|
||||
|
||||
```js
|
||||
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
|
||||
// ...
|
||||
const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title, date: -1 } );
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
刷新页面后,打开表单修改标题并点击保存,您将看到如下错误提示:
|
||||
|
||||

|
||||
|
||||
非常好!现在我们可以**恢复 `handleChange` 函数的先前版本**,继续下一步操作。
|
||||
|
||||
### 步骤七:状态指示器
|
||||
|
||||
我们的表单还存在最后一个问题:缺乏视觉反馈。在表单消失或显示错误信息之前,我们无法完全确定*保存*按钮是否生效。
|
||||
|
||||
现在我们将解决这个问题,并向用户传达两种状态:_保存中_和_未检测到更改_。相关的选择器是`isSavingEntityRecord`和`hasEditsForEntityRecord`。与`getEntityRecord`不同,这些选择器从不发起HTTP请求,仅返回当前实体记录状态。
|
||||
|
||||
让我们在`EditPageForm`中使用它们:
|
||||
|
||||
```js
|
||||
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`在保存过程中显示加载动画,并在无编辑内容时禁用保存按钮:
|
||||
|
||||
```js
|
||||
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`不支持中断正在进行的*保存*操作,我们也相应禁用了*取消*按钮。
|
||||
|
||||
实际运行效果如下:
|
||||
|
||||

|
||||

|
||||
|
||||
### 整体联调
|
||||
|
||||
所有组件都已就位,太棒了!以下是我们本章构建的完整代码:
|
||||
|
||||
```js
|
||||
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
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤四:实现页面标题字段的可编辑功能
|
||||
|
||||
我们的*页面标题*字段存在一个问题:无法编辑。它接收固定值但在输入时不会更新。我们需要一个 `onChange` 处理函数。
|
||||
|
||||
您可能在其他 React 应用中也见过类似的模式,这被称为["受控组件"](https://reactjs.org/docs/forms.html#controlled-components):
|
||||
|
||||
```js
|
||||
function VanillaReactForm({ initialTitle }) {
|
||||
const [title, setTitle] = useState( initialTitle );
|
||||
return (
|
||||
<TextControl
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
value={ title }
|
||||
onChange={ setTitle }
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
在 Gutenberg 数据中更新实体记录与此类似,但不同之处在于,我们不使用 `setTitle` 将数据存储在本地(组件级别)状态,而是使用 `editEntityRecord` 操作将更新存储在 *Redux* 状态中。以下是在浏览器的开发工具中尝试的方法:
|
||||
|
||||
```js
|
||||
// 我们需要一个有效的页面 ID 来调用 editEntityRecord,因此使用 getEntityRecords 获取第一个可用的 ID。
|
||||
const pageId = wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )[0].id;
|
||||
|
||||
// 更新标题
|
||||
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', pageId, { title: '更新后的标题' } );
|
||||
```
|
||||
|
||||
此时,您可能会问,`editEntityRecord` 比 `useState` 好在哪里?答案是它提供了一些额外功能。
|
||||
|
||||
首先,我们可以像检索数据一样轻松地保存更改,并确保所有缓存都能正确更新。
|
||||
|
||||
其次,通过 `editEntityRecord` 应用的更改可以通过 `undo` 和 `redo` 操作轻松撤销或重做。
|
||||
|
||||
最后,由于更改存储在 *Redux* 状态中,它们是“全局的”,可以被其他组件访问。例如,我们可以让 `PagesList` 显示当前编辑的标题。
|
||||
|
||||
关于最后一点,让我们看看使用 `getEntityRecord` 访问刚刚更新的实体记录时会发生什么:
|
||||
|
||||
```js
|
||||
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
|
||||
```
|
||||
|
||||
它并未反映编辑后的内容。这是怎么回事?
|
||||
|
||||
实际上,`<PagesList />` 渲染的是 `getEntityRecord()` 返回的数据。如果 `getEntityRecord()` 反映了更新后的标题,那么用户在 `TextControl` 中输入的任何内容也会立即显示在 `<PagesList />` 中。这并不是我们想要的效果。在用户决定保存之前,编辑内容不应泄漏到表单之外。
|
||||
|
||||
Gutenberg 数据通过区分*实体记录*和*已编辑的实体记录*来解决这个问题。*实体记录*反映来自 API 的数据,忽略任何本地编辑,而*已编辑的实体记录*则在数据基础上应用了所有本地编辑。两者同时存在于 Redux 状态中。
|
||||
|
||||
让我们看看调用 `getEditedEntityRecord` 会发生什么:
|
||||
|
||||
```js
|
||||
wp.data.select( 'core' ).getEditedEntityRecord( 'postType', 'page', pageId ).title
|
||||
// "更新后的标题"
|
||||
|
||||
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
|
||||
// { "rendered": "<原始未更改的标题>", "raw": "..." }
|
||||
```
|
||||
|
||||
如您所见,实体记录的 `title` 是一个对象,而已编辑实体记录的 `title` 是一个字符串。
|
||||
|
||||
这并非偶然。像 `title`、`excerpt` 和 `content` 这样的字段可能包含[短代码](https://developer.wordpress.org/apis/handbook/shortcode/)或[动态区块](/docs/how-to-guides/block-tutorial/creating-dynamic-blocks.md),这意味着它们只能在服务器上渲染。对于这些字段,REST API 同时暴露了 `raw` 标记和 `rendered` 字符串。例如,在区块编辑器中,`content.rendered` 可用于视觉预览,而 `content.raw` 可用于填充代码编辑器。
|
||||
|
||||
那么,为什么已编辑实体记录的 `content` 是一个字符串?由于 JavaScript 无法正确渲染任意的区块标记,它仅存储 `raw` 标记,而不包含 `rendered` 部分。由于这是一个字符串,整个字段就变成了字符串。
|
||||
|
||||
现在我们可以相应地更新 `EditPageForm`。我们可以使用 [`useDispatch`](/packages/data/README.md#usedispatch) 钩子访问操作,类似于使用 `useSelect` 访问选择器:
|
||||
|
||||
```js
|
||||
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
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
label="页面标题:"
|
||||
value={ page.title }
|
||||
onChange={ handleChange }
|
||||
/>
|
||||
<div className="form-buttons">
|
||||
<Button onClick={ onSaveFinished } variant="primary">
|
||||
保存
|
||||
</Button>
|
||||
<Button onClick={ onCancel } variant="tertiary">
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
我们添加了一个 `onChange` 处理函数,通过 `editEntityRecord` 操作跟踪编辑,然后将选择器更改为 `getEditedEntityRecord`,以便 `page.title` 始终反映更改。
|
||||
|
||||
现在的效果如下:
|
||||
|
||||

|
||||
395
docs/how-to-guides/data-basics/4-building-a-create-page-form.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# 构建创建页面表单
|
||||
|
||||
在[上一章节](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)中我们创建了*编辑页面*功能,本章节我们将新增*创建页面*功能。以下是我们即将构建功能的预览:
|
||||
|
||||

|
||||
|
||||
### 步骤一:添加“创建新页面”按钮
|
||||
|
||||
首先我们构建一个用于显示创建页面表单的按钮,这与我们在[第三章节](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)构建的编辑按钮类似:
|
||||
|
||||
```js
|
||||
import { useDispatch } from '@wordpress/data';
|
||||
import { Button, Modal, TextControl } from '@wordpress/components';
|
||||
|
||||
function CreatePageButton() {
|
||||
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="创建新页面">
|
||||
<CreatePageForm
|
||||
onCancel={ closeModal }
|
||||
onSaveFinished={ closeModal }
|
||||
/>
|
||||
</Modal>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePageForm() {
|
||||
// 暂时留空
|
||||
return <div/>;
|
||||
}
|
||||
```
|
||||
|
||||
很好!现在让`MyFirstApp`显示我们全新的按钮:
|
||||
|
||||
```js
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
return (
|
||||
<div>
|
||||
<div className="list-controls">
|
||||
<SearchControl onChange={ setSearchTerm } value={ searchTerm }/>
|
||||
<CreatePageButton/>
|
||||
</div>
|
||||
<PagesList hasResolved={ hasResolved } pages={ pages }/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
最终效果如下所示:
|
||||
|
||||

|
||||
|
||||
### 步骤二:提取受控页面表单
|
||||
|
||||
按钮就位后,我们可以全力构建表单。本教程重点在于数据管理,因此不会构建完整的页面编辑器。表单将仅包含一个字段:文章标题。
|
||||
|
||||
幸运的是,我们在[第三章节](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)构建的`EditPageForm`已经实现了80%的功能。大部分用户界面已就绪,我们将在`CreatePageForm`中复用这些组件。首先将表单UI提取为独立组件:
|
||||
|
||||
```js
|
||||
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
|
||||
// ...
|
||||
return (
|
||||
<PageForm
|
||||
title={ page.title }
|
||||
onChangeTitle={ handleChange }
|
||||
hasEdits={ hasEdits }
|
||||
lastError={ lastError }
|
||||
isSaving={ isSaving }
|
||||
onCancel={ onCancel }
|
||||
onSave={ handleSave }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PageForm( { title, onChangeTitle, hasEdits, lastError, isSaving, onCancel, onSave } ) {
|
||||
return (
|
||||
<div className="my-gutenberg-form">
|
||||
<TextControl
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
label="页面标题:"
|
||||
value={ title }
|
||||
onChange={ onChangeTitle }
|
||||
/>
|
||||
{ lastError ? (
|
||||
<div className="form-error">错误:{ lastError.message }</div>
|
||||
) : (
|
||||
false
|
||||
) }
|
||||
<div className="form-buttons">
|
||||
<Button
|
||||
onClick={ onSave }
|
||||
variant="primary"
|
||||
disabled={ !hasEdits || isSaving }
|
||||
>
|
||||
{ isSaving ? (
|
||||
<>
|
||||
<Spinner/>
|
||||
保存中
|
||||
</>
|
||||
) : '保存' }
|
||||
</Button>
|
||||
<Button
|
||||
onClick={ onCancel }
|
||||
variant="tertiary"
|
||||
disabled={ isSaving }
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
这段代码的质量优化不应改变应用程序的任何功能。让我们尝试编辑页面来确认:
|
||||
|
||||

|
||||
|
||||
很好!编辑表单依然存在,现在我们有了构建新`CreatePageForm`的基础模块。
|
||||
|
||||
### 整合所有代码
|
||||
|
||||
以下是本章节构建的全部内容:
|
||||
|
||||
```js
|
||||
function CreatePageForm( { onCancel, onSaveFinished } ) {
|
||||
const [title, setTitle] = useState();
|
||||
const { lastError, isSaving } = useSelect(
|
||||
( select ) => ( {
|
||||
lastError: select( coreDataStore )
|
||||
.getLastEntitySaveError( 'postType', 'page' ),
|
||||
isSaving: select( coreDataStore )
|
||||
.isSavingEntityRecord( 'postType', 'page' ),
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
|
||||
const { saveEntityRecord } = useDispatch( coreDataStore );
|
||||
const handleSave = async () => {
|
||||
const savedRecord = await saveEntityRecord(
|
||||
'postType',
|
||||
'page',
|
||||
{ title, status: 'publish' }
|
||||
);
|
||||
if ( savedRecord ) {
|
||||
onSaveFinished();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageForm
|
||||
title={ title }
|
||||
onChangeTitle={ setTitle }
|
||||
hasEdits={ !!title }
|
||||
onSave={ handleSave }
|
||||
lastError={ lastError }
|
||||
onCancel={ onCancel }
|
||||
isSaving={ isSaving }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageForm
|
||||
title={ page.title }
|
||||
onChangeTitle={ handleChange }
|
||||
hasEdits={ hasEdits }
|
||||
lastError={ lastError }
|
||||
isSaving={ isSaving }
|
||||
onCancel={ onCancel }
|
||||
onSave={ handleSave }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PageForm( { title, onChangeTitle, hasEdits, lastError, isSaving, onCancel, onSave } ) {
|
||||
return (
|
||||
<div className="my-gutenberg-form">
|
||||
<TextControl
|
||||
__nextHasNoMarginBottom
|
||||
__next40pxDefaultSize
|
||||
label="页面标题:"
|
||||
value={ title }
|
||||
onChange={ onChangeTitle }
|
||||
/>
|
||||
{ lastError ? (
|
||||
<div className="form-error">错误:{ lastError.message }</div>
|
||||
) : (
|
||||
false
|
||||
) }
|
||||
<div className="form-buttons">
|
||||
<Button
|
||||
onClick={ onSave }
|
||||
variant="primary"
|
||||
disabled={ !hasEdits || isSaving }
|
||||
>
|
||||
{ isSaving ? (
|
||||
<>
|
||||
<Spinner/>
|
||||
保存中
|
||||
</>
|
||||
) : '保存' }
|
||||
</Button>
|
||||
<Button
|
||||
onClick={ onCancel }
|
||||
variant="tertiary"
|
||||
disabled={ isSaving }
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
现在只需刷新页面即可体验表单功能:
|
||||
|
||||

|
||||
|
||||
## 后续步骤
|
||||
|
||||
* **下一章节:** [添加删除按钮](/docs/how-to-guides/data-basics/5-adding-a-delete-button.md)
|
||||
* **上一章节:** [构建编辑表单](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)
|
||||
* (可选)在 block-development-examples 代码库中查看[完整应用](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)
|
||||
|
||||
### 步骤三:构建CreatePageForm组件
|
||||
|
||||
`CreatePageForm`组件只需提供渲染`PageForm`组件所需的七个属性:
|
||||
|
||||
* 标题
|
||||
* 标题变更处理函数
|
||||
* 编辑状态标识
|
||||
* 最后错误信息
|
||||
* 保存状态标识
|
||||
* 取消处理函数
|
||||
* 保存处理函数
|
||||
|
||||
具体实现如下:
|
||||
|
||||
#### 标题、标题变更处理、编辑状态
|
||||
|
||||
`EditPageForm`组件更新并保存的是Redux状态中已存在的实体记录,因此我们依赖`editedEntityRecords`选择器。
|
||||
|
||||
而`CreatePageForm`不存在预先的实体记录,只有空表单。用户输入的内容仅存在于本地表单,可通过React的`useState`钩子进行跟踪:
|
||||
|
||||
```js
|
||||
function CreatePageForm( { onCancel, onSaveFinished } ) {
|
||||
const [title, setTitle] = useState();
|
||||
const handleChange = ( title ) => setTitle( title );
|
||||
return (
|
||||
<PageForm
|
||||
title={ title }
|
||||
onChangeTitle={ setTitle }
|
||||
hasEdits={ !!title }
|
||||
{ /* 其他属性 */ }
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 保存处理、取消处理
|
||||
|
||||
在`EditPageForm`中,我们通过`saveEditedEntityRecord('postType', 'page', pageId )`操作保存Redux状态中的编辑内容。
|
||||
|
||||
但`CreatePageForm`既无Redux状态中的编辑内容,也无pageId。此时需要调用的是[`saveEntityRecord`](https://developer.wordpress.org/block-editor/reference-guides/data/data-core/#saveentityrecord)操作(名称中不含Edited),它接收的是代表新实体记录的对象而非pageId。
|
||||
|
||||
传递给`saveEntityRecord`的数据会通过POST请求发送到对应REST API接口。例如执行以下操作:
|
||||
|
||||
```js
|
||||
saveEntityRecord( 'postType', 'page', { title: "测试页面" } );
|
||||
```
|
||||
|
||||
将向[WordPress页面REST API接口](/wp/v2/pages)发起POST请求,请求体中包含单个字段:`title=测试页面`。
|
||||
|
||||
现在我们将其应用到`CreatePageForm`:
|
||||
|
||||
```js
|
||||
function CreatePageForm( { onSaveFinished, onCancel } ) {
|
||||
// ...
|
||||
const { saveEntityRecord } = useDispatch( coreDataStore );
|
||||
const handleSave = async () => {
|
||||
const savedRecord = await saveEntityRecord(
|
||||
'postType',
|
||||
'page',
|
||||
{ title }
|
||||
);
|
||||
if ( savedRecord ) {
|
||||
onSaveFinished();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<PageForm
|
||||
{ /* 其他属性 */ }
|
||||
onSave={ handleSave }
|
||||
onCancel={ onCancel }
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
还需注意:新建页面默认不会被`PagesList`获取。根据REST API文档,`/wp/v2/pages`接口在创建(POST请求)时默认生成`status=draft`的页面,但返回(GET请求)的是`status=publish`的页面。解决方案是显式传递status参数:
|
||||
|
||||
```js
|
||||
function CreatePageForm( { onSaveFinished, onCancel } ) {
|
||||
// ...
|
||||
const { saveEntityRecord } = useDispatch( coreDataStore );
|
||||
const handleSave = async () => {
|
||||
const savedRecord = await saveEntityRecord(
|
||||
'postType',
|
||||
'page',
|
||||
{ title, status: 'publish' }
|
||||
);
|
||||
if ( savedRecord ) {
|
||||
onSaveFinished();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<PageForm
|
||||
{ /* 其他属性 */ }
|
||||
onSave={ handleSave }
|
||||
onCancel={ onCancel }
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
请将此更改应用到本地的`CreatePageForm`组件,接下来处理剩余两个属性。
|
||||
|
||||
#### 最后错误、保存状态
|
||||
|
||||
`EditPageForm`通过`getLastEntitySaveError`和`isSavingEntityRecord`选择器获取错误和进度信息,两者都传递三个参数:`( 'postType', 'page', pageId )`。
|
||||
|
||||
但`CreatePageForm`没有pageId参数。此时可省略pageId参数来获取未指定ID的实体记录信息(即新建记录)。`useSelect`调用与`EditPageForm`非常相似:
|
||||
|
||||
```js
|
||||
function CreatePageForm( { onCancel, onSaveFinished } ) {
|
||||
// ...
|
||||
const { lastError, isSaving } = useSelect(
|
||||
( select ) => ( {
|
||||
// 注意省略了pageId参数:
|
||||
lastError: select( coreDataStore )
|
||||
.getLastEntitySaveError( 'postType', 'page' ),
|
||||
// 注意省略了pageId参数
|
||||
isSaving: select( coreDataStore )
|
||||
.isSavingEntityRecord( 'postType', 'page' ),
|
||||
} ),
|
||||
[]
|
||||
);
|
||||
// ...
|
||||
return (
|
||||
<PageForm
|
||||
{ /* 其他属性 */ }
|
||||
lastError={ lastError }
|
||||
isSaving={ isSaving }
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
大功告成!以下是我们新表单的实际运行效果:
|
||||
|
||||

|
||||

|
||||
440
docs/how-to-guides/data-basics/5-adding-a-delete-button.md
Normal file
@@ -0,0 +1,440 @@
|
||||
## 下一步做什么?
|
||||
|
||||
* **上一部分:** [构建*创建页面表单*](/docs/how-to-guides/data-basics/4-building-a-create-page-form.md)
|
||||
* (可选)在 block-development-examples 代码库中查看[已完成的应用程序](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)
|
||||
|
||||
# 添加删除按钮
|
||||
|
||||
在[上一章节](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)中,我们实现了新建页面的功能,本章节将为应用添加*删除*功能。
|
||||
|
||||
以下是我们即将实现的效果预览:
|
||||
|
||||

|
||||
|
||||
### 步骤一:添加删除按钮
|
||||
|
||||
首先创建 `DeletePageButton` 组件并更新 `PagesList` 组件的用户界面:
|
||||
|
||||
```js
|
||||
import { Button } from '@wordpress/components';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
|
||||
const DeletePageButton = () => (
|
||||
<Button variant="primary">
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
|
||||
function PagesList( { hasResolved, pages } ) {
|
||||
if ( ! hasResolved ) {
|
||||
return <Spinner />;
|
||||
}
|
||||
if ( ! pages?.length ) {
|
||||
return <div>暂无数据</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped table-view-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>标题</td>
|
||||
<td style={{width: 190}}>操作</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ pages?.map( ( page ) => (
|
||||
<tr key={page.id}>
|
||||
<td>{ decodeEntities( page.title.rendered ) }</td>
|
||||
<td>
|
||||
<div className="form-buttons">
|
||||
<PageEditButton pageId={ page.id } />
|
||||
{/* ↓ 这是 PagesList 组件中的唯一改动 */}
|
||||
<DeletePageButton pageId={ page.id }/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) ) }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
此时 PagesList 的显示效果如下:
|
||||
|
||||

|
||||
|
||||
### 步骤二:为按钮绑定删除操作
|
||||
|
||||
在 Gutenberg 数据层中,我们通过 `deleteEntityRecord` 操作从 WordPress REST API 删除实体记录。该操作会发送请求、处理结果并更新 Redux 状态中的缓存数据。
|
||||
|
||||
以下是在浏览器开发者工具中尝试删除实体记录的方法:
|
||||
|
||||
```js
|
||||
// 调用 deleteEntityRecord 需要有效的页面ID,先通过 getEntityRecords 获取首个可用ID
|
||||
const pageId = wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )[0].id;
|
||||
|
||||
// 执行删除操作:
|
||||
const promise = wp.data.dispatch( 'core' ).deleteEntityRecord( 'postType', 'page', pageId );
|
||||
|
||||
// 当 API 请求成功或失败时,promise 会相应地被解析或拒绝
|
||||
```
|
||||
|
||||
REST API 请求完成后,您会注意到列表中的某个页面已消失。这是因为列表通过 `useSelect()` 钩子和 `select( coreDataStore ).getEntityRecords( 'postType', 'page' )` 选择器动态生成。只要底层数据发生变化,列表就会立即使用新数据重新渲染,这非常便捷!
|
||||
|
||||
接下来让我们在点击 `DeletePageButton` 时触发该操作:
|
||||
|
||||
```js
|
||||
const DeletePageButton = ({ pageId }) => {
|
||||
const { deleteEntityRecord } = useDispatch( coreDataStore );
|
||||
const handleDelete = () => deleteEntityRecord( 'postType', 'page', pageId );
|
||||
return (
|
||||
<Button variant="primary" onClick={ handleDelete }>
|
||||
删除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤三:添加视觉反馈
|
||||
|
||||
点击*删除*按钮后,REST API 请求可能需要一些时间才能完成。与之前章节类似,我们可以通过 `<Spinner />` 组件来直观展示这一状态。
|
||||
|
||||
这里需要使用 `isDeletingEntityRecord` 选择器,它与[第三章节](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)中提到的 `isSavingEntityRecord` 选择器类似:返回 `true` 或 `false` 且不会触发任何 HTTP 请求:
|
||||
|
||||
```js
|
||||
const DeletePageButton = ({ pageId }) => {
|
||||
// ...
|
||||
const { isDeleting } = useSelect(
|
||||
select => ({
|
||||
isDeleting: select( coreDataStore ).isDeletingEntityRecord( 'postType', 'page', pageId ),
|
||||
}),
|
||||
[ pageId ]
|
||||
)
|
||||
return (
|
||||
<Button variant="primary" onClick={ handleDelete } disabled={ isDeleting }>
|
||||
{ isDeleting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
删除中...
|
||||
</>
|
||||
) : '删除' }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
实际运行效果如下:
|
||||
|
||||

|
||||
|
||||
### 步骤4:错误处理
|
||||
|
||||
我们之前乐观地假设*删除*操作总能成功。但不幸的是,其底层是通过REST API发起的请求,可能因多种原因失败:
|
||||
|
||||
* 网站可能宕机
|
||||
* 删除请求可能无效
|
||||
* 页面可能已被其他用户删除
|
||||
|
||||
为了在发生这些错误时通知用户,我们需要使用`getLastEntityDeleteError`选择器提取错误信息:
|
||||
|
||||
```js
|
||||
// 将9替换为实际页面ID
|
||||
wp.data.select( 'core' ).getLastEntityDeleteError( 'postType', 'page', 9 )
|
||||
```
|
||||
|
||||
以下是在`DeletePageButton`中的具体实现:
|
||||
|
||||
```js
|
||||
import { useEffect } from 'react';
|
||||
const DeletePageButton = ({ pageId }) => {
|
||||
// ...
|
||||
const { error, /* ... */ } = useSelect(
|
||||
select => ( {
|
||||
error: select( coreDataStore ).getLastEntityDeleteError( 'postType', 'page', pageId ),
|
||||
// ...
|
||||
} ),
|
||||
[pageId]
|
||||
);
|
||||
useEffect( () => {
|
||||
if ( error ) {
|
||||
// 显示错误信息
|
||||
}
|
||||
}, [error] )
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`error`对象来自`@wordpress/api-fetch`,包含以下错误属性:
|
||||
|
||||
* `message` – 人类可读的错误信息(如`Invalid post ID`)
|
||||
* `code` – 字符串型错误代码(如`rest_post_invalid_id`)。所有错误代码需参考[`/v2/pages`端点源码](https://github.com/WordPress/wordpress-develop/blob/2648a5f984b8abf06872151898e3a61d3458a628/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php#L226-L230)
|
||||
* `data`(可选)– 错误详情,包含HTTP响应码的`code`属性
|
||||
|
||||
本教程将直接显示`error.message`来转换错误信息。
|
||||
|
||||
WordPress采用`Snackbar`组件显示状态信息,下图是**小工具编辑器**中的效果:
|
||||
|
||||

|
||||
|
||||
现在为插件实现相同通知功能,包含两个部分:
|
||||
|
||||
1. 显示通知
|
||||
2. 触发通知
|
||||
|
||||
#### 显示通知
|
||||
|
||||
当前应用只能显示页面,需要新增通知显示功能。WordPress提供了完整的React通知组件,其中[`Snackbar`组件](https://wordpress.github.io/gutenberg/?path=/story/components-snackbar--default)可呈现单条通知:
|
||||
|
||||

|
||||
|
||||
不过我们不会直接使用`Snackbar`,而是采用能显示多条通知、支持平滑动画和自动隐藏的`SnackbarList`组件——这正是小工具编辑器和其他wp-admin页面使用的同款组件!
|
||||
|
||||
创建自定义`Notifications`组件:
|
||||
|
||||
```js
|
||||
import { SnackbarList } from '@wordpress/components';
|
||||
import { store as noticesStore } from '@wordpress/notices';
|
||||
|
||||
function Notifications() {
|
||||
const notices = []; // 此处稍后完善
|
||||
|
||||
return (
|
||||
<SnackbarList
|
||||
notices={ notices }
|
||||
className="components-editor-notices__snackbar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
基础框架已搭建,但当前通知列表为空。如何填充?我们将沿用WordPress使用的[`@wordpress/notices`](https://github.com/WordPress/gutenberg/blob/895ca1f6a7d7e492974ea55f693aecbeb1d5bbe3/docs/reference-guides/data/data-core-notices.md)方案:
|
||||
|
||||
```js
|
||||
import { SnackbarList } from '@wordpress/components';
|
||||
import { store as noticesStore } from '@wordpress/notices';
|
||||
|
||||
function Notifications() {
|
||||
const notices = useSelect(
|
||||
( select ) => select( noticesStore ).getNotices(),
|
||||
[]
|
||||
);
|
||||
const { removeNotice } = useDispatch( noticesStore );
|
||||
const snackbarNotices = notices.filter( ({ type }) => type === 'snackbar' );
|
||||
|
||||
return (
|
||||
<SnackbarList
|
||||
notices={ snackbarNotices }
|
||||
className="components-editor-notices__snackbar"
|
||||
onRemove={ removeNotice }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MyFirstApp() {
|
||||
// ...
|
||||
return (
|
||||
<div>
|
||||
{/* ... */}
|
||||
<Notifications />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
本教程重点在于页面管理,不深入讨论上述代码细节。若想了解`@wordpress/notices`的详细信息,建议查阅[手册页面](https://developer.wordpress.org/block-editor/reference-guides/data/data-core-notices/)。
|
||||
|
||||
现在我们已经准备好向用户报告可能发生的错误了。
|
||||
|
||||
#### 发送通知
|
||||
|
||||
有了 SnackbarNotices 组件后,我们现在可以发送通知了!具体操作如下:
|
||||
|
||||
```js
|
||||
import { useEffect } from 'react';
|
||||
import { store as noticesStore } from '@wordpress/notices';
|
||||
function DeletePageButton( { pageId } ) {
|
||||
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
|
||||
// 如果传入存储句柄而非回调函数,useSelect 将返回选择器列表:
|
||||
const { getLastEntityDeleteError } = useSelect( coreDataStore )
|
||||
const handleDelete = async () => {
|
||||
const success = await deleteEntityRecord( 'postType', 'page', pageId);
|
||||
if ( success ) {
|
||||
// 告知用户操作成功:
|
||||
createSuccessNotice( "页面已删除!", {
|
||||
type: 'snackbar',
|
||||
} );
|
||||
} else {
|
||||
// 在 deleteEntityRecord 执行失败后,直接使用选择器获取最新错误信息
|
||||
const lastError = getLastEntityDeleteError( 'postType', 'page', pageId );
|
||||
const message = ( lastError?.message || '出现错误。' ) + ' 请刷新页面后重试。'
|
||||
// 向用户明确展示操作失败原因:
|
||||
createErrorNotice( message, {
|
||||
type: 'snackbar',
|
||||
} );
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
太好了!现在 `DeletePageButton` 已完全具备错误感知能力。让我们看看实际错误提示效果。通过将 `pageId` 乘以一个大数值来触发无效删除操作:
|
||||
|
||||
```js
|
||||
function DeletePageButton( { pageId, onCancel, onSaveFinished } ) {
|
||||
pageId = pageId * 1000;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
刷新页面并点击任意 `删除` 按钮后,您将看到如下错误提示:
|
||||
|
||||

|
||||
|
||||
完美!现在可以**移除 `pageId = pageId * 1000;` 这行代码**。
|
||||
|
||||
接下来尝试实际删除页面。刷新浏览器并点击删除按钮后,您将看到:
|
||||
|
||||

|
||||
|
||||
大功告成!
|
||||
|
||||
### 完整功能集成
|
||||
|
||||
所有组件已就绪,太棒了!以下是本章节完成的所有代码变更:
|
||||
|
||||
```js
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { Button, Modal, TextControl } from '@wordpress/components';
|
||||
|
||||
function MyFirstApp() {
|
||||
const [searchTerm, setSearchTerm] = useState( '' );
|
||||
const { pages, hasResolved } = useSelect(
|
||||
( select ) => {
|
||||
const query = {};
|
||||
if ( searchTerm ) {
|
||||
query.search = searchTerm;
|
||||
}
|
||||
const selectorArgs = ['postType', 'page', query];
|
||||
const pages = select( coreDataStore ).getEntityRecords( ...selectorArgs );
|
||||
return {
|
||||
pages,
|
||||
hasResolved: select( coreDataStore ).hasFinishedResolution(
|
||||
'getEntityRecords',
|
||||
selectorArgs,
|
||||
),
|
||||
};
|
||||
},
|
||||
[searchTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="list-controls">
|
||||
<SearchControl onChange={ setSearchTerm } value={ searchTerm }/>
|
||||
<PageCreateButton/>
|
||||
</div>
|
||||
<PagesList hasResolved={ hasResolved } pages={ pages }/>
|
||||
<Notifications />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnackbarNotices() {
|
||||
const notices = useSelect(
|
||||
( select ) => select( noticesStore ).getNotices(),
|
||||
[]
|
||||
);
|
||||
const { removeNotice } = useDispatch( noticesStore );
|
||||
const snackbarNotices = notices.filter( ( { type } ) => type === 'snackbar' );
|
||||
|
||||
return (
|
||||
<SnackbarList
|
||||
notices={ snackbarNotices }
|
||||
className="components-editor-notices__snackbar"
|
||||
onRemove={ removeNotice }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesList( { hasResolved, pages } ) {
|
||||
if ( !hasResolved ) {
|
||||
return <Spinner/>;
|
||||
}
|
||||
if ( !pages?.length ) {
|
||||
return <div>暂无结果</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped table-view-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>标题</td>
|
||||
<td style={ { width: 190 } }>操作</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ pages?.map( ( page ) => (
|
||||
<tr key={ page.id }>
|
||||
<td>{ page.title.rendered }</td>
|
||||
<td>
|
||||
<div className="form-buttons">
|
||||
<PageEditButton pageId={ page.id }/>
|
||||
<DeletePageButton pageId={ page.id }/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) ) }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePageButton( { pageId } ) {
|
||||
const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
|
||||
// 如果传入存储句柄而非回调函数,useSelect 将返回选择器列表:
|
||||
const { getLastEntityDeleteError } = useSelect( coreDataStore )
|
||||
const handleDelete = async () => {
|
||||
const success = await deleteEntityRecord( 'postType', 'page', pageId);
|
||||
if ( success ) {
|
||||
// 告知用户操作成功:
|
||||
createSuccessNotice( "页面已删除!", {
|
||||
type: 'snackbar',
|
||||
} );
|
||||
} else {
|
||||
// 此时直接使用选择器获取错误信息
|
||||
// 假设我们通过以下方式获取错误:
|
||||
// const { lastError } = useSelect( function() { /* ... */ } );
|
||||
// 那么 lastError 在 handleDelete 内部将显示为 null
|
||||
// 为什么?因为这里引用的是在 handleDelete 被调用前就计算好的旧版本
|
||||
const lastError = getLastEntityDeleteError( 'postType', 'page', pageId );
|
||||
const message = ( lastError?.message || '出现错误。' ) + ' 请刷新页面后重试。'
|
||||
// 向用户明确展示操作失败原因:
|
||||
createErrorNotice( message, {
|
||||
type: 'snackbar',
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
const { deleteEntityRecord } = useDispatch( coreDataStore );
|
||||
const { isDeleting } = useSelect(
|
||||
select => ( {
|
||||
isDeleting: select( coreDataStore ).isDeletingEntityRecord( 'postType', 'page', pageId ),
|
||||
} ),
|
||||
[ pageId ]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button variant="primary" onClick={ handleDelete } disabled={ isDeleting }>
|
||||
{ isDeleting ? (
|
||||
<>
|
||||
<Spinner />
|
||||
删除中...
|
||||
</>
|
||||
) : '删除' }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
15
docs/how-to-guides/data-basics/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 使用Gutenberg数据创建你的首个应用
|
||||
|
||||
本教程旨在帮助你熟悉Gutenberg数据层。它将指导你构建一个简单的React应用程序,让用户能够管理他们的WordPress页面。完成后的应用效果如下:
|
||||
|
||||
[](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/WordPress/block-development-examples/trunk/plugins/data-basics-59c8f8/_playground/blueprint.json "在WordPress Playground中打开演示")
|
||||
|
||||
你可以在block-development-examples代码库中查看[完整应用](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/data-basics-59c8f8)。
|
||||
|
||||
### 目录
|
||||
|
||||
1. [环境设置](/docs/how-to-guides/data-basics/1-data-basics-setup.md)
|
||||
2. [构建基础页面列表](/docs/how-to-guides/data-basics/2-building-a-list-of-pages.md)
|
||||
3. [构建编辑表单](/docs/how-to-guides/data-basics/3-building-an-edit-form.md)
|
||||
4. [构建创建页面表单](/docs/how-to-guides/data-basics/4-building-a-create-page-form.md)
|
||||
5. [添加删除按钮](/docs/how-to-guides/data-basics/5-adding-a-delete-button.md)
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
docs/how-to-guides/data-basics/media/delete-button/snackbar.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/edit-button.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-editable.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-error.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-finished.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-inactive.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-scaffold.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/form-spinner.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/how-to-guides/data-basics/media/edit-form/modal-initial.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/how-to-guides/data-basics/media/finished-app.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/how-to-guides/data-basics/media/list-of-pages/filter.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/how-to-guides/data-basics/media/list-of-pages/indicator.jpg
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/how-to-guides/data-basics/media/setup/hello-from-js.jpg
Normal file
|
After Width: | Height: | Size: 3.6 KiB |