Components, DataViews and Admin Screens

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Core screens such as the Site Editor’s Pages, Templates and Patterns are React applications built from three layers: @wordpress/components (buttons, inputs, modals, notices), @wordpress/dataviews (DataViews for lists of records, DataForm for editing one record) and, since 7.1, the WordPress Design System’s tokens and ThemeProvider. A plugin can use the same layers for its own screens instead of a WP_List_Table subclass or a hand-built form. This page, for plugin developers who know React (see React), builds Catalogue > Books in lumen-catalog, the Lumen Press catalogue plugin: a list of lp_book posts with four layouts, search, filters, sorting, pagination, a quick-edit form and a bulk "Move to trash" action, all over /wp/v2/lp_book.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 on Node 24, and checked on 2026-10-28. The screen was built with wp-scripts build and exercised by Playwright 1.64.1 in headless Chromium, in Docker, against a 7.1.3 site on PHP 8.4 and MariaDB 11.8 (the stack of Local Development Environments). WordPress 7.2 (in beta, planned for 2026-12-08) is not covered. The Catalogue menu, its capability model and the Settings screen come from Administration Menus and Settings API; the build tooling from Block Development Environment.

Which package versions WordPress 7.1 ships

wordpress-develop pins one Gutenberg commit (the gutenberg.sha key of its package.json) and copies the built packages from it; for 7.1.3 that is the Gutenberg 23.6 release candidate. npm has wp-6.5 to wp-7.0 dist-tags on these packages but no wp-7.1 tag, so the versions below were read from that commit:

Package In 7.1.3 Script handle in 7.1.3

@wordpress/components

37.0.0

wp-components (script and style)

@wordpress/dataviews

17.2.0

none: must be bundled

@wordpress/theme

1.0.0

wp-theme (script with ThemeProvider, style with the tokens), new in 7.1

@wordpress/ui

0.18.0

none: bundled (DataViews uses it internally)

@wordpress/core-data, @wordpress/data

7.51.0, 10.51.0

wp-core-data, wp-data

@wordpress/element, React

8.3.0, React 18.3.1

wp-element, react, react-dom

