diff --git a/lib/compat/wordpress-7.0/class-wp-connector-registry.php b/lib/compat/wordpress-7.0/class-wp-connector-registry.php index 27e5d6b385c38f..798e29942dee74 100644 --- a/lib/compat/wordpress-7.0/class-wp-connector-registry.php +++ b/lib/compat/wordpress-7.0/class-wp-connector-registry.php @@ -56,7 +56,7 @@ final class WP_Connector_Registry { * @since 7.0.0 * * @param string $id The unique connector identifier. Must contain only lowercase - * alphanumeric characters and underscores. + * alphanumeric characters, hyphens, and underscores. * @param array $args { * An associative array of arguments for the connector. * @@ -82,11 +82,11 @@ final class WP_Connector_Registry { * @phpstan-return Connector|null */ public function register( string $id, array $args ): ?array { - if ( ! preg_match( '/^[a-z0-9_]+$/', $id ) ) { + if ( ! preg_match( '/^[a-z0-9_-]+$/', $id ) ) { _doing_it_wrong( __METHOD__, __( - 'Connector ID must contain only lowercase alphanumeric characters and underscores.' + 'Connector ID must contain only lowercase alphanumeric characters, hyphens, and underscores.' ), '7.0.0' ); @@ -161,7 +161,8 @@ public function register( string $id, array $args ): ?array { if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) { $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url']; } - $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + $sanitized_id = str_replace( '-', '_', $id ); + $connector['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key"; } if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { diff --git a/packages/e2e-tests/plugins/connectors-js-extensibility.php b/packages/e2e-tests/plugins/connectors-js-extensibility.php new file mode 100644 index 00000000000000..8152a0d9171ff3 --- /dev/null +++ b/packages/e2e-tests/plugins/connectors-js-extensibility.php @@ -0,0 +1,68 @@ +register( + 'test_custom_service', + array( + 'name' => 'Test Custom Service', + 'description' => 'A custom service for E2E testing.', + 'type' => 'custom_service', + 'authentication' => array( + 'method' => 'none', + ), + ) + ); + + $registry->register( + 'test_server_only_service', + array( + 'name' => 'Test Server Only Service', + 'description' => 'A server-only service with no JS render.', + 'type' => 'custom_service', + 'authentication' => array( + 'method' => 'none', + ), + ) + ); + } +); + +// Enqueue the script module on the connectors page. +add_action( + 'admin_enqueue_scripts', + static function () { + if ( ! isset( $_GET['page'] ) || 'options-connectors-wp-admin' !== $_GET['page'] ) { + return; + } + + wp_register_script_module( + 'gutenberg-test-connectors-js-extensibility', + plugins_url( 'connectors-js-extensibility/index.mjs', __FILE__ ), + array( + array( + 'id' => '@wordpress/connectors', + 'import' => 'static', + ), + ) + ); + wp_enqueue_script_module( 'gutenberg-test-connectors-js-extensibility' ); + } +); diff --git a/packages/e2e-tests/plugins/connectors-js-extensibility/index.mjs b/packages/e2e-tests/plugins/connectors-js-extensibility/index.mjs new file mode 100644 index 00000000000000..8b96ef90b474d6 --- /dev/null +++ b/packages/e2e-tests/plugins/connectors-js-extensibility/index.mjs @@ -0,0 +1,35 @@ +/** + * Script module that demonstrates client-side connector registration. + * + * The server registers test_custom_service with its name and description. + * This module calls registerConnector() with the same slug to add a render + * function. The store merges both registrations, so the final connector + * combines the render function from JS with the metadata from PHP. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { + __experimentalRegisterConnector as registerConnector, + __experimentalConnectorItem as ConnectorItem, +} from '@wordpress/connectors'; + +const h = window.React.createElement; + +// Register the render function for the connector. +registerConnector( 'test_custom_service', { + render: ( props ) => + h( + ConnectorItem, + { + className: 'connector-item--test_custom_service', + name: props.name, + description: props.description, + logo: props.logo, + }, + h( + 'p', + { className: 'test-custom-service-content' }, + 'Custom rendered content for testing.' + ) + ), +} ); diff --git a/routes/connectors-home/default-connectors.tsx b/routes/connectors-home/default-connectors.tsx index 6b41f270b4e369..f69cdd26246b05 100644 --- a/routes/connectors-home/default-connectors.tsx +++ b/routes/connectors-home/default-connectors.tsx @@ -28,7 +28,7 @@ interface ConnectorData { name: string; description: string; logoUrl?: string; - type: 'ai_provider'; + type: string; plugin?: { slug: string; isInstalled: boolean; @@ -231,28 +231,26 @@ function ApiKeyConnector( { export function registerDefaultConnectors() { const connectors = getConnectorData(); - const sanitize = ( s: string ) => s.replace( /[^a-z0-9-]/gi, '-' ); + const sanitize = ( s: string ) => s.replace( /[^a-z0-9-_]/gi, '-' ); for ( const [ connectorId, data ] of Object.entries( connectors ) ) { const { authentication } = data; - if ( - data.type !== 'ai_provider' || - authentication.method !== 'api_key' - ) { - continue; - } - - const connectorName = `${ sanitize( data.type ) }/${ sanitize( - connectorId - ) }`; - registerConnector( connectorName, { + const connectorName = sanitize( connectorId ); + const args: Partial< Omit< ConnectorConfig, 'slug' > > = { name: data.name, description: data.description, logo: getConnectorLogo( connectorId, data.logoUrl ), authentication, plugin: data.plugin, - render: ApiKeyConnector, - } ); + }; + if ( + data.type === 'ai_provider' && + authentication.method === 'api_key' + ) { + args.render = ApiKeyConnector; + } + + registerConnector( connectorName, args ); } } diff --git a/routes/connectors-home/stage.tsx b/routes/connectors-home/stage.tsx index b86929d59c4677..77a6f81ec8495f 100644 --- a/routes/connectors-home/stage.tsx +++ b/routes/connectors-home/stage.tsx @@ -42,7 +42,10 @@ function ConnectorsPage() { [] ); - const isEmpty = connectors.length === 0; + const renderableConnectors = connectors.filter( + ( connector: ConnectorConfig ) => connector.render + ); + const isEmpty = renderableConnectors.length === 0; return ( { } ); } ); } ); + + test.describe( 'JS extensibility', () => { + const PLUGIN_SLUG = 'gutenberg-test-connectors-js-extensibility'; + + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + } ); + + test( 'should not display a card for a server-only connector without a JS render function', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( + SETTINGS_PAGE_PATH, + CONNECTORS_PAGE_QUERY + ); + + // The server registers test_server_only_service but no JS + // registerConnector call provides a render function for it, + // so no card should appear in the UI. + await expect( + page.getByRole( 'heading', { + name: 'Test Server Only Service', + level: 2, + } ) + ).toBeHidden(); + } ); + + test( 'should display a custom connector registered via JS with merging strategy', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( + SETTINGS_PAGE_PATH, + CONNECTORS_PAGE_QUERY + ); + + const card = page.locator( '.connector-item--test_custom_service' ); + await expect( card ).toBeVisible(); + + // Verify the custom content from the render function is visible. + await expect( + card.getByText( 'Custom rendered content for testing.' ) + ).toBeVisible(); + + // Verify label and description from the server-side PHP registration + // are merged with the client-side JS render function. + await expect( + card.getByRole( 'heading', { + name: 'Test Custom Service', + level: 2, + } ) + ).toBeVisible(); + await expect( + card.getByText( 'A custom service for E2E testing.' ) + ).toBeVisible(); + } ); + } ); } );