上一篇《Using Data Views to display and interact with data in plugins》介绍了如何创建插件,在 WordPress 后台显示 React 应用,并通过 DataViews 组件列出图片数据集。界面已经支持显示、排序、搜索和筛选照片。
在把 React 应用加入后台、用 DataViews 展示数据之后,接下来要进一步扩展操作,让用户直接把列表里的图片加入媒体库。
本文将实现上传选中图片的操作、带有中间对话步骤的模态窗口,以及实时反馈处理进度的界面。内容涉及 WordPress REST API、通知框、模态窗口和其他 UI 组件。原文开头的视频展示了最终交互,可在原文观看。
开始之前
本教程接着上篇项目继续。获取本篇的起始版本:
git clone git@github.com:wptrainingteam/devblog-dataviews-plugin.git
git checkout part1
最终代码与开发过程中的提交记录位于项目仓库。原文为各步骤提供了对应提交链接,便于追踪变化。
添加上传媒体库的操作
项目目前只有查看原尺寸图片的操作。现在向传给 DataViews 的 actions 数组加入上传操作:
const actions = [
{
id: 'upload-media',
label: __( 'Upload Media' ),
isPrimary: true,
icon: 'upload',
supportsBulk: true,
callback: ( images ) => {
images.forEach( ( image ) => {
console.log( `Image to upload: ${ image.slug }` );
});
},
},
...
]
新操作启用了多选。目前每触发一次,它只会为每张选中图片向控制台输出消息。保持 npm start 运行,即 npm run start 的别名,以便文件变化时自动重新构建。
之前的 see-original 操作使用 id、label、callback。这次还使用:
isPrimary: true,把它设为每个条目的主要操作。supportsBulk: true,启用多选,让一次操作作用于多个条目。icon,指定主要操作的图标。
callback 接收一个包含一项或多项的数组,可以通过 forEach 逐项执行逻辑。完整属性说明见 DataViews 组件文档。
根据 URL 上传图片分为三步:先下载并转换为 Blob;再用 Blob 构造 FormData,作为 POST 请求体;最后用 apiFetch 向媒体端点发送 POST。
先在 forEach 回调中放入步骤注释:
const actions = [
{
id: 'upload-media',
...
callback: ( images ) => {
images.forEach( ( image ) => {
// 1- Download the image and convert it to a blob
// 2- Create FormData with the image blob
// 3- Send the request to the WP REST API
});
},
},
...
]
下载图片并转成 Blob
每张图片的地址在 image.urls.raw。使用浏览器原生 fetch 下载,再把 Response 转换为便于网络传输的二进制大对象 Blob:
images.forEach( async ( image ) => {
// 1- Download the image and convert it to a blob
const responseRequestImage = await fetch( image.urls.raw );
const blobImage = await responseRequestImage.blob();
...
})
fetch 返回 Promise,因此把 forEach 的回调声明为 async,就能用 await 获取异步结果。成功响应封装在 Response 对象里,其 .blob() 方法生成 Blob。
用图片 Blob 构造 FormData
fetch 与 WordPress 的 apiFetch 都可以把 FormData 作为请求体,以 multipart/form-data 方式传输。将图片放进 file 字段:
images.forEach( async ( image ) => {
...
// 2- Create FormData with the image blob
const formDataWithImage = new FormData();
formDataWithImage.append(
'file',
blobImage,
`${ image.slug }.jpg`
);
...
})
FormData 表示 HTML 表单数据。从服务器角度看,这与接收 HTML 表单提交类似。
请求 WordPress REST API
apiFetch 封装了 window.fetch,方便补全 REST API 基础地址并处理请求头中的 nonce 等信息。在 App.js 顶部导入:
import apiFetch from "@wordpress/api-fetch";
随后在 forEach 内发送请求:
images.forEach( async ( image ) => {
...
// 3- Send the request to the WP REST API with apiFetch
await apiFetch({
path: "/wp/v2/media",
method: "POST",
body: formDataWithImage,
})
.then( console.log )
...
})
请求 POST /wp/v2/media 会创建媒体项目,详见 REST API 媒体参考。公开 REST API 自 WordPress 4.7 起属于核心功能。
现在用户可以逐张或批量上传列表中的照片。但结果只有控制台消息,用户反馈还不够清楚,需要进一步改善。
显示上传结果通知
WordPress 提供通知系统,也可以通过 Notices Store 管理。在这里,用 withNotices 高阶组件包装 App,便于使用通知 UI。
包装后,组件收到额外的 noticeOperations 与 noticeUI:前者包含 createNotice 等操作方法,后者是显示通知的 React 组件。
先导入 withNotices:
import { withNotices } from "@wordpress/components";
再修改 App 定义:
const App = withNotices(({ noticeOperations, noticeUI }) => {
const { createNotice } = noticeOperations;
...
});
从 noticeOperations 解构出 createNotice 后,可以创建成功或失败通知,通知会出现在 noticeUI 放置的位置。给 apiFetch 接上成功、失败处理函数:
// 3- Send the request to the WP REST API with apiFetch
await apiFetch({
path: "/wp/v2/media",
method: "POST",
body: formDataWithImage,
})
.then(onSuccessMediaUpload)
.catch(onErrorMediaUpload);
在 App 内、调用之前定义这两个函数:
const onSuccessMediaUpload = (oImageUploaded) => {
const title = oImageUploaded.title.rendered;
createNotice({
status: "success",
content: __(`${title}.jpg successfully uploaded to Media Library!`),
isDismissible: true,
});
};
const onErrorMediaUpload = (error) => {
console.log(error);
createNotice({
status: "error",
content: __("An error occurred!"),
isDismissible: true,
});
};
onSuccessMediaUpload 收到媒体端点返回的图片对象,可以用其中的信息生成成功消息。onErrorMediaUpload 收到导致失败的错误,并创建错误通知。
要让通知显示出来,将 noticeUI 放入 App 返回值:
return (
<>
{noticeUI}
<DataViews
...
/>
</>
);
React 组件需要返回一个父元素,可以用 Fragment 包住多个元素。由于通知出现在页面顶部,可在 upload-media 回调开头滚动到顶部,避免用户错过提示:
window.scrollTo( 0, 0 );
用 Spinner 表示正在上传
WordPress 组件库的 Spinner 适合表示上传进行中。在使用之前,需要跟踪正在进行的上传,只在存在未结束任务时显示。
使用状态跟踪上传
在 App 开头用 useState 创建数组状态:
const [isUploadingItems, setIsUploadingItems] = useState([]);
每张图片开始上传时,把它的 slug 放入数组;上传成功时移除。React 状态更新会触发重新渲染。在 forEach 回调开头加入:
setIsUploadingItems((prevIsUploadingItems) => [
...prevIsUploadingItems,
image.slug,
]);
成功时从数组移除相应 slug,失败时清空数组:
const onSuccessMediaUpload = (oImageUploaded) => {
...
setIsUploadingItems((prevIsUploadingItems) =>
prevIsUploadingItems.filter((slugLoading) => slugLoading !== title)
);
...
};
const onErrorMediaUpload = (error) => {
setIsUploadingItems([]);
...
};
显示 Spinner
数组非空时显示 Spinner。先导入组件:
import { Spinner } from "@wordpress/components";
然后在返回的 JSX 中按条件渲染:
return (
<>
{!!isUploadingItems.length && <Spinner />}
...
<DataViews
...
/>
</>
);
打开模态窗口的操作
上篇的 see-original 直接显示原图。现在增加一个模态窗口,让用户先选择要打开的尺寸。
Data Views 操作可以通过 RenderModal 提供一个 React 组件,在触发操作时作为模态窗口显示。一旦提供 RenderModal,callback 就会被忽略;modalHeader 可以指定窗口标题。
首先替换 see-original 的定义:
{
id: 'see-original',
label: __( 'See Original' ),
modalHeader: __( 'See Original Image', 'action label' ),
RenderModal: ( { items: [ item ], closeModal } ) => {
return (
<div>
<button
onClick={ () => {
closeModal();
window.open( item.urls.raw, '_blank' );
} }
>
Open original image in new window
</button>
</div>
);
},
}
这段代码在窗口中放一个按钮,单击后关闭窗口并在新窗口打开原图,相比之前只是增加了中间步骤。
接着增加尺寸下拉框。导入这些组件:
import {
SelectControl,
Button,
__experimentalText as Text,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
...
} from '@wordpress/components';
SelectControl、Button、Text、HStack 和 VStack 都是 WordPress React 组件。Gutenberg Storybook提供了交互示例。
再定义完整操作:
{
id: 'see-original',
label: __( 'See Original' ),
modalHeader: __( 'See Original Image', 'action label' ),
RenderModal: ( { items: [ item ], closeModal } ) => {
const [ size, setSize ] = useState( 'raw' );
return (
<VStack spacing="5">
<Text>
{ `Select the size you want to open for "${ item.slug }"` }
</Text>
<HStack justify="left">
<SelectControl
__nextHasNoMarginBottom
label="Size"
value={ size }
options={ Object.keys( item.urls )
.filter( ( url ) => url !== 'small_s3' )
.map( ( url ) => ( {
label: url,
value: url,
} ) ) }
onChange={ setSize }
/>
</HStack>
<HStack justify="right">
<Button
__next40pxDefaultSize
variant="primary"
onClick={ () => {
closeModal();
window.open( item.urls[ size ], '_blank' );
} }
>
Open image from original location
</Button>
</HStack>
</VStack>
);
},
},
SelectControl 从图片 urls 对象生成可选尺寸,过滤掉 small_s3,通过 setSize 将选择存入 size 状态。按钮从 props 取得 closeModal,先关闭对话框,再打开所选尺寸的图片。
完整实现与输出
此时,src/App.js 的原文完整实现如下:
import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews/wp';
import { getTopicsElementsFormat } from './utils';
import { useState, useMemo } from '@wordpress/element';
import {
SelectControl,
Button,
__experimentalText as Text,
__experimentalHStack as HStack,
__experimentalVStack as VStack,
Spinner,
withNotices,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import './style.scss';
// source "data" definition
import { dataPhotos } from './data';
// "defaultLayouts" definition
const primaryField = 'id';
const mediaField = 'img_src';
const defaultLayouts = {
table: {
layout: {
primaryField,
},
},
grid: {
layout: {
primaryField,
mediaField,
},
},
};
// "fields" definition
const fields = [
{
id: 'img_src',
label: __( 'Image' ),
render: ( { item } ) => (
<img alt={ item.alt_description } src={ item.urls.thumb } />
),
enableSorting: false,
},
{
id: 'id',
label: __( 'ID' ),
enableGlobalSearch: true,
},
{
id: 'author',
label: __( 'Author' ),
getValue: ( { item } ) =>
`${ item.user.first_name } ${ item.user.last_name }`,
render: ( { item } ) => (
<a target="_blank" href={ item.user.url } rel="noreferrer">
{ item.user.first_name } { item.user.last_name }
</a>
),
enableGlobalSearch: true,
},
{
id: 'alt_description',
label: __( 'Description' ),
enableGlobalSearch: true,
},
{
id: 'topics',
label: __( 'Topics' ),
elements: getTopicsElementsFormat( dataPhotos ),
render: ( { item } ) => {
return (
<div className="topic_photos">
{ item.topics.map( ( topic ) => (
<span key={ topic } className="topic_photo_item">
{ topic.toUpperCase() }
</span>
) ) }
</div>
);
},
filterBy: {
operators: [ 'isAny', 'isNone', 'isAll', 'isNotAll' ],
},
enableSorting: false,
},
{
id: 'width',
label: __( 'Width' ),
getValue: ( { item } ) => parseInt( item.width ),
enableSorting: true,
},
{
id: 'height',
label: __( 'Height' ),
getValue: ( { item } ) => parseInt( item.height ),
enableSorting: true,
},
];
const App = withNotices( ( { noticeOperations, noticeUI } ) => {
const { createNotice } = noticeOperations;
const [ isUploadingItems, setIsUploadingItems ] = useState( [] );
// "view" and "setView" definition
const [ view, setView ] = useState( {
type: 'table',
perPage: 10,
layout: defaultLayouts.table.layout,
fields: [
'img_src',
'id',
'alt_description',
'author',
'topics',
'width',
'height',
],
} );
// "processedData" and "paginationInfo" definition
const { data: processedData, paginationInfo } = useMemo( () => {
return filterSortAndPaginate( dataPhotos, view, fields );
}, [ view ] );
const onSuccessMediaUpload = ( oImageUploaded ) => {
const title = oImageUploaded.title.rendered;
setIsUploadingItems( ( prevIsUploadingItems ) =>
prevIsUploadingItems.filter(
( slugLoading ) => slugLoading !== title
)
);
createNotice( {
status: 'success',
// translators: %s is the image title
content:
`${ title }.jpg ` +
__( 'successfully uploaded to Media Library' ),
isDismissible: true,
} );
};
const onErrorMediaUpload = ( error ) => {
setIsUploadingItems( [] );
console.log( error );
createNotice( {
status: 'error',
content: __( 'An error occurred!' ),
isDismissible: true,
} );
};
// "actions" definition
const actions = [
{
id: 'upload-media',
label: __( 'Upload Media' ),
isPrimary: true,
icon: 'upload',
supportsBulk: true,
callback: ( images ) => {
images.forEach( async ( image ) => {
// 1- Download the image and convert it to a blob
const responseRequestImage = await fetch( image.urls.raw );
const blobImage = await responseRequestImage.blob();
// 2- Create FormData with the image blob
const formDataWithImage = new FormData();
formDataWithImage.append(
'file',
blobImage,
`${ image.slug }.jpg`
);
// 3- Send the request to the WP REST API with apiFetch
await apiFetch( {
path: '/wp/v2/media',
method: 'POST',
body: formDataWithImage,
} ).then( console.log );
} );
},
},
{
id: 'see-original',
label: __( 'See Original' ),
modalHeader: __( 'See Original Image', 'action label' ),
RenderModal: ( { items: [ item ], closeModal } ) => {
const [ size, setSize ] = useState( 'raw' );
return (
<VStack spacing="5">
<Text>
{ `Select the size you want to open for "${ item.slug }"` }
</Text>
<HStack justify="left">
<SelectControl
__nextHasNoMarginBottom
label="Size"
value={ size }
options={ Object.keys( item.urls )
.filter( ( url ) => url !== 'small_s3' )
.map( ( url ) => ( {
label: url,
value: url,
} ) ) }
onChange={ setSize }
/>
</HStack>
<HStack justify="right">
<Button
__next40pxDefaultSize
variant="primary"
onClick={ () => {
closeModal();
window.open( item.urls[ size ], '_blank' );
} }
>
Open image from original location
</Button>
</HStack>
</VStack>
);
},
},
];
return (
<>
{ !! isUploadingItems.length && <Spinner /> }
{ noticeUI }
<DataViews
data={ processedData }
fields={ fields }
view={ view }
onChangeView={ setView }
defaultLayouts={ defaultLayouts }
actions={ actions }
paginationInfo={ paginationInfo }
/>
</>
);
} );
export default App;
项目代码展示完毕后,打开后台 Media 下的“Add Media from Third Party Service”子页面。按前面各步骤的设计,应当具有以下交互:
- 表格模式下,每张图片都有 Upload Media 主要操作,悬停时显示图标。
- 三点菜单提供 Upload Media 与 See Original。
- 可以多选图片并一起上传。
- 上传结束时显示通知。
- 上传过程中显示 Spinner。
- See Original 打开尺寸选择窗口,随后在新窗口显示所选图片。
完整项目见GitHub 仓库,原文还提供了基于 WordPress Playground 的在线演示。
结语
这篇结束了 DataViews 两篇系列:上一篇介绍基本使用,本篇深入讨论 actions 能执行的任务。
要跟踪功能进展,可以查看 Gutenberg 仓库中带有 [Feature] Data Views 标签的议题,以及设计团队的 DataViews 双周更新。
@wordpress/dataviews 为插件开发提供了新可能。作者鼓励开发者把使用体验、遇到的问题反馈到原文评论区或 Gutenberg issue。原文感谢 @bph、@greenshady、@oandregal 与 @milana_cap 提供反馈及审阅。
来源与版权
作者 JuanMa Garrido。原文:Actions from Data Views: Adding images to the Media Library,发表于 2024 年 9 月 23 日。本文为中文翻译,保留当时的 API、实验性组件名称和源代码。版权及项目许可证归原作者和项目权利人所有,不把 WordPress 软件的许可证擅自等同于博客全部文字与图片的许可证。











暂无评论内容