@wordpress/scripts externalizes every @wordpress/* import to its wp-* handle except a list its dependency-extraction plugin always bundles (in 37.0.0: admin-ui, dataviews, dataviews/wp, fields, grid, icons, interface, ui, undo-manager, views and a few internals). The plugin’s own copy of DataViews is the one that runs, whatever WordPress version hosts it, so its version is pinned. React 19 was postponed beyond 7.1 (DataViews 15.0.0 moved to it and 16.0.0 reverted); 17.x accepts ^18 || ^19.

@wordpress/components and Storybook

@wordpress/components is the UI kit of the editor and the React admin: Button, TextControl, SelectControl, ToggleControl, ComboboxControl, Modal, Notice, SnackbarList, Card, Panel, DropdownMenu, Popover, Tooltip, Flex, Spinner and more. The Gutenberg Storybook (https://wordpress.github.io/gutenberg/) shows each one with editable props, including stories for DataViews and DataForm; the Block Editor Handbook’s component reference renders the same READMEs. Exports named __experimental* or __unstable* (__experimentalHStack, …) may change without deprecation and wp-scripts lint-js rejects them (@wordpress/no-unsafe-wp-apis), so the example uses Flex. In 7.1 the form controls (TextControl, SelectControl, NumberControl, FormTokenField, …) are 40px high unconditionally, so __next40pxDefaultSize (added in 6.7) has no effect on them; Button still uses it. The Navigation component (deprecated since 6.8, replaced by Navigator) and __experimentalApplyValueToSides were removed, and View, Flex, Divider, Surface, Truncate and Spacer no longer render through Emotion (the css prop of View is ignored). @wordpress/ui is the newer token-based library (Card.Root, Card.Content); it has no handle in 7.1, so a plugin that imports it bundles it.

DataViews, DataForm and DataViewsPicker

DataViews renders a dataset in a layout with search, filters, sorting, pagination, field visibility, density and actions (per item or bulk). It is controlled: it never fetches data, it calls onChangeView with a new view and the caller queries its source. DataForm edits one record from the same field definitions; DataViewsPicker is a selection-oriented variant (pickerTable, pickerGrid, pickerActivity); filterSortAndPaginate() applies a view to an in-memory array and useFormValidity() computes a DataForm validity object. Inside a plugin built with @wordpress/scripts, import from @wordpress/dataviews/wp: a pre-built bundle that inlines its own dependencies and imports only @wordpress/data, i18n, date, element and React from WordPress.

The view holds the user’s choices: type, search, filters ({ field, operator, value }, optionally isLocked), page, perPage, sort, fields (visible ids in order), titleField, mediaField, descriptionField, groupBy, infiniteScrollEnabled and a per-layout layout object. defaultLayouts lists the layouts offered and the properties applied when the user switches to one.

Layout Since layout options and use

table

first releases (6.5 era)

density, enableMoving, per-column styles (width, minWidth, maxWidth, align; right-align numbers)

grid

first releases

density, badgeFields, previewSize; cards built around mediaField

list

first releases

density; compact rows with title, media and a few fields

activity

package 11.0.0 (WordPress 7.0)

density; a vertical timeline for logs and revisions

Fields have id, label, type (text, integer, number, datetime, date, media, boolean, email, password, telephone, color, url, array), getValue/setValue, render, Edit (a control name such as 'richtext', new in 17.2.0, or a component), elements, filterBy (false or { operators, isPrimary }), enableSorting, enableHiding, enableGlobalSearch, readOnly, isVisible and isValid (required, elements, pattern, minLength, maxLength, min, max, custom). The type supplies default rendering, editing, sorting and filter operators (is, isNot, contains, isAny, isNone, isAll for text; lessThan, between, … for numbers; before, after, inThePast, … for dates); single- and multi-value operators cannot be mixed in one filter. Actions have id, label, icon, isPrimary, isEligible( item ), supportsBulk, context and either callback( items, { registry, onActionPerformed } ) or RenderModal (with modalHeader, modalSize, hideModalHeader); rows are selectable only when a bulk action exists. DataForm takes data, fields, form, validity and onChange( edits ); form.layout.type is regular, panel, card, row or details. The API moved fast: panel came in 4.0.0, form.type became form.layout.type in 7.0.0, row came in 9.0.0, and 10.0.0 (WordPress 6.9) replaced isItemValid() with the controlled validity prop, so code written for 6.7 or 6.8 needs updating.

7.1 also lets PHP configure the Site Editor’s own DataViews screens through get_entity_view_config_posttype_page, ..._wp_template, ..._wp_template_part and ..._wp_block filters (default_view, default_layouts, view_list, Quick Edit form, changed with $config->merge( $patch, 1 )). A plugin’s own screen passes the same configuration as props.

The Books screen

The screen needs lp_book with 'show_in_rest' => true, the lp_genre taxonomy with show_in_rest, and the meta keys lp_isbn, lp_pages and lp_published_on registered with show_in_rest, exactly as in Custom Post Types and Taxonomies and Metadata and Custom Fields. The page is visible to anyone with edit_lp_books (Administrators and Editors); every read and write goes through the REST API with the user’s own capabilities and the wp_rest nonce that wp-api-fetch adds, so the screen grants nothing the REST API does not.

package.json

Every package is pinned to the version in WordPress 7.1.3, so the editor’s autocompletion and the lint rules match the runtime. Only @wordpress/dataviews and @wordpress/icons end up in the bundle; the others are externalized. @wordpress/theme 1.0.0 declares an optional peer stylelint@^16, while @wordpress/scripts 37.0.0 installs stylelint 17, so a plain npm install stops with ERESOLVE; the overrides entry resolves it. --legacy-peer-deps also installs, but it skips peer dependencies such as ajv and the build then fails with MODULE_NOT_FOUND.

{
	"name": "lumen-catalog",
	"version": "0.1.0",
	"private": true,
	"scripts": {
		"build": "wp-scripts build books=./src/admin/books/index.js --output-path=build/admin",
		"start": "wp-scripts start books=./src/admin/books/index.js --output-path=build/admin",
		"lint:js": "wp-scripts lint-js src/admin",
		"lint:style": "wp-scripts lint-style \"src/**/*.scss\""
	},
	"devDependencies": {
		"@wordpress/scripts": "37.0.0",
		"stylelint": "17.16.0"
	},
	"dependencies": {
		"@wordpress/components": "37.0.0",
		"@wordpress/core-data": "7.51.0",
		"@wordpress/data": "10.51.0",
		"@wordpress/dataviews": "17.2.0",
		"@wordpress/element": "8.3.0",
		"@wordpress/i18n": "6.24.0",
		"@wordpress/icons": "15.2.0",
		"@wordpress/notices": "5.51.0",
		"@wordpress/theme": "1.0.0"
	},
	"overrides": {
		"@wordpress/theme": {
			"stylelint": "$stylelint"
		}
	}
}

