Data Views 操作:把图片添加到 WordPress 媒体库

上一篇《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 软件的许可证擅自等同于博客全部文字与图片的许可证。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容