diff --git a/assets/src/js/commands/admin-commands.js b/assets/src/js/commands/admin-commands.js index 168e371b..7aeff499 100644 --- a/assets/src/js/commands/admin-commands.js +++ b/assets/src/js/commands/admin-commands.js @@ -11,7 +11,7 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { dispatch, select } from '@wordpress/data'; import { addQueryArgs } from '@wordpress/url'; import { @@ -23,13 +23,14 @@ import { tool, upload, download, + edit, } from '@wordpress/icons'; /** * Register admin commands for SCF */ const registerAdminCommands = () => { - if ( ! select( 'core/commands') || ! dispatch( 'core/commands' ) ) { + if ( ! select( 'core/commands' ) || ! dispatch( 'core/commands' ) ) { return; } @@ -174,7 +175,11 @@ const registerAdminCommands = () => { // the menu slug (which is also a relative URL), resulting in somewhat // peculiar naming, e.g. // edit.php?post_type=acf-field-group-edit.php?post_type=acf-ui-options-page - if ( registeredCommands.some( ( cmd ) => cmd.name.endsWith( commandUrl ) ) ) { + if ( + registeredCommands.some( ( cmd ) => + cmd.name.endsWith( commandUrl ) + ) + ) { return; } registerCommand( command ); @@ -183,6 +188,74 @@ const registerAdminCommands = () => { // "Create New" commands are not automatically registered by WordPress, // so we always register them. createCommands.forEach( registerCommand ); + + // Edit commands for existing field groups — data injected via PHP since + // acf-field-group posts are not REST-accessible. + const fieldGroups = window.scfFieldGroups; + if ( Array.isArray( fieldGroups ) ) { + fieldGroups.forEach( ( fieldGroup ) => { + if ( ! fieldGroup?.id ) { + return; + } + commandStore.registerCommand( { + name: 'scf/edit-field-group-' + fieldGroup.key, + label: sprintf( + /* translators: %s: field group title */ + __( 'Edit field group: %s', 'secure-custom-fields' ), + fieldGroup.title + ), + icon: edit, + keywords: [ + 'edit', + 'modify', + 'field group', + 'fields', + fieldGroup.title, + ], + callback: ( { close } ) => { + document.location = addQueryArgs( 'post.php', { + post: fieldGroup.id, + action: 'edit', + } ); + close(); + }, + } ); + } ); + } + + // Edit commands for existing SCF options pages — data injected via PHP since + // acf-ui-options-page posts are not REST-accessible. + const editOptionsPages = window.scfEditOptionsPages; + if ( Array.isArray( editOptionsPages ) ) { + editOptionsPages.forEach( ( optionsPage ) => { + if ( ! optionsPage?.id ) { + return; + } + commandStore.registerCommand( { + name: 'scf/edit-options-page-' + optionsPage.menu_slug, + label: sprintf( + /* translators: %s: options page title */ + __( 'Edit options page: %s', 'secure-custom-fields' ), + optionsPage.title + ), + icon: edit, + keywords: [ + 'edit', + 'modify', + 'options page', + 'settings', + optionsPage.title, + ], + callback: ( { close } ) => { + document.location = addQueryArgs( 'post.php', { + post: optionsPage.id, + action: 'edit', + } ); + close(); + }, + } ); + } ); + } }; if ( 'requestIdleCallback' in window ) { diff --git a/assets/src/js/commands/custom-post-type-commands.js b/assets/src/js/commands/custom-post-type-commands.js index 6ffff5a8..6d8807c2 100644 --- a/assets/src/js/commands/custom-post-type-commands.js +++ b/assets/src/js/commands/custom-post-type-commands.js @@ -12,10 +12,90 @@ * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { dispatch, resolveSelect, select } from '@wordpress/data'; +import { dispatch, resolveSelect, select, useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; import { addQueryArgs } from '@wordpress/url'; import { page, plus, edit } from '@wordpress/icons'; +/** + * Maximum number of posts a search loader surfaces per post type. + */ +const SEARCH_RESULTS_LIMIT = 10; + +/** + * Creates a command-loader hook bound to a single post type. + * + * The hook is reused by the command palette, which calls it on every search + * change, so it must follow the rules of hooks (no async). Posts are fetched + * through the core-data store, which also resolves read capabilities + * server-side. + * + * @param {Object} postType Post type object from the REST types endpoint. + * + * @return {Function} Loader hook accepting `{ search }` and returning commands. + */ +const createPostSearchLoaderHook = ( postType ) => + function usePostSearchLoader( { search } ) { + const { records, isLoading } = useSelect( + ( selectStore ) => { + const { getEntityRecords, hasFinishedResolution } = + selectStore( 'core' ); + const query = { + search: search ? search : undefined, + per_page: SEARCH_RESULTS_LIMIT, + orderby: search ? 'relevance' : 'date', + }; + return { + records: getEntityRecords( + 'postType', + postType.slug, + query + ), + isLoading: ! hasFinishedResolution( 'getEntityRecords', [ + 'postType', + postType.slug, + query, + ] ), + }; + }, + [ search ] + ); + + const commands = useMemo( () => { + return ( records ?? [] ) + .slice( 0, SEARCH_RESULTS_LIMIT ) + .map( ( record ) => { + const editPostUrl = addQueryArgs( 'post.php', { + post: record.id, + action: 'edit', + } ); + return { + name: `scf/edit-post-${ postType.slug }-${ record.id }`, + label: + record.title?.rendered || + __( '(no title)', 'secure-custom-fields' ), + icon: edit, + category: 'edit', + keywords: [ + 'edit', + 'modify', + postType.slug, + postType.name, + ], + callback: ( { close } ) => { + document.location = editPostUrl; + close(); + }, + }; + } ); + }, [ records ] ); + + return { + commands, + isLoading, + }; + }; + /** * Register custom post type commands */ @@ -32,117 +112,249 @@ const registerPostTypeCommands = async () => { const commandStore = dispatch( 'core/commands' ); const registeredCommands = select( 'core/commands' ).getCommands(); - postTypes.forEach( async ( postType ) => { + postTypes.forEach( ( postType ) => { if ( ! postType?.visibility?.show_ui ) { return; } - const viewAllCommandUrl = addQueryArgs( 'edit.php', { - post_type: postType.slug, + // WordPress core does NOT expose `visibility.show_in_rest` on the + // /wp/v2/types response. The canonical "REST is on" signal is a + // non-empty `rest_base`. A non-empty `rest_namespace` confirms the + // post type is registered against a real REST namespace (e.g. wp/v2). + // Check both — a CPT with `rest_base` set but no namespace is + // malformed and would 404 on every request. + const hasRestSupport = + !! postType.rest_base && !! postType.rest_namespace; + + // eslint-disable-next-line no-console + console.debug( '[SCF commands] registering for', postType.slug, { + show_in_rest: hasRestSupport, + rest_base: postType.rest_base, } ); - // WordPress stores destination URLs in the command *name*, appended to - // the menu slug (which is also a relative URL), resulting in somewhat - // peculiar naming, e.g. - // edit.php?post_type=movie-post-new.php?post_type=movie - if ( - ! registeredCommands.some( ( cmd ) => - cmd.name.endsWith( viewAllCommandUrl ) - ) && - ( await resolveSelect( 'core' ).canUser( - 'read', - postType.rest_base - ) ) - ) { - // Register "View All" command for this post type - commandStore.registerCommand( { - name: `scf/cpt-${ postType.slug }`, - label: postType.labels.all_items, - icon: page, - keywords: [ - 'post type', - 'content', - 'cpt', - postType.slug, - postType.name, - ].filter( Boolean ), - callback: ( { close } ) => { - document.location = viewAllCommandUrl; - close(); - }, + // Wrap each CPT registration so one failure doesn't kill the rest and + // so we can see which post type silently fails to register. + const registerOne = async () => { + const viewAllCommandUrl = addQueryArgs( 'edit.php', { + post_type: postType.slug, } ); - } - const addNewCommandUrl = addQueryArgs( 'post-new.php', { - post_type: postType.slug, + // WordPress stores destination URLs in the command *name*, appended to + // the menu slug (which is also a relative URL), resulting in somewhat + // peculiar naming, e.g. + // edit.php?post_type=movie-post-new.php?post_type=movie + if ( + ! registeredCommands.some( ( cmd ) => + cmd.name.endsWith( viewAllCommandUrl ) + ) && + ( await resolveSelect( 'core' ).canUser( + 'read', + postType.rest_base + ) ) + ) { + // Register "View All" command for this post type + commandStore.registerCommand( { + name: `scf/cpt-${ postType.slug }`, + label: postType.labels.all_items, + icon: page, + keywords: [ + 'post type', + 'content', + 'cpt', + postType.slug, + postType.name, + ].filter( Boolean ), + callback: ( { close } ) => { + document.location = viewAllCommandUrl; + close(); + }, + } ); + } + + const addNewCommandUrl = addQueryArgs( 'post-new.php', { + post_type: postType.slug, + } ); + + if ( + ! registeredCommands.some( ( cmd ) => + cmd.name.endsWith( addNewCommandUrl ) + ) && + ( await resolveSelect( 'core' ).canUser( + 'create', + postType.rest_base + ) ) + ) { + // Register "Add New" command for this post type + commandStore.registerCommand( { + name: `scf/new-${ postType.slug }`, + label: postType.labels.add_new_item, + icon: plus, + keywords: [ + 'add', + 'new', + 'create', + 'content', + postType.slug, + postType.name, + ], + callback: ( { close } ) => { + document.location = addNewCommandUrl; + close(); + }, + } ); + } + + // Register "Edit Post Type" command. The scf_post_id field is only + // exposed to users who can edit the post type definition, so its + // presence gates the command. + if ( postType.scf_post_id ) { + commandStore.registerCommand( { + name: `scf/edit-${ postType.slug }`, + label: sprintf( + /* translators: %s: post type label */ + __( 'Edit post type: %s', 'secure-custom-fields' ), + postType.name + ), + icon: edit, + keywords: [ + 'edit', + 'modify', + 'post type', + 'cpt', + 'settings', + postType.slug, + postType.name, + ], + callback: ( { close } ) => { + document.location = addQueryArgs( 'post.php', { + post: postType.scf_post_id, + action: 'edit', + } ); + close(); + }, + } ); + } + + // Register a loader that searches this post type's posts as the user + // types, letting them pick an existing instance to edit. The core + // /wp/v2/types response uses a non-empty `rest_base` as the canonical + // "show in REST" signal — there is no `visibility.show_in_rest` key. + if ( hasRestSupport ) { + commandStore.registerCommandLoader( { + name: `scf/edit-posts-${ postType.slug }`, + hook: createPostSearchLoaderHook( postType ), + } ); + } + }; + + registerOne().catch( ( err ) => { + // eslint-disable-next-line no-console + console.error( + '[SCF commands] failed to register for', + postType.slug, + err + ); } ); + } ); +}; + +/** + * Register taxonomy commands for SCF-managed taxonomies. + */ +const registerTaxonomyCommands = async () => { + if ( ! resolveSelect( 'core' ) || ! dispatch( 'core/commands' ) ) { + return; + } + + const taxonomies = await resolveSelect( 'core' ).getTaxonomies( { + per_page: -1, + } ); + + const commandStore = dispatch( 'core/commands' ); + const registeredCommands = select( 'core/commands' ).getCommands(); - if ( - ! registeredCommands.some( ( cmd ) => - cmd.name.endsWith( addNewCommandUrl ) - ) && - ( await resolveSelect( 'core' ).canUser( - 'create', - postType.rest_base - ) ) - ) { - // Register "Add New" command for this post type + taxonomies.forEach( ( taxonomy ) => { + // Skip taxonomies not managed by SCF. + if ( ! taxonomy.scf_taxonomy_id ) { + return; + } + + // Wrap registration so we can await capability checks. + const registerOne = async () => { + const viewTermsUrl = addQueryArgs( 'edit-tags.php', { + taxonomy: taxonomy.slug, + } ); + + if ( + ! registeredCommands.some( ( cmd ) => + cmd.name.endsWith( viewTermsUrl ) + ) && + ( await resolveSelect( 'core' ).canUser( + 'edit', + 'taxonomy', + taxonomy.slug + ) ) + ) { + commandStore.registerCommand( { + name: `scf/tax-${ taxonomy.slug }`, + label: taxonomy.name, + icon: page, + keywords: [ + 'taxonomy', + 'terms', + 'tags', + 'categories', + taxonomy.slug, + ].filter( Boolean ), + callback: ( { close } ) => { + document.location = viewTermsUrl; + close(); + }, + } ); + } + + // Register "Edit Taxonomy" definition command. commandStore.registerCommand( { - name: `scf/new-${ postType.slug }`, - label: postType.labels.add_new_item, - icon: plus, + name: `scf/edit-tax-${ taxonomy.slug }`, + label: sprintf( + /* translators: %s: taxonomy label */ + __( 'Edit taxonomy: %s', 'secure-custom-fields' ), + taxonomy.name + ), + icon: edit, keywords: [ - 'add', - 'new', - 'create', - 'content', - postType.slug, - postType.name, + 'edit', + 'modify', + 'taxonomy', + 'settings', + taxonomy.slug, + taxonomy.name, ], callback: ( { close } ) => { - document.location = addNewCommandUrl; + document.location = addQueryArgs( 'post.php', { + post: taxonomy.scf_taxonomy_id, + action: 'edit', + } ); close(); }, } ); - } - - // Register "Edit Post Type" command. The scf_post_id field is only - // exposed to users who can edit the post type definition, so its - // absence means the command must not be offered. - if ( ! postType.scf_post_id ) { - return; - } + }; - commandStore.registerCommand( { - name: `scf/edit-${ postType.slug }`, - label: sprintf( - /* translators: %s: post type label */ - __( 'Edit post type: %s', 'secure-custom-fields' ), - postType.name - ), - icon: edit, - keywords: [ - 'edit', - 'modify', - 'post type', - 'cpt', - 'settings', - postType.slug, - postType.name, - ], - callback: ( { close } ) => { - document.location = addQueryArgs( 'post.php', { - post: postType.scf_post_id, - action: 'edit', - } ); - close(); - }, + registerOne().catch( ( err ) => { + // eslint-disable-next-line no-console + console.error( + '[SCF commands] failed to register taxonomy for', + taxonomy.slug, + err + ); } ); } ); }; if ( 'requestIdleCallback' in window ) { window.requestIdleCallback( registerPostTypeCommands, { timeout: 500 } ); + window.requestIdleCallback( registerTaxonomyCommands, { timeout: 500 } ); } else { setTimeout( registerPostTypeCommands, 500 ); + setTimeout( registerTaxonomyCommands, 500 ); } diff --git a/includes/admin/admin-commands.php b/includes/admin/admin-commands.php index 19970b68..73a7d5b9 100644 --- a/includes/admin/admin-commands.php +++ b/includes/admin/admin-commands.php @@ -39,6 +39,38 @@ function acf_commands_init() { // Only load admin commands if user has SCF admin capabilities. if ( current_user_can( acf_get_setting( 'capability' ) ) ) { wp_enqueue_script( 'scf-commands-admin' ); + + // Inject field group data for "Edit Field Group" commands. + $field_groups = acf_get_field_groups(); + $editable_groups = array(); + foreach ( $field_groups as $field_group ) { + $post_id = isset( $field_group['ID'] ) ? (int) $field_group['ID'] : 0; + if ( $post_id && current_user_can( 'edit_post', $post_id ) ) { + $editable_groups[] = array( + 'id' => $post_id, + 'title' => $field_group['title'], + 'key' => $field_group['key'], + ); + } + } + wp_localize_script( 'scf-commands-admin', 'scfFieldGroups', $editable_groups ); + + // Inject options page data for "Edit Options Page" commands. + if ( function_exists( 'acf_get_ui_options_pages' ) ) { + $ui_options_pages = acf_get_ui_options_pages(); + $editable_pages = array(); + foreach ( $ui_options_pages as $options_page ) { + $post_id = isset( $options_page['ID'] ) ? (int) $options_page['ID'] : 0; + if ( $post_id && current_user_can( 'edit_post', $post_id ) ) { + $editable_pages[] = array( + 'id' => $post_id, + 'title' => ! empty( $options_page['page_title'] ) ? $options_page['page_title'] : ( $options_page['menu_title'] ?? '' ), + 'menu_slug' => $options_page['menu_slug'], + ); + } + } + wp_localize_script( 'scf-commands-admin', 'scfEditOptionsPages', $editable_pages ); + } } } diff --git a/includes/assets.php b/includes/assets.php index d9d4b836..9a03180f 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -361,7 +361,7 @@ public function register_scripts() { array_unique( array_merge( $custom_post_type_commands_asset['dependencies'], - array( 'acf', 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-commands', 'wp-i18n', 'wp-dom-ready' ) + array( 'acf', 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-core-data', 'wp-commands', 'wp-i18n', 'wp-dom-ready' ) ) ) ); diff --git a/includes/rest-api/class-acf-rest-types-endpoint.php b/includes/rest-api/class-acf-rest-types-endpoint.php index ee50ffcc..c26b22d8 100644 --- a/includes/rest-api/class-acf-rest-types-endpoint.php +++ b/includes/rest-api/class-acf-rest-types-endpoint.php @@ -36,6 +36,7 @@ class SCF_Rest_Types_Endpoint { */ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_extra_fields' ) ); + add_action( 'rest_api_init', array( $this, 'register_taxonomy_fields' ) ); add_action( 'rest_api_init', array( $this, 'register_parameters' ) ); add_filter( 'rest_request_before_callbacks', array( $this, 'filter_types_request' ), 10, 3 ); add_filter( 'rest_prepare_post_type', array( $this, 'filter_post_type' ), 10, 3 ); @@ -190,6 +191,64 @@ private function get_source_post_types( $source ) { } } + /** + * Register extra SCF fields for the taxonomies endpoint. + * + * @since SCF 6.8.3 + * + * @return void + */ + public function register_taxonomy_fields() { + if ( ! acf_get_setting( 'rest_api_enabled' ) ) { + return; + } + + register_rest_field( + 'taxonomy', + 'scf_taxonomy_id', + array( + 'get_callback' => array( $this, 'get_scf_taxonomy_id' ), + 'schema' => array( + 'description' => __( 'The SCF internal post ID that defines this taxonomy. Null if not managed by SCF or if the current user cannot edit the taxonomy definition.', 'secure-custom-fields' ), + 'type' => array( 'integer', 'null' ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + ), + ) + ); + } + + /** + * Get the SCF internal post ID for a taxonomy. + * + * Only exposed to users who can edit the taxonomy definition, so that + * consumers (e.g. Command Palette commands) can rely on its presence as + * a capability check. + * + * @since SCF 6.8.3 + * + * @param array $taxonomy_object The taxonomy object. + * @return int|null The post ID if managed by SCF and editable by the current user, null otherwise. + */ + public function get_scf_taxonomy_id( $taxonomy_object ) { + $slug = $taxonomy_object['slug']; + $scf_taxonomies = acf_get_acf_taxonomies(); + + foreach ( $scf_taxonomies as $scf_taxonomy ) { + if ( $scf_taxonomy['taxonomy'] === $slug ) { + $scf_tax_id = isset( $scf_taxonomy['ID'] ) ? (int) $scf_taxonomy['ID'] : 0; + + if ( ! $scf_tax_id || ! current_user_can( 'edit_post', $scf_tax_id ) ) { + return null; + } + + return $scf_tax_id; + } + } + + return null; + } + /** * Register extra SCF fields for the post types endpoint. * diff --git a/tests/e2e/command-palette.spec.ts b/tests/e2e/command-palette.spec.ts index 467836bd..393c3c99 100644 --- a/tests/e2e/command-palette.spec.ts +++ b/tests/e2e/command-palette.spec.ts @@ -170,6 +170,28 @@ test.describe( 'Command Palette', () => { page.getByRole( 'textbox', { name: /Plural Label/ } ) ).toHaveValue( 'SCF E2E Test Type' ); } ); + + test( 'should surface an existing instance to edit via search', async ( { + page, + } ) => { + await openCommandPalette( page ); + + const input = getCommandPaletteInput( page ); + + // The search loader returns posts of the test type as results. + await input.fill( 'SCF E2E Test Instance' ); + + await page + .getByRole( 'option', { + name: /SCF E2E Test Instance/, + } ) + .click(); + + // Selecting the result opens the post editor for that instance. + await expect( page ).toHaveURL( + /post\.php\?post=\d+&action=edit/ + ); + } ); } ); } ); } ); diff --git a/tests/e2e/plugins/scf-test-setup-post-types.php b/tests/e2e/plugins/scf-test-setup-post-types.php index bb0a705e..f9195dbd 100644 --- a/tests/e2e/plugins/scf-test-setup-post-types.php +++ b/tests/e2e/plugins/scf-test-setup-post-types.php @@ -86,12 +86,34 @@ function scf_test_create_scf_post_type_entry() { // Create the post type entry in the database using SCF's API acf_update_post_type( $post_type_config ); + + // Seed a published post of the test type for command palette search tests. + if ( ! get_option( 'scf_e2e_test_instance_created' ) ) { + $post_id = wp_insert_post( + array( + 'post_title' => 'SCF E2E Test Instance', + 'post_type' => 'scf-e2e-test-type', + 'post_status' => 'publish', + 'post_content' => 'Test instance for command palette editing.', + ) + ); + + if ( $post_id && ! is_wp_error( $post_id ) ) { + update_option( 'scf_e2e_test_instance_created', $post_id ); + } + } } /** * Clean up on plugin deactivation */ function scf_test_cleanup() { + $instance_id = get_option( 'scf_e2e_test_instance_created' ); + if ( $instance_id ) { + wp_delete_post( $instance_id, true ); + delete_option( 'scf_e2e_test_instance_created' ); + } + acf_delete_post_type( 'scf_e2e_test_post_type' ); }