The named entry books= produces books.js, books.css and books.asset.php; the block build (wp-scripts build --blocks-manifest --experimental-modules) stays a separate script.

src/BooksScreen.php

<?php
/**
 * Catalogue > Books: a React screen built with DataViews.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Registers the submenu page and loads the built React app on it only. */
final class BooksScreen {

	public const SLUG   = 'lumen-catalog-books';
	public const HANDLE = 'lumen-catalog-books';

	/** @var string Hook suffix returned by add_submenu_page(). */
	private static $hook = '';

	/** Called from Plugin::boot() in admin requests. */
	public static function register(): void {
		// Priority 20: the parent "Catalogue" menu is added at the default priority 10.
		add_action( 'admin_menu', array( self::class, 'add_page' ), 20 );
		add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue' ) );
	}

	/** Adds Catalogue > Books, first under Overview, for anyone who can edit books. */
	public static function add_page(): void {
		$title      = __( 'Books', 'lumen-catalog' );
		self::$hook = (string) add_submenu_page( 'lumen-catalog', $title, $title, 'edit_lp_books', self::SLUG, array( self::class, 'render' ), 1 );
	}

	/** Prints the mount point; React replaces its content. */
	public static function render(): void {
		printf(
			'<div class="wrap"><h1>%s</h1><div id="lumen-books-root"><p>%s</p></div></div>',
			esc_html( get_admin_page_title() ),
			esc_html__( 'Loading books…', 'lumen-catalog' )
		);
	}

	/** Enqueues the bundle with the dependencies wp-scripts computed, on this screen only. */
	public static function enqueue( string $hook_suffix ): void {
		if ( $hook_suffix !== self::$hook ) {
			return;
		}
		$url   = plugin_dir_url( __DIR__ ) . 'build/admin/';
		$asset = require plugin_dir_path( __DIR__ ) . 'build/admin/books.asset.php';

		wp_enqueue_script( self::HANDLE, $url . 'books.js', $asset['dependencies'], $asset['version'], array( 'in_footer' => true ) );
		wp_set_script_translations( self::HANDLE, 'lumen-catalog', plugin_dir_path( __DIR__ ) . 'languages' );
		// books.css holds the bundled DataViews styles; wp-components brings the wp-theme tokens.
		wp_enqueue_style( self::HANDLE, $url . 'books.css', array( 'wp-components' ), $asset['version'] );
	}
}

The hook-suffix check keeps the 2 MB bundle off other screens; the wp-components style pulls in wp-theme.

src/admin/books/index.js and books.scss

/** Entry point: mounts the Books app inside the ThemeProvider of the 7.1 design system. */
import { createRoot } from '@wordpress/element';
import { ThemeProvider } from '@wordpress/theme';
import BooksApp from './app';
import './books.scss';

const container = document.getElementById( 'lumen-books-root' );
if ( container ) {
	createRoot( container ).render(
		<ThemeProvider color={ { primary: '#a3271f' } } cornerRadius="moderate">
			<BooksApp />
		</ThemeProvider>
	);
}
@import "@wordpress/dataviews/build-style/style.css";

// Tokens come from the wp-theme stylesheet (a wp-components dependency).
#lumen-books-root {
	margin-top: 16px;
	background: var(--wpds-color-background-surface-neutral-strong);
	border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral-weak);
	border-radius: var(--wpds-border-radius-lg);
	overflow: hidden;
}

The DataViews stylesheet is imported from SCSS, not from JavaScript: an import '@wordpress/dataviews/build-style/style.css' in index.js is caught by the dependency-extraction plugin and written into books.asset.php as a script dependency named wp-dataviews/build-style/style.css. No such handle exists, so WordPress does not print the script at all and the screen stays on "Loading books…".

The 7.1 Design System and ThemeProvider

7.1 registers two assets named wp-theme: a stylesheet that defines every --wpds-* design token (colours, dimensions, borders, typography, motion) at :root, and a script that exports one public React component, ThemeProvider (from @wordpress/theme, whose index exports only ThemeProvider, its token types and private APIs). The provider derives a full, contrast-aware colour ramp from two seeds and rewrites the tokens for its subtree:

