Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions eval/scenarios/admin-ui-list-table.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "Add an admin list table for plugin data",
"skills": ["wordpress-router", "wp-project-triage", "wp-admin-ui"],
"query": "Add an admin page to my plugin that shows a searchable, sortable table of items stored in a custom DB table. Include bulk delete. Make sure it's secure.",
"expected_behavior": [
"Step 1: Run wordpress-router to classify repo kind",
"Step 2: Run wp-project-triage script to detect plugin structure",
"Step 3: Route to wp-admin-ui based on admin interface task",
"Step 4: Register admin menu page with add_menu_page() hooked on admin_menu",
"Step 5: Gate render callback immediately with current_user_can()",
"Step 6: Create class extending WP_List_Table with get_columns(), get_sortable_columns(), get_bulk_actions(), prepare_items()",
"Step 7: Implement prepare_items() with sanitized orderby/order, pagination via set_pagination_args()",
"Step 8: Add nonce field in the form wrapping the table",
"Step 9: Implement process_bulk_action() with check_admin_referer() and current_user_can() before deleting",
"Step 10: Escape all output in column render methods with esc_html(), esc_attr(), esc_url()"
],
"success_criteria": [
"Admin page registered with correct capability",
"Render callback gates with current_user_can()",
"WP_List_Table subclass implements required methods",
"prepare_items() sets _column_headers and calls set_pagination_args()",
"Bulk delete verifies nonce and capability before writing to DB",
"All column output is escaped",
"orderby and order query params are sanitized before use in SQL"
]
}
123 changes: 123 additions & 0 deletions skills/wp-admin-ui/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
name: wp-admin-ui
description: "Use when building WordPress admin interfaces: menu and submenu pages, options pages via Settings API, data tables (WP_List_Table or @wordpress/dataviews), schema-driven forms (@wordpress/dataform), meta boxes, and dashboard widgets. Covers both classic PHP and modern React paradigms."
compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Classic approach works on all versions; @wordpress/dataviews and @wordpress/dataform require WP 6.6+."
---

# WP Admin UI

## When to use

Use this skill when:

- registering admin menu pages or submenu pages
- building options/settings pages
- displaying tabular data in the admin (list tables, data views)
- building admin forms (Settings API or @wordpress/dataform)
- adding meta boxes to post/page editors
- creating dashboard widgets

## Inputs required

- Repo root and target plugin (path to main plugin file).
- WordPress version (determines whether @wordpress/dataviews is available — 6.6+).
- Whether the page is top-level menu or submenu, and required capability.
- For list tables: data source (custom DB table, posts, options, REST endpoint).
- For React-based UI: whether @wordpress/scripts build tooling is present.

## Procedure

### 0) Triage and detect project

1. Run triage:
- `node skills/wp-project-triage/scripts/detect_wp_project.mjs`
2. Detect plugin headers:
- `node skills/wp-plugin-development/scripts/detect_plugins.mjs`
3. Check WordPress version to decide classic vs modern path:
- WP < 6.6 → classic only (PHP, WP_List_Table)
- WP 6.6+ → modern path available (@wordpress/dataviews, @wordpress/dataform)

### 1) Register admin pages

- Use `add_menu_page()` for top-level entries; `add_submenu_page()` for children.
- Always pass the minimum required capability (`manage_options` for site-wide settings, narrower caps for scoped tools).
- Hook registration on `admin_menu`.
- Gate the callback body immediately with `current_user_can()` before rendering anything.

See:
- `references/admin-menus.md`

### 2a) Classic: Settings API + WP_List_Table

For options pages:

- Register each option with `register_setting()` and a `sanitize_callback`.
- Group with `add_settings_section()` and `add_settings_field()`.
- Render using `settings_fields()` + `do_settings_sections()` inside a `<form>` posting to `options.php`.
- Verify nonce via `check_admin_referer()` in any custom save handler.

For tabular data:

- Extend `WP_List_Table`; implement `get_columns()`, `prepare_items()`, and `column_default()`.
- Add bulk action support via `get_bulk_actions()` and `process_bulk_action()`.
- Always verify nonce + capability before processing bulk actions.

See:
- `references/settings-api.md`
- `references/list-table.md`

### 2b) Modern: @wordpress/dataviews + @wordpress/dataform (WP 6.6+)

For data tables with filtering, sorting, and bulk actions:

