diff --git a/.gitignore b/.gitignore index 2bb7bf13..86e7e107 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ assets/build/**/*.php # Release folder release/ +# Local agent planning notes +.zcode/ diff --git a/assets/src/js/create-taxonomies/helpers.js b/assets/src/js/create-taxonomies/helpers.js new file mode 100644 index 00000000..2682f656 --- /dev/null +++ b/assets/src/js/create-taxonomies/helpers.js @@ -0,0 +1,96 @@ +/** + * Pure helpers for the create taxonomy sidebar panel. + * + * Kept free of React and WordPress globals so the payload and validation + * rules can be unit tested directly. + * + * @since SCF 6.9.6 + */ + +import { __ } from '@wordpress/i18n'; + +/** + * Builds a taxonomy key from a label. + * + * Matches the key rules the classic editor enforces: lower case letters, + * numbers, underscores and dashes, capped at 32 characters. + * + * @since SCF 6.9.6 + * + * @param {string} label The label to convert. + * @return {string} The generated taxonomy key. + */ +export function slugifyTaxonomyKey( label ) { + return String( label ?? '' ) + .trim() + .toLowerCase() + .replace( /[^a-z0-9_-]+/g, '-' ) + .replace( /-+/g, '-' ) + .replace( /^-|-$/g, '' ) + .substring( 0, 32 ); +} + +/** + * Validates the create taxonomy form values. + * + * @since SCF 6.9.6 + * + * @param {Object} values The form values. + * @return {Object} A map of field name to error message. Empty when valid. + */ +export function validateTaxonomyForm( values ) { + const errors = {}; + const taxonomy = String( values.taxonomy ?? '' ).trim(); + + if ( ! String( values.singularLabel ?? '' ).trim() ) { + errors.singularLabel = __( + 'The singular label is required.', + 'secure-custom-fields' + ); + } + + if ( ! String( values.pluralLabel ?? '' ).trim() ) { + errors.pluralLabel = __( + 'The plural label is required.', + 'secure-custom-fields' + ); + } + + if ( ! taxonomy ) { + errors.taxonomy = __( + 'The taxonomy key is required.', + 'secure-custom-fields' + ); + } else if ( taxonomy.length > 32 ) { + errors.taxonomy = __( + 'The taxonomy key may be 32 characters or fewer.', + 'secure-custom-fields' + ); + } else if ( ! /^[a-z0-9_-]+$/.test( taxonomy ) ) { + errors.taxonomy = __( + 'The taxonomy key must only contain lower case letters, numbers, underscores or dashes.', + 'secure-custom-fields' + ); + } + + return errors; +} + +/** + * Builds the REST payload for creating a taxonomy. + * + * @since SCF 6.9.6 + * + * @param {Object} values The form values. + * @param {string} postType The post type the taxonomy should be bound to. + * @return {Object} The request body. + */ +export function buildTaxonomyPayload( values, postType ) { + return { + singular_label: String( values.singularLabel ?? '' ).trim(), + plural_label: String( values.pluralLabel ?? '' ).trim(), + taxonomy: String( values.taxonomy ?? '' ).trim(), + hierarchical: Boolean( values.hierarchical ), + object_type: postType ? [ postType ] : [], + }; +} diff --git a/assets/src/js/create-taxonomies/index.js b/assets/src/js/create-taxonomies/index.js new file mode 100644 index 00000000..849b68c9 --- /dev/null +++ b/assets/src/js/create-taxonomies/index.js @@ -0,0 +1,216 @@ +/** + * Create Taxonomy editor sidebar panel. + * + * Adds a document settings panel to the block editor for creating a new SCF + * taxonomy in context, bound to the post type currently being edited. Gated + * in PHP on the create_taxonomies beta feature. + * + * @since SCF 6.9.6 + */ + +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { registerPlugin } from '@wordpress/plugins'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreDataStore } from '@wordpress/core-data'; +import { useSelect, useDispatch, select } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; +import { + Button, + Notice, + TextControl, + ToggleControl, + Spinner, +} from '@wordpress/components'; + +import { + buildTaxonomyPayload, + slugifyTaxonomyKey, + validateTaxonomyForm, +} from './helpers'; + +// PluginDocumentSettingPanel moved into the @wordpress/editor package in +// WordPress 6.6. On older versions it is only exposed from @wordpress/edit-post. +// Resolve it at runtime so the panel keeps working on every supported version, +// since the package held the component too late to import it from one place. +const PluginDocumentSettingPanel = + window.wp?.editor?.PluginDocumentSettingPanel || + window.wp?.editPost?.PluginDocumentSettingPanel; + +const CreateTaxonomyPanel = () => { + const postType = useSelect( + ( selectStore ) => + selectStore( editorStore )?.getCurrentPostType?.() ?? '', + [] + ); + + // The panel only makes sense for content post types, so it is hidden on the + // internal wp_* entities (templates, reusable blocks) the post editor also + // renders, and on any post type that is not shown in the admin. + const isSupportedPostType = useSelect( + ( selectStore ) => { + if ( ! postType || postType.startsWith( 'wp_' ) ) { + return false; + } + + const postTypeObject = + selectStore( coreDataStore )?.getPostType?.( postType ); + + return Boolean( + postTypeObject?.viewable && postTypeObject?.visibility?.show_ui + ); + }, + [ postType ] + ); + + const { savePost } = useDispatch( editorStore ); + + const [ isOpen, setIsOpen ] = useState( false ); + const [ singularLabel, setSingularLabel ] = useState( '' ); + const [ pluralLabel, setPluralLabel ] = useState( '' ); + const [ taxonomy, setTaxonomy ] = useState( '' ); + const [ hierarchical, setHierarchical ] = useState( false ); + const [ error, setError ] = useState( '' ); + const [ isSaving, setIsSaving ] = useState( false ); + + // Keep the key in step with the singular label until the user edits it. + const onSingularLabelChange = ( value ) => { + setSingularLabel( value ); + + if ( ! taxonomy || taxonomy === slugifyTaxonomyKey( singularLabel ) ) { + setTaxonomy( slugifyTaxonomyKey( value ) ); + } + }; + + const onSubmit = () => { + const values = { singularLabel, pluralLabel, taxonomy, hierarchical }; + const errors = validateTaxonomyForm( values ); + + if ( Object.keys( errors ).length ) { + setError( Object.values( errors )[ 0 ] ); + return; + } + + setError( '' ); + setIsSaving( true ); + + apiFetch( { + path: '/scf/v1/taxonomies', + method: 'POST', + data: buildTaxonomyPayload( values, postType ), + } ) + .then( async () => { + // A new taxonomy is registered on the next request, so reload to + // make it available on the current screen. Save first when the post + // has unsaved edits, otherwise the reload would discard them. + if ( select( editorStore ).isEditedPostDirty?.() && savePost ) { + await savePost(); + } + window.location.reload(); + } ) + .catch( ( response ) => { + setIsSaving( false ); + setError( + response?.message || + __( + 'The taxonomy could not be created.', + 'secure-custom-fields' + ) + ); + } ); + }; + + if ( ! isSupportedPostType ) { + return null; + } + + return ( + + { ! isOpen && ( + + ) } + + { isOpen && ( + <> + { error && ( + + { error } + + ) } + + + + + + + + + +
+ + + { isSaving && } +
+ + ) } +
+ ); +}; + +registerPlugin( 'scf-create-taxonomy', { + render: PluginDocumentSettingPanel ? CreateTaxonomyPanel : () => null, +} ); diff --git a/includes/admin/beta-features.php b/includes/admin/beta-features.php index e0e656ea..8beff552 100644 --- a/includes/admin/beta-features.php +++ b/includes/admin/beta-features.php @@ -90,6 +90,13 @@ public function get_beta_features() { * @return void */ public function admin_menu() { + // Include and register the beta features before the block editor + // enqueues its assets. Features that add editor scripts attach their + // own callbacks in initialize(), and "enqueue_block_editor_assets" + // fires before "admin_enqueue_scripts", so registering any later + // would leave those callbacks unhooked. + $this->include_beta_features(); + // bail early if no show_admin if ( ! acf_get_setting( 'show_admin' ) ) { return; @@ -137,8 +144,16 @@ public function admin_body_class( $classes ) { * @return void */ private function include_beta_features() { + // Only include and register the beta features once. + static $included = false; + if ( $included ) { + return; + } + $included = true; + acf_include( 'includes/admin/beta-features/class-scf-beta-feature.php' ); - acf_include( 'includes/admin/beta-features/class-scf-beta-feature-connect-fields.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-editor-sidebar.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-create-taxonomies.php' ); add_action( 'scf/include_admin_beta_features', array( $this, 'register_beta_features' ) ); @@ -153,6 +168,8 @@ private function include_beta_features() { * @return void */ public function register_beta_features() { + $this->register_beta_feature( 'SCF_Admin_Beta_Feature_Editor_Sidebar' ); + $this->register_beta_feature( 'SCF_Admin_Beta_Feature_Create_Taxonomies' ); } /** diff --git a/includes/admin/beta-features/class-scf-beta-feature-create-taxonomies.php b/includes/admin/beta-features/class-scf-beta-feature-create-taxonomies.php new file mode 100644 index 00000000..ae85cca2 --- /dev/null +++ b/includes/admin/beta-features/class-scf-beta-feature-create-taxonomies.php @@ -0,0 +1,53 @@ +name = 'create_taxonomies'; + $this->title = __( 'Create Taxonomies in the Editor', 'secure-custom-fields' ); + $this->description = __( 'Adds a panel to the block editor sidebar for creating new taxonomies in context.', 'secure-custom-fields' ); + + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_block_editor_assets' ) ); + } + + /** + * Enqueues the sidebar panel script when the feature is enabled. + * + * @return void + */ + public function enqueue_block_editor_assets() { + if ( ! $this->is_enabled() ) { + return; + } + + wp_enqueue_script( 'scf-create-taxonomies' ); + } + } +endif; diff --git a/includes/assets.php b/includes/assets.php index d9d4b836..1778eb09 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -279,6 +279,14 @@ public function register_scripts() { 'deps' => array(), 'in_footer' => true, ), + 'scf-create-taxonomies' => array( + 'handle' => 'scf-create-taxonomies', + 'src' => acf_get_url( sprintf( $js_path_patterns['base'], 'scf-create-taxonomies' ) ), + 'asset_file' => acf_get_path( sprintf( $asset_path_patterns['base'], 'scf-create-taxonomies' ) ), + 'version' => $version, + 'deps' => array( 'wp-plugins', 'wp-editor', 'wp-core-data', 'wp-components', 'wp-element', 'wp-data', 'wp-i18n', 'wp-api-fetch' ), + 'in_footer' => true, + ), ); // Define style registrations. diff --git a/includes/rest-api.php b/includes/rest-api.php index 7d711055..a7fea5cd 100644 --- a/includes/rest-api.php +++ b/includes/rest-api.php @@ -16,6 +16,7 @@ acf_include( 'includes/rest-api/class-acf-rest-embed-links.php' ); acf_include( 'includes/rest-api/class-acf-rest-request.php' ); acf_include( 'includes/rest-api/class-acf-rest-types-endpoint.php' ); +acf_include( 'includes/rest-api/class-scf-rest-create-taxonomy.php' ); acf_new_instance( 'ACF_Rest_Api' ); acf_new_instance( 'SCF_Rest_Types_Endpoint' ); diff --git a/includes/rest-api/class-scf-rest-create-taxonomy.php b/includes/rest-api/class-scf-rest-create-taxonomy.php new file mode 100644 index 00000000..6fc600eb --- /dev/null +++ b/includes/rest-api/class-scf-rest-create-taxonomy.php @@ -0,0 +1,241 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_taxonomy' ), + 'permission_callback' => 'scf_current_user_has_capability', + 'args' => array( + 'singular_label' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'plural_label' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'taxonomy' => array( + 'required' => true, + 'type' => 'string', + ), + 'object_type' => array( + 'required' => true, + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'hierarchical' => array( + 'required' => false, + 'type' => 'boolean', + 'default' => false, + ), + ), + ) + ); + } + + /** + * Creates an SCF taxonomy from the sidebar request. + * + * @since SCF 6.9.6 + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response|WP_Error + */ + public function create_taxonomy( WP_REST_Request $request ) { + if ( ! scf_current_user_has_capability() ) { + return new WP_Error( + 'scf_rest_cannot_create', + __( 'Sorry, you are not allowed to create taxonomies.', 'secure-custom-fields' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + $taxonomy_key = (string) $request->get_param( 'taxonomy' ); + + $taxonomy = array( + 'key' => uniqid( 'taxonomy_' ), + 'title' => (string) $request->get_param( 'plural_label' ), + 'taxonomy' => $taxonomy_key, + 'object_type' => $this->sanitize_object_type( $request->get_param( 'object_type' ) ), + 'hierarchical' => (bool) $request->get_param( 'hierarchical' ), + 'labels' => array( + 'name' => (string) $request->get_param( 'plural_label' ), + 'singular_name' => (string) $request->get_param( 'singular_label' ), + ), + ); + + $validator = acf_get_instance( 'SCF_JSON_Schema_Validator' ); + if ( ! $validator->validate( $taxonomy, 'taxonomy' ) ) { + $validation_message = $validator->get_validation_errors_string(); + + if ( empty( $validation_message ) ) { + $validation_message = __( 'The taxonomy could not be validated.', 'secure-custom-fields' ); + } + + return new WP_Error( 'scf_invalid_taxonomy', $validation_message, array( 'status' => 400 ) ); + } + + // Reject a key already used by an SCF taxonomy, or one registered elsewhere. + if ( $this->taxonomy_key_in_use( $taxonomy_key ) ) { + return new WP_Error( + 'scf_taxonomy_exists', + __( 'This taxonomy key is already in use and cannot be used.', 'secure-custom-fields' ), + array( 'status' => 409 ) + ); + } + + $created = acf_update_internal_post_type( $taxonomy, 'acf-taxonomy' ); + + if ( empty( $created['ID'] ) ) { + return new WP_Error( + 'scf_taxonomy_create_failed', + __( 'The taxonomy could not be created.', 'secure-custom-fields' ), + array( 'status' => 500 ) + ); + } + + return new WP_REST_Response( + array( + 'id' => (int) $created['ID'], + 'key' => (string) $created['key'], + 'taxonomy' => (string) $created['taxonomy'], + ), + 201 + ); + } + + /** + * Checks whether a taxonomy key is already used by SCF or WordPress. + * + * Mirrors the duplicate check the classic taxonomy editor performs, so + * the sidebar cannot create a taxonomy the admin screen would reject. + * Taxonomies that failed to register are ignored, matching the editor, + * which lets that key be reused. + * + * @since SCF 6.9.6 + * + * @param string $taxonomy_key The requested taxonomy key. + * @return bool + */ + private function taxonomy_key_in_use( $taxonomy_key ) { + $instance = acf_get_internal_post_type_instance( 'acf-taxonomy' ); + + if ( $instance && $instance->store ) { + $store = acf_get_store( $instance->store ); + + $matches = array_filter( + $instance->get_posts(), + function ( $item ) use ( $taxonomy_key, $store ) { + if ( ! isset( $item['taxonomy'] ) || $item['taxonomy'] !== $taxonomy_key ) { + return false; + } + + // A taxonomy that exists but failed to register does not + // reserve its key. register_taxonomies() flags those on the + // store rather than in the database. + $stored = $store->get( $item['key'] ); + + return empty( $stored['not_registered'] ); + } + ); + + if ( ! empty( $matches ) ) { + return true; + } + } + + return taxonomy_exists( $taxonomy_key ); + } + + /** + * Sanitizes and filters the requested post types. + * + * Only post types that actually exist are kept, so the taxonomy cannot be + * bound to an arbitrary string. + * + * @since SCF 6.9.6 + * + * @param mixed $object_type The requested post type list. + * @return array + */ + private function sanitize_object_type( $object_type ) { + $object_type = is_array( $object_type ) ? $object_type : array(); + $object_type = array_map( 'sanitize_key', $object_type ); + + return array_values( array_filter( $object_type, 'post_type_exists' ) ); + } + } + + acf_new_instance( 'SCF_Rest_Create_Taxonomy_Endpoint' ); + +endif; // class_exists check. diff --git a/secure-custom-fields.php b/secure-custom-fields.php index 069bc3b2..4414c53f 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -970,6 +970,7 @@ function scf_plugin_uninstall() { // List of known beta features. $beta_features = array( 'editor_sidebar', + 'create_taxonomies', ); foreach ( $beta_features as $beta_feature ) { diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 59d8b016..c8fee97e 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -144,22 +144,25 @@ const test = wpTest.extend( { 'role=button[name="View"i]' ); - if ( + const hasViewButton = ( await viewButton.count() ) > 0 && - ( await viewButton.first().isVisible() ) - ) { - return editor.openPreviewPage(); - } + ( await viewButton.first().isVisible() ); - await editorTopBar - .locator( 'role=button[name="Preview"i]' ) - .click(); + await ( hasViewButton + ? viewButton.first() + : editorTopBar.locator( 'role=button[name="Preview"i]' ) + ).click(); + // WordPress trunk renamed the menu item to + // "Preview (opens in a new tab)", so match on the shared + // "Preview ... new tab" text instead of the exact label. const [ previewPage ] = await Promise.all( [ context.waitForEvent( 'page' ), - page.click( - 'role=menuitem[name=/Preview in new tab/i]' - ), + page + .getByRole( 'menuitem', { + name: /Preview.*new tab/i, + } ) + .click(), ] ); return previewPage; diff --git a/tests/js/create-taxonomies.test.js b/tests/js/create-taxonomies.test.js new file mode 100644 index 00000000..1e56e965 --- /dev/null +++ b/tests/js/create-taxonomies.test.js @@ -0,0 +1,153 @@ +/** + * Unit tests for the create taxonomy sidebar helpers. + * + * Covers the pure functions that build and validate the REST payload, which is + * where a mismatch with the server rules would cause a silent failure. + */ + +import { + buildTaxonomyPayload, + slugifyTaxonomyKey, + validateTaxonomyForm, +} from '../../assets/src/js/create-taxonomies/helpers'; + +describe( 'slugifyTaxonomyKey', () => { + it( 'lowercases and replaces spaces with dashes', () => { + expect( slugifyTaxonomyKey( 'Product Genre' ) ).toBe( 'product-genre' ); + } ); + + it( 'strips characters that are not valid in a taxonomy key', () => { + expect( slugifyTaxonomyKey( 'Genre & Tag!' ) ).toBe( 'genre-tag' ); + } ); + + it( 'collapses repeated separators', () => { + expect( slugifyTaxonomyKey( 'genre -- tag' ) ).toBe( 'genre-tag' ); + } ); + + it( 'trims leading and trailing separators', () => { + expect( slugifyTaxonomyKey( ' -genre-' ) ).toBe( 'genre' ); + } ); + + it( 'caps the key at 32 characters', () => { + const long = 'a'.repeat( 60 ); + expect( slugifyTaxonomyKey( long ) ).toHaveLength( 32 ); + } ); + + it( 'returns an empty string for empty input', () => { + expect( slugifyTaxonomyKey( '' ) ).toBe( '' ); + expect( slugifyTaxonomyKey( null ) ).toBe( '' ); + } ); +} ); + +describe( 'validateTaxonomyForm', () => { + const valid = { + singularLabel: 'Genre', + pluralLabel: 'Genres', + taxonomy: 'genre', + hierarchical: false, + }; + + it( 'accepts a complete valid form', () => { + expect( validateTaxonomyForm( valid ) ).toEqual( {} ); + } ); + + it( 'requires a singular label', () => { + const errors = validateTaxonomyForm( { + ...valid, + singularLabel: ' ', + } ); + expect( errors.singularLabel ).toBeDefined(); + } ); + + it( 'requires a plural label', () => { + const errors = validateTaxonomyForm( { ...valid, pluralLabel: '' } ); + expect( errors.pluralLabel ).toBeDefined(); + } ); + + it( 'requires a taxonomy key', () => { + const errors = validateTaxonomyForm( { ...valid, taxonomy: '' } ); + expect( errors.taxonomy ).toBeDefined(); + } ); + + it( 'rejects a key longer than 32 characters', () => { + const errors = validateTaxonomyForm( { + ...valid, + taxonomy: 'a'.repeat( 33 ), + } ); + expect( errors.taxonomy ).toBeDefined(); + } ); + + it( 'accepts a key of exactly 32 characters', () => { + expect( + validateTaxonomyForm( { ...valid, taxonomy: 'a'.repeat( 32 ) } ) + ).toEqual( {} ); + } ); + + it( 'rejects a key with invalid characters', () => { + const errors = validateTaxonomyForm( { + ...valid, + taxonomy: 'Genre Tag!', + } ); + expect( errors.taxonomy ).toBeDefined(); + } ); + + it( 'accepts underscores and dashes in the key', () => { + expect( + validateTaxonomyForm( { ...valid, taxonomy: 'product_genre-tag' } ) + ).toEqual( {} ); + } ); +} ); + +describe( 'buildTaxonomyPayload', () => { + it( 'maps form values to the REST body', () => { + const payload = buildTaxonomyPayload( + { + singularLabel: 'Genre', + pluralLabel: 'Genres', + taxonomy: 'genre', + hierarchical: true, + }, + 'post' + ); + + expect( payload ).toEqual( { + singular_label: 'Genre', + plural_label: 'Genres', + taxonomy: 'genre', + hierarchical: true, + object_type: [ 'post' ], + } ); + } ); + + it( 'trims whitespace from the label and key', () => { + const payload = buildTaxonomyPayload( + { + singularLabel: ' Genre ', + pluralLabel: ' Genres ', + taxonomy: ' genre ', + hierarchical: false, + }, + 'post' + ); + + expect( payload.singular_label ).toBe( 'Genre' ); + expect( payload.plural_label ).toBe( 'Genres' ); + expect( payload.taxonomy ).toBe( 'genre' ); + } ); + + it( 'coerces hierarchical to a boolean', () => { + const payload = buildTaxonomyPayload( + { singularLabel: 'G', pluralLabel: 'Gs', taxonomy: 'g' }, + 'post' + ); + expect( payload.hierarchical ).toBe( false ); + } ); + + it( 'sends an empty object_type when no post type is available', () => { + const payload = buildTaxonomyPayload( + { singularLabel: 'G', pluralLabel: 'Gs', taxonomy: 'g' }, + '' + ); + expect( payload.object_type ).toEqual( [] ); + } ); +} ); diff --git a/tests/php/includes/rest-api/SCFRestCreateTaxonomyTest.php b/tests/php/includes/rest-api/SCFRestCreateTaxonomyTest.php new file mode 100644 index 00000000..1bc745f3 --- /dev/null +++ b/tests/php/includes/rest-api/SCFRestCreateTaxonomyTest.php @@ -0,0 +1,261 @@ +endpoint = new SCF_Rest_Create_Taxonomy_Endpoint(); + + $this->admin_id = wp_insert_user( + array( + 'user_login' => 'create_taxonomy_admin_' . uniqid(), + 'user_pass' => 'password', + 'role' => 'administrator', + ) + ); + wp_set_current_user( $this->admin_id ); + } + + /** + * Clean up test data. + */ + public function tearDown(): void { + $posts = get_posts( + array( + 'post_type' => 'acf-taxonomy', + 'numberposts' => -1, + 'post_status' => 'any', + ) + ); + + foreach ( $posts as $post ) { + wp_delete_post( $post->ID, true ); + } + + delete_option( 'scf_beta_feature_create_taxonomies_enabled' ); + + wp_set_current_user( 0 ); + + if ( $this->admin_id ) { + wp_delete_user( $this->admin_id ); + } + + parent::tearDown(); + } + + /** + * Builds a request for the create route. + * + * @param array $params Request parameters. + * @return WP_REST_Request + */ + private function make_request( array $params = array() ) { + $request = new WP_REST_Request( 'POST', '/scf/v1/taxonomies' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return $request; + } + + /** + * Default valid parameters. + * + * @param array $overrides Optional overrides. + * @return array + */ + private function valid_params( array $overrides = array() ) { + return array_merge( + array( + 'singular_label' => 'Genre', + 'plural_label' => 'Genres', + 'taxonomy' => 'test_' . uniqid(), + 'object_type' => array( 'post' ), + 'hierarchical' => true, + ), + $overrides + ); + } + + /** + * Test the route is registered while the beta feature is enabled. + */ + public function test_route_is_registered() { + update_option( 'scf_beta_feature_create_taxonomies_enabled', true ); + + $this->endpoint->register_routes(); + + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/scf/v1/taxonomies', $routes ); + } + + /** + * Test the route is absent while the beta feature is disabled. + */ + public function test_route_not_registered_when_beta_disabled() { + delete_option( 'scf_beta_feature_create_taxonomies_enabled' ); + + // rest_get_server() reuses its server for the whole process, so a fresh + // one is needed to see what this call alone would register. + $GLOBALS['wp_rest_server'] = new WP_REST_Server(); + + $this->endpoint->register_routes(); + + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( '/scf/v1/taxonomies', $routes ); + } + + /** + * Reads a created taxonomy from its stored post. + * + * WorDBless keeps the slashed serialized content, so it is unslashed here + * to match how WordPress stores and reads the data in a real install. + * + * @param int $post_id The taxonomy post ID. + * @return array + */ + private function read_taxonomy( $post_id ) { + $post = get_post( $post_id ); + + return (array) maybe_unserialize( wp_unslash( $post->post_content ) ); + } + + /** + * Test a valid request creates a taxonomy. + */ + public function test_creates_taxonomy() { + $params = $this->valid_params(); + $result = $this->endpoint->create_taxonomy( $this->make_request( $params ) ); + + $this->assertInstanceOf( 'WP_REST_Response', $result ); + $this->assertEquals( 201, $result->get_status() ); + + $data = $result->get_data(); + $this->assertNotEmpty( $data['id'] ); + $this->assertEquals( $params['taxonomy'], $data['taxonomy'] ); + + $created = $this->read_taxonomy( $data['id'] ); + $this->assertEquals( $params['taxonomy'], $created['taxonomy'] ); + $this->assertEquals( 'Genres', $created['labels']['name'] ); + $this->assertEquals( 'Genre', $created['labels']['singular_name'] ); + $this->assertTrue( (bool) $created['hierarchical'] ); + } + + /** + * Test duplicate taxonomy keys are rejected. + */ + public function test_rejects_duplicate_key() { + $params = $this->valid_params(); + + $first = $this->endpoint->create_taxonomy( $this->make_request( $params ) ); + $this->assertInstanceOf( 'WP_REST_Response', $first ); + + $second = $this->endpoint->create_taxonomy( + $this->make_request( + array_merge( $params, array( 'taxonomy' => $params['taxonomy'] ) ) + ) + ); + + $this->assertInstanceOf( 'WP_Error', $second ); + $this->assertEquals( 'scf_taxonomy_exists', $second->get_error_code() ); + } + + /** + * Test reserved and invalid keys are rejected by schema validation. + */ + public function test_rejects_invalid_key() { + $result = $this->endpoint->create_taxonomy( + $this->make_request( $this->valid_params( array( 'taxonomy' => 'category' ) ) ) + ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertEquals( 'scf_invalid_taxonomy', $result->get_error_code() ); + } + + /** + * Test uppercase keys are rejected. + */ + public function test_rejects_uppercase_key() { + $result = $this->endpoint->create_taxonomy( + $this->make_request( $this->valid_params( array( 'taxonomy' => 'Genre' ) ) ) + ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertEquals( 'scf_invalid_taxonomy', $result->get_error_code() ); + } + + /** + * Test post types that do not exist are filtered out. + */ + public function test_filters_unknown_post_types() { + $params = $this->valid_params( + array( 'object_type' => array( 'post', 'not_a_real_post_type' ) ) + ); + $result = $this->endpoint->create_taxonomy( $this->make_request( $params ) ); + + $this->assertInstanceOf( 'WP_REST_Response', $result ); + + $created = $this->read_taxonomy( $result->get_data()['id'] ); + $this->assertEquals( array( 'post' ), $created['object_type'] ); + } + + /** + * Test the permission callback requires the SCF capability. + */ + public function test_permission_requires_capability() { + wp_set_current_user( 0 ); + + $this->assertFalse( scf_current_user_has_capability() ); + + $result = $this->endpoint->create_taxonomy( + $this->make_request( $this->valid_params() ) + ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertEquals( 'scf_rest_cannot_create', $result->get_error_code() ); + } +} diff --git a/tests/php/test-admin-beta-features.php b/tests/php/test-admin-beta-features.php index 2d0a971a..2cfa9b94 100644 --- a/tests/php/test-admin-beta-features.php +++ b/tests/php/test-admin-beta-features.php @@ -29,6 +29,7 @@ public function set_up() { acf_include( 'includes/admin/beta-features.php' ); acf_include( 'includes/admin/beta-features/class-scf-beta-feature.php' ); acf_include( 'includes/admin/beta-features/class-scf-beta-feature-editor-sidebar.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-create-taxonomies.php' ); $this->beta_features = new SCF_Admin_Beta_Features(); } @@ -36,11 +37,39 @@ public function set_up() { /** * Clean up after each test. */ - public function tear_down() { + public function tear_down(): void { delete_option( 'scf_beta_feature_editor_sidebar_enabled' ); + delete_option( 'scf_beta_feature_create_taxonomies_enabled' ); parent::tear_down(); } + /** + * Test the create taxonomies beta feature registers. + */ + public function test_create_taxonomies_beta_feature() { + $this->beta_features->register_beta_feature( 'SCF_Admin_Beta_Feature_Create_Taxonomies' ); + + $beta_features = $this->beta_features->get_beta_features(); + $this->assertArrayHasKey( 'create_taxonomies', $beta_features ); + + $beta_feature = $beta_features['create_taxonomies']; + $this->assertInstanceOf( 'SCF_Admin_Beta_Feature_Create_Taxonomies', $beta_feature ); + $this->assertNotEmpty( $beta_feature->title ); + $this->assertNotEmpty( $beta_feature->description ); + $this->assertFalse( $beta_feature->is_enabled() ); + } + + /** + * Test the default beta features are registered. + */ + public function test_default_beta_features_registered() { + $this->beta_features->register_beta_features(); + + $beta_features = $this->beta_features->get_beta_features(); + $this->assertArrayHasKey( 'editor_sidebar', $beta_features ); + $this->assertArrayHasKey( 'create_taxonomies', $beta_features ); + } + /** * Test beta feature registration. */ diff --git a/webpack.config.js b/webpack.config.js index 21d371e5..f4d80f83 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -29,6 +29,8 @@ const commonConfig = { 'js/pro/acf-pro-ui-options-page': './assets/src/js/pro/acf-pro-ui-options-page.js', 'js/scf-bindings': './assets/src/js/bindings/index.js', + 'js/scf-create-taxonomies': + './assets/src/js/create-taxonomies/index.js', // CSS files 'css/acf-dark': './assets/src/sass/acf-dark.scss',