WordPress.org

WordPress Developer Blog

Actions from Data Views: Adding images to the Media Library

Actions from Data Views: Adding images to the Media Library

It’s been almost a month since Using Data Views to display and interact with data in plugins was published. In that post, you discovered how to create a plugin that displays a React app in the WordPress admin to list a dataset of pictures using Data Views. With the DataViews component, you were able to display a dataset of images and provide your users with a nice UI to display, sort, search, and filter the list of photos.

In that post, you learned how to:

  • Add a custom React app to the admin screens.
  • Leverage the DataViews component to display datasets.

With the User Interface ready for the user to display, sort, search, and filter a list of pictures, it’s time to take the Data Views actions of that project further and provide users with tools to directly add any listed images to the Media Library.

In this article, you’ll build:

  • Actions that enable users to upload selected images directly to the Media Library.
  • Actions that open modal windows containing intermediate dialogs to launch specific operations.
  • A user-friendly interface that offers real-time feedback, keeping users informed about the processes being executed.

After laying the groundwork for using Data Views, this post explores the actions you can take with them. These include interacting with WordPress media through the REST API, creating notification boxes, displaying modal windows, and using other UI components,

Here’s a video showcasing the features you’ll be adding to the app throughout this article:

Before you start

In this article, you’ll build on the project started in the previous article, continuing from it was left off

To get the project at the starting point for this article, you can do the following:

git clone git@github.com:wptrainingteam/devblog-dataviews-plugin.git
git checkout part1

The final code of the project explained in this article is available on GitHub
Throughout the article, you’ll find links to specific commits corresponding to the changes being explained, to help you track the project’s progress.

Action to add images to the Media Library

At its current state, the project has only one action to display the images in full size. Let’s now create a new action to upload images to the Media Library.

