Skip to content
Merged
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
9 changes: 5 additions & 4 deletions lib/compat/wordpress-7.0/class-wp-connector-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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'
);
Expand Down Expand Up @@ -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'] ) ) {
Expand Down
68 changes: 68 additions & 0 deletions packages/e2e-tests/plugins/connectors-js-extensibility.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php
/**
* Plugin Name: Gutenberg Test Connectors JS Extensibility
* Plugin URI: https://github.com/WordPress/gutenberg
* Author: Gutenberg Team
*
* Registers two custom-type connectors on the server:
*
* 1. test_custom_service — also registered client-side via a script module using
* the merging strategy (two registerConnector calls with the same slug: one
* providing the render function, the other metadata).
* 2. test_server_only_service — server-only, with no client-side render function,
* so it should not display a card in the UI.
*
* @package gutenberg-test-connectors-js-extensibility
*/

// Register two custom-type connectors for E2E testing.
add_action(
'wp_connectors_init',
static function ( WP_Connector_Registry $registry ) {
$registry->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' );
}
);
Original file line number Diff line number Diff line change
@@ -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.'
)
),
} );
28 changes: 13 additions & 15 deletions routes/connectors-home/default-connectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface ConnectorData {
name: string;
description: string;
logoUrl?: string;
type: 'ai_provider';
type: string;
plugin?: {
slug: string;
isInstalled: boolean;
Expand Down Expand Up @@ -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 );
}
}
5 changes: 4 additions & 1 deletion routes/connectors-home/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Page
Expand Down
62 changes: 62 additions & 0 deletions test/e2e/specs/admin/connectors.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,66 @@ test.describe( 'Connectors', () => {
} );
} );
} );

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();
} );
} );
} );
Loading