diff --git a/eval/scenarios/admin-ui-list-table.json b/eval/scenarios/admin-ui-list-table.json new file mode 100644 index 0000000..5533fa0 --- /dev/null +++ b/eval/scenarios/admin-ui-list-table.json @@ -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" + ] +} diff --git a/skills/wp-admin-ui/SKILL.md b/skills/wp-admin-ui/SKILL.md new file mode 100644 index 0000000..1c05037 --- /dev/null +++ b/skills/wp-admin-ui/SKILL.md @@ -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 `
` 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 `` 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 `` 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`. diff --git a/skills/wp-admin-ui/references/admin-menus.md b/skills/wp-admin-ui/references/admin-menus.md new file mode 100644 index 0000000..1c9a7b8 --- /dev/null +++ b/skills/wp-admin-ui/references/admin-menus.md @@ -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. diff --git a/skills/wp-admin-ui/references/dataviews.md b/skills/wp-admin-ui/references/dataviews.md new file mode 100644 index 0000000..95fe3d7 --- /dev/null +++ b/skills/wp-admin-ui/references/dataviews.md @@ -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 ( + 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 ( + <> + setFormData( { ...formData, ...updates } ) } + /> + + + ); +} +``` + +## 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. diff --git a/skills/wp-admin-ui/references/list-table.md b/skills/wp-admin-ui/references/list-table.md new file mode 100644 index 0000000..3636914 --- /dev/null +++ b/skills/wp-admin-ui/references/list-table.md @@ -0,0 +1,117 @@ +# WP_List_Table + +Use `WP_List_Table` for tabular admin data when targeting WP < 6.6 or when a pure-PHP table is preferred. + +## Minimal implementation + +```php +if ( ! class_exists( 'WP_List_Table' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; +} + +class My_Plugin_List_Table extends WP_List_Table { + + public function get_columns() { + return [ + 'cb' => '', + 'name' => __( 'Name', 'my-plugin' ), + 'email' => __( 'Email', 'my-plugin' ), + 'date' => __( 'Date', 'my-plugin' ), + ]; + } + + public function get_sortable_columns() { + return [ + 'name' => [ 'name', false ], + 'date' => [ 'date', true ], // true = already sorted descending + ]; + } + + public function get_bulk_actions() { + return [ 'delete' => __( 'Delete', 'my-plugin' ) ]; + } + + public function prepare_items() { + $per_page = 20; + $current_page = $this->get_pagenum(); + $orderby = sanitize_sql_orderby( $_REQUEST['orderby'] ?? 'date' ) ?: 'date'; + $order = 'ASC' === strtoupper( $_REQUEST['order'] ?? '' ) ? 'ASC' : 'DESC'; + + // Fetch data — replace with your query. + $all_items = my_plugin_get_items( $orderby, $order ); + $total = count( $all_items ); + + $this->items = array_slice( $all_items, ( $current_page - 1 ) * $per_page, $per_page ); + + $this->_column_headers = [ $this->get_columns(), [], $this->get_sortable_columns() ]; + + $this->set_pagination_args( [ + 'total_items' => $total, + 'per_page' => $per_page, + 'total_pages' => ceil( $total / $per_page ), + ] ); + } + + public function column_default( $item, $column_name ) { + return esc_html( $item[ $column_name ] ?? '' ); + } + + public function column_cb( $item ) { + return sprintf( '', absint( $item['id'] ) ); + } + + public function column_name( $item ) { + $actions = [ + 'edit' => sprintf( '%s', esc_url( admin_url( 'admin.php?page=my-plugin&action=edit&id=' . absint( $item['id'] ) ) ), __( 'Edit', 'my-plugin' ) ), + 'delete' => sprintf( '%s', esc_url( wp_nonce_url( admin_url( 'admin.php?page=my-plugin&action=delete&id=' . absint( $item['id'] ) ) ), 'delete_item_' . $item['id'] ) ), __( 'Delete', 'my-plugin' ) ), + ]; + return esc_html( $item['name'] ) . $this->row_actions( $actions ); + } +} +``` + +## Rendering in the page callback + +```php +function my_plugin_render_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle bulk actions before output. + $table = new My_Plugin_List_Table(); + $table->process_bulk_action(); // implement this method with nonce + cap check + $table->prepare_items(); + ?> +
+

+ + + search_box( __( 'Search', 'my-plugin' ), 'my-plugin-search' ); ?> + +
+ + display(); ?> +
+
+ current_action() ) { + return; + } + check_admin_referer( 'my_plugin_bulk_action', '_wpnonce_bulk' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Permission denied.', 'my-plugin' ) ); + } + $ids = array_map( 'absint', (array) ( $_POST['item'] ?? [] ) ); + foreach ( $ids as $id ) { + my_plugin_delete_item( $id ); + } +} +``` diff --git a/skills/wp-admin-ui/references/meta-boxes.md b/skills/wp-admin-ui/references/meta-boxes.md new file mode 100644 index 0000000..1459dbd --- /dev/null +++ b/skills/wp-admin-ui/references/meta-boxes.md @@ -0,0 +1,62 @@ +# Meta Boxes + +## Registration + +```php +add_action( 'add_meta_boxes', function () { + add_meta_box( + 'my_plugin_meta_box', + __( 'My Plugin Details', 'my-plugin' ), + 'my_plugin_render_meta_box', + [ 'post', 'page' ], // screens; can also be a custom post type slug + 'normal', // context: normal | side | advanced + 'high' // priority: high | default | low + ); +} ); +``` + +## Render callback + +Always output a nonce field so save can verify it: + +```php +function my_plugin_render_meta_box( $post ) { + wp_nonce_field( 'my_plugin_save_meta_' . $post->ID, 'my_plugin_meta_nonce' ); + $value = get_post_meta( $post->ID, '_my_plugin_key', true ); + ?> + + + prepare()`: + +```php +global $wpdb; +$results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$wpdb->prefix}my_table WHERE user_id = %d AND status = %s", + absint( $user_id ), + sanitize_text_field( $status ) + ) +); +``` diff --git a/skills/wp-admin-ui/references/settings-api.md b/skills/wp-admin-ui/references/settings-api.md new file mode 100644 index 0000000..bb85625 --- /dev/null +++ b/skills/wp-admin-ui/references/settings-api.md @@ -0,0 +1,88 @@ +# Settings API + +Use the Settings API for plugin options pages. It handles nonces, option persistence, and validation hooks automatically via `options.php`. + +## Registration (on `admin_init`) + +```php +add_action( 'admin_init', function () { + register_setting( + 'my_plugin_options', // option group (matches settings_fields()) + 'my_plugin_options', // option name stored in wp_options + [ + 'sanitize_callback' => 'my_plugin_sanitize_options', + 'default' => [ 'enable_feature' => false, 'api_key' => '' ], + ] + ); + + add_settings_section( + 'my_plugin_general', + __( 'General', 'my-plugin' ), + '__return_false', // optional description callback + 'my-plugin-settings' // page slug passed to do_settings_sections() + ); + + add_settings_field( + 'my_plugin_api_key', + __( 'API Key', 'my-plugin' ), + 'my_plugin_field_api_key', + 'my-plugin-settings', + 'my_plugin_general' + ); +} ); +``` + +## Sanitize callback + +Return a clean value or add a settings error and return the old value: + +```php +function my_plugin_sanitize_options( $input ) { + $clean = []; + $clean['enable_feature'] = ! empty( $input['enable_feature'] ); + $clean['api_key'] = sanitize_text_field( $input['api_key'] ?? '' ); + + if ( empty( $clean['api_key'] ) ) { + add_settings_error( 'my_plugin_options', 'missing_api_key', __( 'API key is required.', 'my-plugin' ) ); + $clean['api_key'] = get_option( 'my_plugin_options' )['api_key'] ?? ''; + } + + return $clean; +} +``` + +## Render the page + +```php +function my_plugin_render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + settings_errors( 'my_plugin_options' ); + ?> +
+

+
+ +
+
+