Add the following code to the actions array that is passed to the actions prop of the Dataviews component:

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 }` );
            });
        },
    },
    ...
]

This new action enables multi-selection in the Data Views UI. For now, when triggered, it will log a message to the console for each selected image.

Remember to have npm start (which is the alias of npm run start) running to automatically generate the project’s build version when a file changes.

The ‘see-original’ action was defined using the properties id,label, and callback.  As you can see above, the definition of this new ‘upload-media’ action uses some additional properties such as:

  • isPrimary – By setting this property to true this action will become the default action for each item.
  • supportsBulk – You can enable multi-selection of items by setting this property to `true`. With this setting enabled, you can perform the action on several items at the same time.
  • icon – icon to show for primary actions. 

Notice how the callback function of this action is now receiving an array of one or more items. By using forEach you can perform some logic on each one of the items selected.

Check here the complete list of properties that can be used to define actions for the Data Views items.

Now that the ‘upload-media’ action is created, let’s take some time to consider the steps needed to upload an image to the Media Library given a URL:

  1. Download the image from the provided URL and convert it to a blob.
  2. Create a FormData object with the image blob that can be sent as the body of the POST request.
  3. Send a POST request with apiFetch to the proper endpoint of the WordPress REST API to add the image to the Media Library.

Basically, you’ll need to make a POST request to a specific REST API URL, including data with the image in binary format. To do this, some preliminary steps are required to prepare the data for the REST API request.

Add these steps as comments of the forEach callback to have them as a reference as we add the logic for each step:

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 
            });
        },
    },
    ...
]

Download the image and convert it to a blob

The image URL for each item  is available  at image.urls.raw, so you could use the browser’s native fetch method to download each image and convert the response to a blob.

To send an image in a POST request, you need to convert it into a format that can be transmitted over the network. A common format for this is a binary large object (Blob).

Add this code to the forEach’s callback function:

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();
	...
})

Since fetch is an asynchronous method that returns a promise, you can declare the forEach callback as an  async function. This allows you to use the  await operator and avoid promise chains to handle the promise responses.

The successful response from fetch is encapsulated in a Response instance, which includes a blob() method. This method allows you to directly generate a blob from the response.

Create FormData with the image blob

With the image in blob format, the next step is to prepare the data to be sent in an HTTP request to the REST API. One way to send data in a POST request is to use a FormData object as the request body.

Methods like fetch (or WordPress’ apiFetch) can use a FormData object as the body of request.  This data is encoded and transmitted with the Content-Type set to multipart/form-data

Add this code to the forEach’s callback function:

images.forEach( async ( image ) => {
	...
	// 2- Create FormData with the image blob
	const formDataWithImage = new FormData();
	formDataWithImage.append(
		'file',
		blobImage,
		`${ image.slug }.jpg`
	);
	...
})

The code above creates a new FormData object  appending a file field containing the image in blob format.

A FormData object represents HTML form data. From the server’s perspective, it would be as if you submit the data from an HTML form.

Send the request to the WP REST API

At this point, everything is ready to do the REST API request with the apiFetch method.

apiFetch is a wrapper around window.fetch that offers several advantages when making requests to the WP REST API, such as automatically completing the Base URL for the REST API endpoint and including the nonce in the request headers.

Go to the beginning of App.js and import apiFetch method from @wordpress/api-fetch:

import apiFetch from "@wordpress/api-fetch"; 

Now add this code to the forEach’s callback function:

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 )
	...
})

The code above uses apiFetch to do a (POST) request to the wp/v2/media endpoint. As documented in the REST API Handbook, you can create a Media Item in the WordPress installation via POST requests to the /wp/v2/media endpoint.

Public REST API has been part of the core since WP 4.7

Your Data Views now includes a new feature that allows you to upload displayed photos directly to the Media Library. You can upload images individually or select multiple images for bulk uploading.

However, the feedback the user is getting about the upload process isn’t great, as it only provides a message in the console. Let’s work on improving this user experience.

Notification boxes with the outcome of the upload processes

WordPress provides a notification system that can be managed via the Notices Store. A good approach to interact with the Notices UI, is to wrap your React component with the withNotices Higher-Order Component.

By wrapping your App component with withNotices, your component will receive the additional props noticeOperations and noticeUI:

  • noticeOperations is an object that contains the createNotice method (among others), which you can use to generate a specific notification box.
  • noticeUI is the Notice React component that will be displayed when a notification box is created.

Go to the beginning of App.js and import withNotices method from "@wordpress/components":

import { withNotices } from "@wordpress/components";

Go to the line that starts the definition of your App component and replace it with the following code:

const App = withNotices(({ noticeOperations, noticeUI }) => {
	const { createNotice } = noticeOperations;
	...
});

You can use the createNotice method destructured from noticeOperations to create notification boxes that will appear wherever you place the noticeUI component on your React App’s screen. These notification boxes can be used to provide information to the user about the success or failure of images uploaded to the Media Library.

Chain the following then and catch methods to the apiFetch call to handle the success or error of the API request, using the yet-to-be-defined onSuccessMediaUpload and onErrorMediaUpload functions:

// 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);

Now, add the following code to the App component (and before the call of these functions) with the onSuccessMediaUpload and onErrorMediaUpload functions definitions:

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,
	});
};

The onSuccessMediaUpload receives the object returned by the /wp/v2/media endpoint with the details of the image successfully uploaded. With this info you can generate a success box informing the user about the success of the image upload.

If the upload operation fails, the onErrorMediaUpload will receive the specific error that prompted the failure of the upload operation. Inside this function you can also call a error box to notify the user that something went wrong.

To display the notification boxes triggered by the createNotice methods, add the noticeUI component  to the return of the App component:

return (
	<>
		{noticeUI}
		<DataViews
			...
		/>
	</>
);

React components can only return one parent element, so you can use the `Fragment` component to group your elements together.

Since notification boxes will appear at the top of the screen, you can add the following code at the start of the ‘upload-media’ callback to scroll to the top each time this action is triggered, ensuring users don’t miss any notifications:

window.scrollTo( 0, 0 );

A Spinner to indicate uploading processes in progress

A good UI element that could also be added is an indicator of uploading processes in progress. The Spinner component from WordPress components is perfect for this.

But before using the Spinner component, some logic needs to be added to ensure it is displayed only while upload processes are running. To monitor the ongoing upload processes, you can use a state variable that holds an array of all the uploads in progress.

Keeping track of the upload processes using state variables

Include the following code at the start of your App component to add a state variable using useState:

const [isUploadingItems, setIsUploadingItems] = useState([]);

The isUploadingItems state variable will keep track of the upload operations in progress. Every time a new image starts its upload process, its slug will be added to the isUploadingItems array. And every time a new image is successfully uploaded, its slug will be removed from the isUploadingItems array. 

Every time a state variable is updated the React component is re-rendered.

Add the following code at the beginning of the forEach callback (inside the ‘upload-media’ action’s callback):

setIsUploadingItems((prevIsUploadingItems) => [
	...prevIsUploadingItems,
	image.slug,
]);

The code above adds every image’s slug being selected for upload to the isUploadingItems state variable array.

To remove an image’s slug from the isUploadingItems state variable array when it has been successfully uploaded or emptying it completely when an error occurs you can add the following pieces of code to the onSuccessMediaUpload and onErrorMediaUploadfunctions.

const onSuccessMediaUpload = (oImageUploaded) => {
	...
	setIsUploadingItems((prevIsUploadingItems) =>
		prevIsUploadingItems.filter((slugLoading) => slugLoading !== title)
	);
	...
  };

const onErrorMediaUpload = (error) => {
	setIsUploadingItems([]);
	...
};

Displaying the Spinner component

While the isUploadingItems state variable array is not empty, the Spinner component should be displayed to indicate to the user that there’s some uploading process in progress. 

Navigate to the top of App.js and import the Spinner component from "@wordpress/components":

import { Spinner } from "@wordpress/components";

Finally, to conditionally display the Spinner component while any of the image uploads are still in progress, add the following code to the return statement of the App component:

return (
   <>
     {!!isUploadingItems.length && <Spinner />}
     ...
     <DataViews
        ...
     />
   </>
 );

Action with modal window

Remember the action you created in the previous article to display an image in full size? What about improving this action to show a modal so users can choose the size of the image they want to open in a new window?

Data Views actions provide a mechanism to display a modal window when triggered. Through the RenderModal property you can define a React component that will be displayed upon the action call as a modal window. When the RenderModal property is provided, the callback property is ignored.

A modalHeader property can also be defined to set a header text for the Modal window.

You could replace the ‘see-original’ action definition with the following code: 

{
	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>
		);
	},
}

The code above would open a modal when the action is triggered over an image and provide a button to open the original image in a new window. This is the same behavior defined in the previous version via callback but with a Modal window in between.

Let’s make this modal window nicer and more interesting by providing a Dropdown so users can select the size of the image to be opened in a new window.

First, import some components to be used in the modal window:

import {
	SelectControl,
	Button,
	__experimentalText as Text,
	__experimentalHStack as HStack,
	__experimentalVStack as VStack,
...
} from '@wordpress/components';	

The SelectControl, Button, Text, HStack and VStack are WordPress components available for WordPress development with React. You can find live examples of these components and others in the Gutenberg Storybook.

Next, use the following code to define the  ‘see-original’ action:

{
	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>
		);
	},
},

The code above uses the SelectControl component to display the sizes available for each image from the object with the info for each photo. The image size selected is stored in the size state variable through the setSize function.

The Button component’s onClick calls the closeModal (available via props) and then opens the selected image (with the selected size) in a new window.

Now, when you click on the ‘see-original’ action on an image you should see a modal window like the following one:

Full implementation and output

At this point, the final src/App.js file of the project should look like this:

import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews';
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;

The Data Views project is now complete! 

Go to your Admin panel and open the ‘Add Media from Third Party Service’ subpage under the ‘Media’ Settings. You should observe the following behavior:

  • In table mode, each image has a primary ‘Upload Media’ action, displayed as an icon when hovered.
  • Clicking the three-dot button on an image reveals two actions: ‘Upload Media’ and ‘See Original.’
  • You can select multiple images and perform the ‘Upload Media’ action on all of them simultaneously.
  • Notification boxes appear once an image upload process is complete.
  • A spinner icon is shown while images are being uploaded.
  • The “See Original” action displays a Modal Windows allowing the user to choose the size of the image to be opened in a new window.

The full code of this project is available at https://github.com/wptrainingteam/devblog-dataviews-plugin.

There’s also a live demo of the project powered by Playground.

Wrapping up

This article concludes a two-part series exploring the potential of the DataViews component:

If you’re interested in following the progress of this feature, you can check the issues with “[Feature] Data Views” label in the gutenberg repo. This component also has biweekly updates, which are shared on https://make.wordpress.org/design/tag/dataviews/.

The  @wordpress/dataviews package is a new tool that opens possibilities for plugin developers, and WordPress Core developers working on this feature would love to hear from you. Have you used it and found it interesting? Or ran into something you weren’t able to do? Please share your thoughts in the comments or the Gutenberg repo as Issues

Props to @bph, @greenshady, @oandregal and @milana_cap for providing feedback and reviewing this post.

One response to “Actions from Data Views: Adding images to the Media Library”

  1. JuanMa Garrido Avatar

    A Developer Hours session, explaining the key ideas behind this project, is available at https://www.youtube.com/watch?v=O1fIC4N_HVw

Leave a Reply

Your email address will not be published. Required fields are marked *