- Use `<DataViews>` from `@wordpress/dataviews` with a `data` array and `fields` config.
- Connect to a REST API endpoint for data fetching (`@wordpress/api-fetch`).
- Define `actions` for row-level and bulk operations; gate each action with a server-side capability check.

For schema-driven forms:

- Use `<DataForm>` from `@wordpress/dataviews` with a `schema` matching registered REST fields or meta.
- Wire `onChange` to local state; submit via `apiFetch` with nonce header (`X-WP-Nonce`).

See:
- `references/dataviews.md`

### 3) Meta boxes (if needed)

- Register with `add_meta_box()`; hook on `add_meta_boxes`.
- Output a nonce field with `wp_nonce_field()` inside the render callback.
- Save on `save_post`; verify nonce, capability (`edit_post`), and auto-save guard before writing.

See:
- `references/meta-boxes.md`

### 4) Security baseline (always)

- Capability check before any output or data write.
- Nonce verification before processing any form submission or action.
- Escape all output: `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()` as appropriate.
- Sanitize all input before saving: `sanitize_text_field()`, `absint()`, `wp_kses_post()`, etc.

See:
- `references/security.md`

## Verification

- Admin page appears in the correct menu location for users with the required capability and is absent for users without it.
- Settings save and reload correctly; sanitization callbacks run.
- Nonce verification passes on valid submission and blocks forged requests.
- List table or DataViews displays correct data with working sort, filter, and bulk actions.
- No PHP notices or JS console errors on page load.

## Failure modes / debugging

- **Page not appearing in menu:** hook not on `admin_menu`, capability too restrictive, or `add_menu_page()` called too early.
- **Settings not saving:** option not registered with `register_setting()`, wrong option group in `settings_fields()`, or sanitize callback returning `null`.
- **Nonce failure on Settings API form:** using a custom POST handler instead of `options.php` without adding `settings_fields()`.
- **WP_List_Table blank:** `prepare_items()` not called, `$_column_headers` not set, or `set_pagination_args()` missing.
- **DataViews not rendering:** `@wordpress/dataviews` not enqueued, missing REST nonce, or `data` prop is not a plain array.
- **Meta box data not saving:** missing `save_post` hook, auto-save not guarded, or nonce field name mismatch.

## Escalation

Consult the Plugin Handbook (Admin Menus, Settings API, Meta Boxes) and the Block Editor Handbook (@wordpress/dataviews) before inventing patterns. For multisite capability nuances, see `wp-wpcli-and-ops`.
69 changes: 69 additions & 0 deletions skills/wp-admin-ui/references/admin-menus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Admin Menus

## Registering pages

```php
add_action( 'admin_menu', function () {
add_menu_page(
__( 'My Plugin', 'my-plugin' ), // page title
__( 'My Plugin', 'my-plugin' ), // menu title
'manage_options', // required capability
'my-plugin', // menu slug
'my_plugin_render_page', // render callback
'dashicons-admin-generic', // icon
80 // position
);

add_submenu_page(
'my-plugin', // parent slug
__( 'Settings', 'my-plugin' ),
__( 'Settings', 'my-plugin' ),
'manage_options',
'my-plugin-settings',
'my_plugin_render_settings_page'
);
} );
```

## Capability selection

| Scope | Capability |
|---|---|
| Site-wide settings | `manage_options` |
| Content management | `edit_posts` |
| User management | `list_users` |
| Custom post type | `edit_{post_type}s` |

Always choose the **narrowest** capability that covers the feature.

## Gating the render callback

Always verify capability at the top of every render callback — menu registration is not sufficient:

```php
function my_plugin_render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'my-plugin' ) );
}
// render ...
}
```

## Enqueuing assets only on your pages

Use the `$hook` parameter from `admin_enqueue_scripts` to avoid loading assets globally:

```php
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( 'toplevel_page_my-plugin' !== $hook ) {
return;
}
wp_enqueue_script( 'my-plugin-admin', plugin_dir_url( __FILE__ ) . 'build/index.js', [ 'wp-element' ], '1.0.0', true );
wp_localize_script( 'my-plugin-admin', 'myPluginData', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'apiUrl' => rest_url( 'my-plugin/v1/' ),
] );
} );
```

The hook name format is `{type}_page_{slug}` for submenus and `toplevel_page_{slug}` for top-level pages.
159 changes: 159 additions & 0 deletions skills/wp-admin-ui/references/dataviews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# @wordpress/dataviews and @wordpress/dataform (WP 6.6+)