Prop Effect

color.primary

Accent seed (default #3858e9); opaque hex, rgb()/rgba() or a CSS colour name only. hsl(), oklch(), transparency and other colour spaces throw.

color.background

Background seed (default #fcfcfc); a dark seed yields a dark theme.

cornerRadius

none, subtle (default), moderate or pronounced.

cursor.control

pointer (default) or default for non-link controls.

isRoot

Also applies the overrides to the document root, so portalled popovers and modals inherit them. At most one per document.

Unset props inherit from the parent provider, then from the stylesheet. Tokens cascade through the DOM, not the React tree, so a Modal or Popover portalled outside the provider keeps the defaults unless the provider has isRoot. Plugin CSS uses the tokens without fallback values (wp-scripts lint-style reports them as plugin-wpds/no-token-fallback-values) and never bundles @wordpress/theme/design-tokens.css into a WordPress screen.

src/admin/books/fields.js

One set of field definitions serves both the list and the quick-edit form. Meta values live under item.meta, so the meta fields read with getValue and write a partial record with setValue.

/** Field definitions shared by DataViews (the list) and DataForm (quick edit). */
import { __ } from '@wordpress/i18n';

const STATUSES = [
	{ value: 'publish', label: __( 'Published', 'lumen-catalog' ) },
	{ value: 'future', label: __( 'Scheduled', 'lumen-catalog' ) },
	{ value: 'draft', label: __( 'Draft', 'lumen-catalog' ) },
	{ value: 'pending', label: __( 'Pending review', 'lumen-catalog' ) },
	{ value: 'private', label: __( 'Private', 'lumen-catalog' ) },
	{ value: 'lp_out_of_print', label: __( 'Out of print', 'lumen-catalog' ) },
];
const NO_FILTER = { filterBy: false, enableSorting: false };
const ANY_OF = { filterBy: { operators: [ 'isAny' ] }, enableSorting: false };
const meta = ( key ) => ( {
	getValue: ( { item } ) => item.meta?.[ key ],
	setValue: ( { value } ) => ( { meta: { [ key ]: value } } ),
} );

export const getFields = ( genres ) => [
	{
		id: 'title',
		type: 'text',
		label: __( 'Title', 'lumen-catalog' ),
		getValue: ( { item } ) => item.title?.raw ?? item.title?.rendered ?? '',
		setValue: ( { value } ) => ( { title: value } ),
		enableHiding: false,
		filterBy: false,
		isValid: { required: true },
	},
	{ id: 'status', type: 'text', label: __( 'Status', 'lumen-catalog' ), elements: STATUSES, ...ANY_OF },
	{
		id: 'lp_genre',
		type: 'array',
		label: __( 'Genre', 'lumen-catalog' ),
		elements: genres.map( ( term ) => ( { value: term.id, label: term.name } ) ),
		render: ( { item } ) =>
			( item.lp_genre ?? [] ).map( ( id ) => genres.find( ( t ) => t.id === id )?.name ?? id ).join( ', ' ),
		...ANY_OF,
	},
	{
		id: 'lp_isbn',
		type: 'text',
		label: __( 'ISBN-13', 'lumen-catalog' ),
		placeholder: '978-1-23456-789-7',
		...meta( 'lp_isbn' ),
		...NO_FILTER,
		isValid: { pattern: '^(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])?$' },
	},
	{ id: 'lp_pages', type: 'integer', label: __( 'Pages', 'lumen-catalog' ), ...meta( 'lp_pages' ), ...NO_FILTER, isValid: { min: 0, max: 5000 } },
	{ id: 'lp_published_on', type: 'date', label: __( 'Publication date', 'lumen-catalog' ), ...meta( 'lp_published_on' ), ...NO_FILTER },
	{ id: 'date', type: 'datetime', label: __( 'Added', 'lumen-catalog' ), filterBy: false, readOnly: true },
];

export const QUICK_EDIT_FORM = {
	layout: { type: 'regular', labelPosition: 'top' },
	fields: [
		'title',
		'status',
		{ id: 'edition', label: __( 'Edition', 'lumen-catalog' ), layout: { type: 'row', alignment: 'start' }, children: [ 'lp_isbn', 'lp_pages' ] },
		'lp_published_on',
	],
};

Sorting and filtering are enabled only where /wp/v2/lp_book can do them server-side: orderby accepts date, title and the other post columns but not meta keys, and the collection has status and lp_genre parameters but no meta filters. Sorting by pages or filtering by ISBN needs a rest_lp_book_query filter that maps the parameter to a meta_query (see REST API: Extending It). The ISBN and page limits repeat the REST schema of the meta registration; the client check gives instant feedback and the server check stays the authority.

src/admin/books/app.js

useEntityRecords() from @wordpress/core-data turns the query into a cached GET /wp/v2/lp_book and returns the records with totalItems and totalPages from the X-WP-Total and X-WP-TotalPages headers. context: 'edit' gives the raw title and requires edit_lp_books; status is listed explicitly because the default is publish only.

/** The Books screen: DataViews over /wp/v2/lp_book through the core-data store. */
import { useMemo, useState } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import { Button, Flex, SnackbarList } from '@wordpress/components';
import { DataViews } from '@wordpress/dataviews/wp';
import { __, _n, sprintf } from '@wordpress/i18n';
import { pencil, trash } from '@wordpress/icons';
import { getFields } from './fields';
import QuickEdit from './quick-edit';

const DEFAULT_VIEW = {
	...{ type: 'table', search: '', page: 1, perPage: 20, filters: [], titleField: 'title' },
	sort: { field: 'date', direction: 'desc' },
	fields: [ 'status', 'lp_genre', 'lp_isbn', 'lp_pages', 'date' ],
};
const DEFAULT_LAYOUTS = {
	table: { layout: { styles: { lp_pages: { align: 'end' } } } },
	grid: { fields: [ 'lp_genre', 'lp_isbn' ], layout: { badgeFields: [ 'status' ] } },
	list: { fields: [ 'status', 'lp_isbn' ] },
	activity: { fields: [ 'status' ] },
};

// Translates the DataViews view into REST query arguments.
function toQuery( view ) {
	const query = {
		context: 'edit',
		per_page: view.perPage,
		page: view.page,
		search: view.search || undefined,
		orderby: view.sort?.field,
		order: view.sort?.direction,
		status: 'publish,future,draft,pending,private,lp_out_of_print',
		_fields: 'id,title,status,date,meta,lp_genre',
	};
	for ( const { field, value } of view.filters ?? [] ) {
		if ( value?.length && ( field === 'status' || field === 'lp_genre' ) ) {
			query[ field ] = value.join( ',' );
		}
	}
	return query;
}

function ConfirmTrash( { items, closeModal, onActionPerformed } ) {
	const { deleteEntityRecord } = useDispatch( coreStore );
	const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
	const onConfirm = async () => {
		const opts = { throwOnError: true };
		try {
			// Without force: true the posts go to the trash.
			await Promise.all( items.map( ( item ) => deleteEntityRecord( 'postType', 'lp_book', item.id, {}, opts ) ) );
			createSuccessNotice( __( 'Moved to trash.', 'lumen-catalog' ), { type: 'snackbar' } );
			onActionPerformed?.( items );
		} catch ( error ) {
			createErrorNotice( error.message, { type: 'snackbar' } );
		}
		closeModal?.();
	};
	return (
		<Flex justify="flex-end">
			<Button __next40pxDefaultSize variant="tertiary" onClick={ closeModal }>{ __( 'Cancel', 'lumen-catalog' ) }</Button>
			<Button __next40pxDefaultSize variant="primary" isDestructive onClick={ onConfirm }>{ __( 'Move to trash', 'lumen-catalog' ) }</Button>
		</Flex>
	);
}

const ACTIONS = [
	{ id: 'quick-edit', label: __( 'Quick edit', 'lumen-catalog' ), icon: pencil, isPrimary: true, RenderModal: QuickEdit },
	{
		id: 'trash',
		label: __( 'Move to trash', 'lumen-catalog' ),
		icon: trash,
		supportsBulk: true,
		modalHeader: ( items ) =>
			sprintf(
				/* translators: %d: number of books. */
				_n( 'Trash %d book?', 'Trash %d books?', items.length, 'lumen-catalog' ),
				items.length
			),
		RenderModal: ConfirmTrash,
	},
];

export default function BooksApp() {
	const [ view, setView ] = useState( DEFAULT_VIEW );
	const query = useMemo( () => toQuery( view ), [ view ] );
	const { records, isResolving, totalItems, totalPages } = useEntityRecords( 'postType', 'lp_book', query );
	const { records: genres } = useEntityRecords( 'taxonomy', 'lp_genre', { per_page: 100, _fields: 'id,name' } );
	const fields = useMemo( () => getFields( genres ?? [] ), [ genres ] );
	const notices = useSelect( ( select ) => select( noticesStore ).getNotices(), [] );
	const { removeNotice } = useDispatch( noticesStore );

	return (
		<>
			<DataViews
				data={ records ?? [] }
				fields={ fields }
				view={ view }
				onChangeView={ setView }
				actions={ ACTIONS }
				defaultLayouts={ DEFAULT_LAYOUTS }
				isLoading={ isResolving }
				paginationInfo={ { totalItems: totalItems ?? 0, totalPages: totalPages ?? 0 } }
				getItemId={ ( item ) => String( item.id ) }
				searchLabel={ __( 'Search books', 'lumen-catalog' ) }
				empty={ <p>{ __( 'No books match these filters.', 'lumen-catalog' ) }</p> }
			/>
			<SnackbarList notices={ notices.filter( ( n ) => n.type === 'snackbar' ) } onRemove={ removeNotice } />
		</>
	);
}

deleteEntityRecord() without force moves the post to the trash (wp-api-fetch sends the DELETE as a POST with an X-HTTP-Method-Override header). The view lives in component state, so it resets on reload; persisting it per user means saving the view object in user meta or the preferences store and passing onReset so DataViews shows its "Reset view" control.

src/admin/books/quick-edit.js

/** Quick edit: a DataForm in the action modal, saved with saveEntityRecord(). */
import { useMemo, useState } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import { Button, Flex } from '@wordpress/components';
import { DataForm, useFormValidity } from '@wordpress/dataviews/wp';
import { __ } from '@wordpress/i18n';
import { getFields, QUICK_EDIT_FORM } from './fields';

const FIELDS = getFields( [] );

export default function QuickEdit( { items: [ book ], closeModal, onActionPerformed } ) {
	const [ edits, setEdits ] = useState( {} );
	const [ isSaving, setIsSaving ] = useState( false );
	const { saveEntityRecord } = useDispatch( coreStore );
	const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
	const canEdit = useSelect(
		( select ) => select( coreStore ).canUser( 'update', { kind: 'postType', name: 'lp_book', id: book.id } ),
		[ book.id ]
	);
	// Meta edits arrive one key at a time; merge them so none is lost.
	const merge = ( a, b ) => ( { ...a, ...b, meta: { ...a.meta, ...b.meta } } );
	const data = useMemo( () => merge( book, edits ), [ book, edits ] );
	const { validity, isValid } = useFormValidity( data, FIELDS, QUICK_EDIT_FORM );

	const onSubmit = async ( event ) => {
		event.preventDefault();
		setIsSaving( true );
		try {
			await saveEntityRecord( 'postType', 'lp_book', { id: book.id, ...edits }, { throwOnError: true } );
			createSuccessNotice( __( 'Book updated.', 'lumen-catalog' ), { type: 'snackbar' } );
			onActionPerformed?.( [ book ] );
			closeModal?.();
		} catch ( error ) {
			createErrorNotice( error.message, { type: 'snackbar' } );
			setIsSaving( false );
		}
	};

	return (
		<form onSubmit={ onSubmit }>
			<DataForm
				data={ data }
				fields={ FIELDS }
				form={ QUICK_EDIT_FORM }
				validity={ validity }
				onChange={ ( change ) => setEdits( ( prev ) => merge( prev, change ) ) }
			/>
			<Flex justify="flex-end" style={ { marginTop: 24 } }>
				<Button __next40pxDefaultSize variant="tertiary" onClick={ closeModal }>{ __( 'Cancel', 'lumen-catalog' ) }</Button>
				<Button __next40pxDefaultSize variant="primary" type="submit" isBusy={ isSaving } accessibleWhenDisabled
					disabled={ ! isValid || ! canEdit || isSaving || ! Object.keys( edits ).length }>
					{ __( 'Save', 'lumen-catalog' ) }
				</Button>
			</Flex>
		</form>
	);
}

saveEntityRecord() sends only the changed keys, so the request is POST /wp/v2/lp_book/<id> with, for example, {"meta":{"lp_isbn":"978-1-23456-792-7","lp_pages":288}}. canUser() issues an OPTIONS request and reads the Allow header; the meta auth_callback and the REST schema still run on the server.

Building and checking the screen

The listings above are compact; npm run lint:js -- --fix reflows them to the Prettier style of wp-scripts, after which lint:js and lint:style report no problems, and npm run build ends with "webpack 5.112.2 compiled with 3 warnings": webpack’s 244 KiB performance hints. books.js is 2.0 MB minified (about 390 KB gzipped) because it carries the DataViews bundle with its own components, date picker and @wordpress/ui; books.css and books-rtl.css are 88 KB each. The generated books.asset.php lists react, react-dom, react-jsx-runtime, wp-components, wp-core-data, wp-data, wp-date, wp-element, wp-hooks, wp-i18n, wp-notices, wp-primitives and wp-theme, all registered by 7.1.3.

A Playwright 1.64.1 script (headless Chromium in a node:24-slim container on the site’s Docker network) logged in as maria.editor with five books seeded by WP-CLI and used the screen as an editor would. The menu showed Overview and Books; the table showed Title, Status, Genre (names, not IDs), ISBN-13, Pages and Added; searching "Lighthouse" and filtering Genre is any of "Crime fiction" each left "The Quiet Lighthouse" and "The Lighthouse Ledger"; sorting by Title worked; the Layout menu switched to dataviews-view-grid, -list, -activity and back to -table. The brand token --wpds-color-background-interactive-brand-strong read #3858e9 on the page and #a3271f inside the provider. In Quick edit, the ISBN 123 left Save with aria-disabled="true"; a valid ISBN and 288 pages saved with the snackbar "Book updated."; the bulk action opened "Trash 1 book?" and removed "Salt and Ember" from the list. The requests were:

GET  /wp-json/wp/v2/lp_book?context=edit&per_page=20&page=1&orderby=date&order=desc&status=publish,future,draft,pending,private,lp_out_of_print&_fields=id,title,status,date,meta,lp_genre
GET  /wp-json/wp/v2/lp_book?...&search=Lighthouse...
GET  /wp-json/wp/v2/lp_book?...&lp_genre=3
GET  /wp-json/wp/v2/lp_book?...&orderby=title&order=asc...
OPTIONS /wp-json/wp/v2/lp_book/9
POST /wp-json/wp/v2/lp_book/9
POST /wp-json/wp/v2/lp_book/8

Afterwards wp post meta get 9 lp_pages printed 288 and "Salt and Ember" was in the trash. As tom.author (no edit_lp_books) the Catalogue menu was absent and the URL returned 403 "Sorry, you are not allowed to access this page.". Browser end-to-end testing is covered in E2E Testing in the Browser and Testing.

Common errors and pitfalls

Symptom Cause and fix

The screen stays on "Loading books…" with no JavaScript error

A dependency in books.asset.php is not a registered handle (typically wp-dataviews/build-style/style.css from a CSS import in JavaScript). Import @wordpress/dataviews/wp and the CSS from SCSS.

Console: "Store "core/rich-text" is already registered."

The DataViews 17.2.0 bundle carries its own @wordpress/rich-text (for the richtext control) and registers the store again on the shared wp-data registry. The screen works; the message is harmless.

npm install fails with ERESOLVE on stylelint

Peer range of @wordpress/theme 1.0.0; add the overrides entry rather than --legacy-peer-deps.

Modals keep the default blue

The modal is portalled outside the ThemeProvider subtree; use isRoot on the top provider.

Where older books are outdated

Older advice Current state

Admin list screens built by subclassing WP_List_Table

The class is still marked private in the Code Reference and may change; React screens with DataViews are the direction core takes (Site Editor screens, 7.1 view-config filters).

Admin pages written as PHP forms enhanced with jQuery, using a bare $ (one older plugin book)

jQuery runs in noConflict mode; new screens are React apps that use @wordpress/components and the REST API.

admin-ajax.php handlers without a nonce or capability check for admin screens

wp-api-fetch sends the wp_rest nonce and every REST route checks capabilities; core-data adds caching.

Bundling React or @wordpress/* packages into the plugin

@wordpress/scripts externalizes them to core handles; only the packages it lists as bundled (DataViews, icons, ui) are shipped by the plugin.

Styling admin screens with hard-coded colours

Use --wpds-* tokens and ThemeProvider (7.1) so screens follow the design system.