Use these components for modern React-based admin tables and forms, backed by the REST API.

## When to use the modern path

- WordPress 6.6+ is the minimum target.
- The plugin already uses `@wordpress/scripts` for building JS.
- You need filtering, sorting, layout switching (table/grid/list), or inline editing out of the box.

## Enqueuing

```php
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( 'toplevel_page_my-plugin' !== $hook ) {
return;
}
$asset = require plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
wp_enqueue_script(
'my-plugin-admin',
plugin_dir_url( __FILE__ ) . 'build/index.js',
$asset['dependencies'],
$asset['version'],
true
);
wp_add_inline_script(
'my-plugin-admin',
'window.myPluginData = ' . wp_json_encode( [
'nonce' => wp_create_nonce( 'wp_rest' ),
'apiUrl' => rest_url( 'my-plugin/v1/items' ),
] ),
'before'
);
} );
```

## DataViews — basic table

```jsx
import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews';
import { useState, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

const DEFAULT_VIEW = {
type: 'table',
perPage: 20,
page: 1,
sort: { field: 'date', direction: 'desc' },
filters: [],
hiddenFields: [],
layout: {},
};

export default function MyItemsView() {
const [ items, setItems ] = useState( [] );
const [ view, setView ] = useState( DEFAULT_VIEW );
const [ isLoading, setIsLoading ] = useState( true );

useEffect( () => {
setIsLoading( true );
apiFetch( { path: '/my-plugin/v1/items', headers: { 'X-WP-Nonce': window.myPluginData.nonce } } )
.then( ( data ) => { setItems( data ); setIsLoading( false ); } );
}, [] );

const fields = [
{ id: 'name', label: 'Name', render: ( { item } ) => item.name },
{ id: 'email', label: 'Email', render: ( { item } ) => item.email },
{ id: 'date', label: 'Date', render: ( { item } ) => item.date },
];

const actions = [
{
id: 'delete',
label: 'Delete',
isPrimary: false,
isDestructive: true,
callback: ( selectedItems ) => {
selectedItems.forEach( ( item ) =>
apiFetch( {
path: `/my-plugin/v1/items/${ item.id }`,
method: 'DELETE',
headers: { 'X-WP-Nonce': window.myPluginData.nonce },
} )
);
},
},
];

const { data: processedData, paginationInfo } = filterSortAndPaginate( items, view, fields );

return (
<DataViews
data={ processedData }
fields={ fields }
view={ view }
onChangeView={ setView }
actions={ actions }
paginationInfo={ paginationInfo }
isLoading={ isLoading }
getItemId={ ( item ) => String( item.id ) }
/>
);
}
```

## DataForm — schema-driven form

```jsx
import { DataForm } from '@wordpress/dataviews';
import { useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

const FORM_FIELDS = [
{ id: 'name', label: 'Name', type: 'text' },
{ id: 'email', label: 'Email', type: 'email' },
];

export default function EditItemForm( { item, onSaved } ) {
const [ formData, setFormData ] = useState( item );

const handleSave = async () => {
await apiFetch( {
path: `/my-plugin/v1/items/${ item.id }`,
method: 'POST',
data: formData,
headers: { 'X-WP-Nonce': window.myPluginData.nonce },
} );
onSaved();
};

return (
<>
<DataForm
data={ formData }
fields={ FORM_FIELDS }
form={ { type: 'regular', fields: [ 'name', 'email' ] } }
onChange={ ( updates ) => setFormData( { ...formData, ...updates } ) }
/>
<button onClick={ handleSave }>Save</button>
</>
);
}
```

## REST API endpoint requirements

Every `DataViews` data source must have a REST endpoint that:

- enforces `permissions_callback` with `current_user_can()`.
- sanitizes query params (`absint`, `sanitize_text_field`).
- returns consistent shape: `{ id, ...fields }` per item.
- supports DELETE with the same capability check.

## Common mistakes

- Passing `data` as a non-array value (e.g., an object from `apiFetch` before it resolves).
- Forgetting `X-WP-Nonce` header — REST requests will return 403.
- Omitting `getItemId` — DataViews requires a stable string ID per item.
- Using `@wordpress/dataviews` on WP < 6.6 — the package is not bundled in core before that version.
Loading
Loading