diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php new file mode 100644 index 00000000000000..1f1e34dda88afd --- /dev/null +++ b/lib/experimental/connectors/default-connectors.php @@ -0,0 +1,323 @@ +hasProvider( $provider_id ) ) { + return null; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key ) + ); + + return $registry->isProviderConfigured( $provider_id ); + } catch ( \Error $e ) { + return null; + } +} + +/** + * Sets the API key authentication for a provider on the WP AI Client registry. + * + * @access private + * + * @param string $key The API key. + * @param string $provider_id The WP AI client provider ID. + */ +function _gutenberg_set_provider_api_key( string $key, string $provider_id ): void { + try { + $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + if ( ! $registry->hasProvider( $provider_id ) ) { + return; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key ) + ); + } catch ( \Error $e ) { + // WP AI Client not available. + } +} + +/** + * Retrieves the real (unmasked) value of a connector API key. + * + * Temporarily removes the masking filter, reads the option, then re-adds it. + * + * @access private + * + * @param string $option_name The option name for the API key. + * @param callable $mask_callback The mask filter function. + * @return string The real API key value. + */ +function _gutenberg_get_real_api_key( string $option_name, callable $mask_callback ): string { + remove_filter( "option_{$option_name}", $mask_callback ); + $value = get_option( $option_name, '' ); + add_filter( "option_{$option_name}", $mask_callback ); + return $value; +} + +// --- Gemini (Google) --- + +/** + * Masks the Gemini API key on read. + * + * @access private + * + * @param string $value The raw option value. + * @return string Masked key or empty string. + */ +function _gutenberg_mask_gemini_api_key( string $value ): string { + if ( '' === $value ) { + return $value; + } + return _gutenberg_mask_api_key( $value ); +} + +/** + * Sanitizes and validates the Gemini API key before saving. + * + * @access private + * + * @param string $value The new value. + * @return string The sanitized value, or empty string if the key is not valid. + */ +function _gutenberg_sanitize_gemini_api_key( string $value ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + $valid = _gutenberg_is_api_key_valid( $value, 'google' ); + return true === $valid ? $value : ''; +} + +// --- OpenAI --- + +/** + * Masks the OpenAI API key on read. + * + * @access private + * + * @param string $value The raw option value. + * @return string Masked key or empty string. + */ +function _gutenberg_mask_openai_api_key( string $value ): string { + if ( '' === $value ) { + return $value; + } + return _gutenberg_mask_api_key( $value ); +} + +/** + * Sanitizes and validates the OpenAI API key before saving. + * + * @access private + * + * @param string $value The new value. + * @return string The sanitized value, or empty string if the key is not valid. + */ +function _gutenberg_sanitize_openai_api_key( string $value ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + $valid = _gutenberg_is_api_key_valid( $value, 'openai' ); + return true === $valid ? $value : ''; +} + +// --- Anthropic --- + +/** + * Masks the Anthropic API key on read. + * + * @access private + * + * @param string $value The raw option value. + * @return string Masked key or empty string. + */ +function _gutenberg_mask_anthropic_api_key( string $value ): string { + if ( '' === $value ) { + return $value; + } + return _gutenberg_mask_api_key( $value ); +} + +/** + * Sanitizes and validates the Anthropic API key before saving. + * + * @access private + * + * @param string $value The new value. + * @return string The sanitized value, or empty string if the key is not valid. + */ +function _gutenberg_sanitize_anthropic_api_key( string $value ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + $valid = _gutenberg_is_api_key_valid( $value, 'anthropic' ); + return true === $valid ? $value : ''; +} + +// --- Connector definitions --- + +/** + * Gets the provider connectors. + * + * @access private + * + * @return array Connectors. + */ +function _gutenberg_get_connectors(): array { + return array( + 'connectors_gemini_api_key' => array( + 'provider' => 'google', + 'mask' => '_gutenberg_mask_gemini_api_key', + 'sanitize' => '_gutenberg_sanitize_gemini_api_key', + ), + 'connectors_openai_api_key' => array( + 'provider' => 'openai', + 'mask' => '_gutenberg_mask_openai_api_key', + 'sanitize' => '_gutenberg_sanitize_openai_api_key', + ), + 'connectors_anthropic_api_key' => array( + 'provider' => 'anthropic', + 'mask' => '_gutenberg_mask_anthropic_api_key', + 'sanitize' => '_gutenberg_sanitize_anthropic_api_key', + ), + ); +} + +// --- REST API filtering --- + +/** + * Validates connector API keys in the REST response when explicitly requested. + * + * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include + * connector fields via `_fields`. For each requested connector field, reads + * the real (unmasked) key, validates it against the provider, and replaces + * the response value with 'invalid_key' if validation fails. + * + * @access private + * + * @param WP_REST_Response $response The response object. + * @param WP_REST_Server $server The server instance. + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response The potentially modified response. + */ +function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { + if ( '/wp/v2/settings' !== $request->get_route() ) { + return $response; + } + + $fields = $request->get_param( '_fields' ); + if ( ! $fields ) { + return $response; + } + + $requested = array_map( 'trim', explode( ',', $fields ) ); + $data = $response->get_data(); + $connectors = _gutenberg_get_connectors(); + + foreach ( $connectors as $option_name => $config ) { + if ( ! in_array( $option_name, $requested, true ) ) { + continue; + } + $real_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] ); + if ( '' === $real_key ) { + continue; + } + if ( true !== _gutenberg_is_api_key_valid( $real_key, $config['provider'] ) ) { + $data[ $option_name ] = 'invalid_key'; + } + } + + $response->set_data( $data ); + return $response; +} +add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 ); + +// --- Registration --- + +/** + * Registers the default connector settings, mask filters, and validation filters. + * + * @access private + */ +function _gutenberg_register_default_connector_settings(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + $connectors = _gutenberg_get_connectors(); + + foreach ( $connectors as $option_name => $config ) { + register_setting( + 'connectors', + $option_name, + array( + 'type' => 'string', + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => $config['sanitize'], + ) + ); + add_filter( "option_{$option_name}", $config['mask'] ); + } +} +add_action( 'init', '_gutenberg_register_default_connector_settings' ); + +/** + * Passes the default connector API keys to the WP AI client. + * + * @access private + */ +function _gutenberg_pass_default_connector_keys_to_ai_client(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + $connectors = _gutenberg_get_connectors(); + + foreach ( $connectors as $option_name => $config ) { + $api_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] ); + if ( ! empty( $api_key ) ) { + _gutenberg_set_provider_api_key( $api_key, $config['provider'] ); + } + } +} +add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client' ); diff --git a/lib/experimental/connectors/load.php b/lib/experimental/connectors/load.php new file mode 100644 index 00000000000000..38783c03b80242 --- /dev/null +++ b/lib/experimental/connectors/load.php @@ -0,0 +1,27 @@ +, + * render: ( { slug, label, description } ) => ( + * } + * name={ label } + * description={ description } + * > + * + * + * ), + * } ); + * ``` + */ +export function registerConnector( + slug: string, + config: Omit< ConnectorConfig, 'slug' > +): void { + unlock( dispatch( store ) ).registerConnector( slug, config ); +} diff --git a/packages/connectors/src/connector-item.tsx b/packages/connectors/src/connector-item.tsx new file mode 100644 index 00000000000000..fa29ec4d6e9b0f --- /dev/null +++ b/packages/connectors/src/connector-item.tsx @@ -0,0 +1,189 @@ +/** + * WordPress dependencies + */ +import { + __experimentalHStack as HStack, + __experimentalVStack as VStack, + __experimentalItem as Item, + __experimentalText as Text, + ExternalLink, + FlexBlock, + Button, + TextControl, +} from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { ReactNode } from 'react'; + +export interface ConnectorItemProps { + className?: string; + icon?: ReactNode; + name: string; + description: string; + actionArea?: ReactNode; + children?: ReactNode; +} + +export function ConnectorItem( { + className, + icon, + name, + description, + actionArea, + children, +}: ConnectorItemProps ) { + return ( + + + + { icon } + + + + { name } + + + { description } + + + + { actionArea } + + { children } + + + ); +} + +export interface DefaultConnectorSettingsProps { + onSave?: ( apiKey: string ) => void | Promise< void >; + onRemove?: () => void; + initialValue?: string; + helpUrl?: string; + helpLabel?: string; + readOnly?: boolean; +} + +/** + * Default settings form for connectors. + * + * @param props - Component props. + * @param props.onSave - Callback invoked with the API key when the user saves. + * @param props.onRemove - Callback invoked when the user removes the connector. + * @param props.initialValue - Initial value for the API key field. + * @param props.helpUrl - URL to documentation for obtaining an API key. + * @param props.helpLabel - Custom label for the help link. Defaults to the URL without protocol. + * @param props.readOnly - Whether the form is in read-only mode. + */ +export function DefaultConnectorSettings( { + onSave, + onRemove, + initialValue = '', + helpUrl, + helpLabel, + readOnly = false, +}: DefaultConnectorSettingsProps ) { + const [ apiKey, setApiKey ] = useState( initialValue ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ saveError, setSaveError ] = useState< string | null >( null ); + + const helpLinkLabel = helpLabel || helpUrl?.replace( /^https?:\/\//, '' ); + + const helpLink = helpUrl ? ( + <> + { __( 'Get your API key at' ) }{ ' ' } + { helpLinkLabel } + + ) : undefined; + + const getHelp = () => { + if ( readOnly ) { + return ( + <> + { __( + 'Your API key is stored securely. You can reset it at' + ) }{ ' ' } + { helpUrl ? ( + + { helpLinkLabel } + + ) : undefined } + + ); + } + if ( saveError ) { + return { saveError }; + } + return helpLink; + }; + + const handleSave = async () => { + setSaveError( null ); + setIsSaving( true ); + try { + await onSave?.( apiKey ); + } catch ( error ) { + setSaveError( + error instanceof Error + ? error.message + : __( + 'It was not possible to connect to the provider using this key.' + ) + ); + } finally { + setIsSaving( false ); + } + }; + + return ( + + { + if ( ! readOnly ) { + setSaveError( null ); + setApiKey( value ); + } + } } + placeholder="YOUR_API_KEY" + disabled={ readOnly || isSaving } + help={ getHelp() } + /> + { readOnly ? ( + + ) : ( + + + + ) } + + ); +} diff --git a/packages/connectors/src/index.ts b/packages/connectors/src/index.ts new file mode 100644 index 00000000000000..bcf5025d6dc7c6 --- /dev/null +++ b/packages/connectors/src/index.ts @@ -0,0 +1,7 @@ +export { registerConnector as __experimentalRegisterConnector } from './api'; +export { + ConnectorItem as __experimentalConnectorItem, + DefaultConnectorSettings as __experimentalDefaultConnectorSettings, +} from './connector-item'; +export type { ConnectorConfig, ConnectorRenderProps } from './types'; +export { privateApis } from './private-apis'; diff --git a/packages/connectors/src/lock-unlock.ts b/packages/connectors/src/lock-unlock.ts new file mode 100644 index 00000000000000..3b6e8ab1bb0067 --- /dev/null +++ b/packages/connectors/src/lock-unlock.ts @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/connectors' + ); diff --git a/packages/connectors/src/private-apis.ts b/packages/connectors/src/private-apis.ts new file mode 100644 index 00000000000000..963df3a09b48ad --- /dev/null +++ b/packages/connectors/src/private-apis.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { lock } from './lock-unlock'; +import { store, STORE_NAME } from './store'; + +export const privateApis: Record< string, never > = {}; +lock( privateApis, { store, STORE_NAME } ); diff --git a/packages/connectors/src/store.ts b/packages/connectors/src/store.ts new file mode 100644 index 00000000000000..1335e050d845fc --- /dev/null +++ b/packages/connectors/src/store.ts @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import type { ConnectorConfig, ConnectorsState } from './types'; +import { unlock } from './lock-unlock'; + +const STORE_NAME = 'core/connectors'; + +const DEFAULT_STATE: ConnectorsState = { + connectors: {}, +}; + +const actions = { + registerConnector( slug: string, config: Omit< ConnectorConfig, 'slug' > ) { + return { + type: 'REGISTER_CONNECTOR' as const, + slug, + config, + }; + }, +}; + +type Action = ReturnType< typeof actions.registerConnector >; + +function reducer( + state: ConnectorsState = DEFAULT_STATE, + action: Action +): ConnectorsState { + switch ( action.type ) { + case 'REGISTER_CONNECTOR': + return { + ...state, + connectors: { + ...state.connectors, + [ action.slug ]: { + slug: action.slug, + ...action.config, + }, + }, + }; + default: + return state; + } +} + +const selectors = { + getConnectors( state: ConnectorsState ): ConnectorConfig[] { + return Object.values( state.connectors ); + }, + getConnector( + state: ConnectorsState, + slug: string + ): ConnectorConfig | undefined { + return state.connectors[ slug ]; + }, +}; + +export const store = createReduxStore( STORE_NAME, { + reducer, +} ); + +register( store ); +unlock( store ).registerPrivateActions( actions ); +unlock( store ).registerPrivateSelectors( selectors ); + +export { STORE_NAME }; diff --git a/packages/connectors/src/types.ts b/packages/connectors/src/types.ts new file mode 100644 index 00000000000000..1b0b72068cd22a --- /dev/null +++ b/packages/connectors/src/types.ts @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +export interface ConnectorRenderProps { + slug: string; + label: string; + description: string; +} + +export interface ConnectorConfig { + slug: string; + label: string; + description: string; + icon?: ReactNode; + render?: ( props: ConnectorRenderProps ) => ReactNode; +} + +export interface ConnectorsState { + connectors: Record< string, ConnectorConfig >; +} diff --git a/packages/connectors/tsconfig.json b/packages/connectors/tsconfig.json new file mode 100644 index 00000000000000..62c44d7383dc2b --- /dev/null +++ b/packages/connectors/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../components" }, + { "path": "../data" }, + { "path": "../element" }, + { "path": "../i18n" }, + { "path": "../icons" }, + { "path": "../private-apis" } + ] +} diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts index e6794d89c806af..6f50a880556253 100644 --- a/packages/private-apis/src/implementation.ts +++ b/packages/private-apis/src/implementation.ts @@ -16,6 +16,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/blocks', '@wordpress/boot', '@wordpress/commands', + '@wordpress/connectors', '@wordpress/workflows', '@wordpress/components', '@wordpress/core-commands', diff --git a/routes/connectors-home/default-connectors.tsx b/routes/connectors-home/default-connectors.tsx new file mode 100644 index 00000000000000..537064ee1388ae --- /dev/null +++ b/routes/connectors-home/default-connectors.tsx @@ -0,0 +1,177 @@ +/** + * WordPress dependencies + */ +import { __experimentalHStack as HStack, Button } from '@wordpress/components'; +import { + __experimentalRegisterConnector as registerConnector, + __experimentalConnectorItem as ConnectorItem, + __experimentalDefaultConnectorSettings as DefaultConnectorSettings, + type ConnectorRenderProps, +} from '@wordpress/connectors'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { useConnectorPlugin } from './use-connector-plugin'; +import { OpenAILogo, ClaudeLogo, GeminiLogo } from './logos'; + +const ConnectedBadge = () => ( + + { __( 'Connected' ) } + +); + +interface ConnectorConfig { + pluginSlug: string; + settingName: string; + helpUrl: string; + helpLabel: string; + Logo: React.ComponentType; +} + +function ProviderConnector( { + label, + description, + pluginSlug, + settingName, + helpUrl, + helpLabel, + Logo, +}: ConnectorRenderProps & ConnectorConfig ) { + const { + pluginStatus, + isExpanded, + setIsExpanded, + isBusy, + isConnected, + currentApiKey, + handleButtonClick, + getButtonLabel, + saveApiKey, + removeApiKey, + } = useConnectorPlugin( { + pluginSlug, + settingName, + } ); + + return ( + } + name={ label } + description={ description } + actionArea={ + + { isConnected && } + + + } + > + { isExpanded && pluginStatus === 'active' && ( + { + await saveApiKey( apiKey ); + setIsExpanded( false ); + } } + /> + ) } + + ); +} + +// OpenAI connector render component +function OpenAIConnector( props: ConnectorRenderProps ) { + return ( + + ); +} + +// Claude connector render component +function ClaudeConnector( props: ConnectorRenderProps ) { + return ( + + ); +} + +// Gemini connector render component +function GeminiConnector( props: ConnectorRenderProps ) { + return ( + + ); +} + +// Register built-in connectors +export function registerDefaultConnectors() { + registerConnector( 'core/openai', { + label: __( 'OpenAI' ), + description: __( + 'Text, image, and code generation with GPT and DALL-E.' + ), + render: OpenAIConnector, + } ); + + registerConnector( 'core/claude', { + label: __( 'Claude' ), + description: __( 'Writing, research, and analysis with Claude.' ), + render: ClaudeConnector, + } ); + + registerConnector( 'core/gemini', { + label: __( 'Gemini' ), + description: __( + "Content generation, translation, and vision with Google's Gemini." + ), + render: GeminiConnector, + } ); +} diff --git a/routes/connectors-home/logos.tsx b/routes/connectors-home/logos.tsx new file mode 100644 index 00000000000000..bdba2db470aa26 --- /dev/null +++ b/routes/connectors-home/logos.tsx @@ -0,0 +1,97 @@ +// OpenAI logo as inline SVG +export const OpenAILogo = () => ( + + + +); + +// Claude/Anthropic logo as inline SVG +export const ClaudeLogo = () => ( + + + +); + +// Gemini logo as inline SVG +export const GeminiLogo = () => ( + + + + + + + + + + + + + + + + + + + + +); diff --git a/routes/connectors-home/package.json b/routes/connectors-home/package.json new file mode 100644 index 00000000000000..862f5f2b3ce8c5 --- /dev/null +++ b/routes/connectors-home/package.json @@ -0,0 +1,21 @@ +{ + "name": "@wordpress/connectors-home-route", + "version": "1.0.0", + "private": true, + "route": { + "path": "/", + "page": [ + "connectors" + ] + }, + "dependencies": { + "@wordpress/admin-ui": "file:../../packages/admin-ui", + "@wordpress/api-fetch": "file:../../packages/api-fetch", + "@wordpress/components": "file:../../packages/components", + "@wordpress/connectors": "file:../../packages/connectors", + "@wordpress/data": "file:../../packages/data", + "@wordpress/element": "file:../../packages/element", + "@wordpress/i18n": "file:../../packages/i18n", + "@wordpress/icons": "file:../../packages/icons" + } +} diff --git a/routes/connectors-home/route.ts b/routes/connectors-home/route.ts new file mode 100644 index 00000000000000..e8de335016b8ac --- /dev/null +++ b/routes/connectors-home/route.ts @@ -0,0 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export const route = { + title: () => __( 'Connectors' ), +}; diff --git a/routes/connectors-home/stage.tsx b/routes/connectors-home/stage.tsx new file mode 100644 index 00000000000000..5f2a26ca3b0e98 --- /dev/null +++ b/routes/connectors-home/stage.tsx @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { Page } from '@wordpress/admin-ui'; +import { __experimentalVStack as VStack } from '@wordpress/components'; +import { + privateApis as connectorsPrivateApis, + type ConnectorConfig, +} from '@wordpress/connectors'; +import { useSelect } from '@wordpress/data'; +import { createInterpolateElement } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { registerDefaultConnectors } from './default-connectors'; +import { unlock } from '../lock-unlock'; + +const { store } = unlock( connectorsPrivateApis ); + +// Register built-in connectors +registerDefaultConnectors(); + +function ConnectorsPage() { + const connectors = useSelect( + ( select ) => unlock( select( store ) ).getConnectors(), + [] + ); + + return ( + +
+ + { connectors.map( ( connector: ConnectorConfig ) => { + if ( connector.render ) { + return ( + + ); + } + return null; + } ) } + +

+ { createInterpolateElement( + __( + 'Find more connectors in the plugin directory' + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + } + ) } +

+
+
+ ); +} + +function Stage() { + return ; +} + +export const stage = Stage; diff --git a/routes/connectors-home/style.scss b/routes/connectors-home/style.scss new file mode 100644 index 00000000000000..ad56bad0ed5069 --- /dev/null +++ b/routes/connectors-home/style.scss @@ -0,0 +1,25 @@ +@use "@wordpress/base-styles/colors" as *; + +.connectors-page { + width: 100%; + max-width: 680px; + margin: 0 auto; + padding: 24px; + + .components-item { + padding: 20px; + border-radius: 8px; + border: 1px solid #ddd; + background: #fff; + overflow: hidden; + } + + .connector-settings .components-text-control__input { + font-family: monospace; + } + + > p { + text-align: center; + color: $gray-600; + } +} diff --git a/routes/connectors-home/use-connector-plugin.ts b/routes/connectors-home/use-connector-plugin.ts new file mode 100644 index 00000000000000..1fda7a5a53b069 --- /dev/null +++ b/routes/connectors-home/use-connector-plugin.ts @@ -0,0 +1,211 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +export type PluginStatus = 'checking' | 'not-installed' | 'inactive' | 'active'; + +interface UseConnectorPluginOptions { + pluginSlug: string; + settingName: string; +} + +interface UseConnectorPluginReturn { + pluginStatus: PluginStatus; + isExpanded: boolean; + setIsExpanded: ( expanded: boolean ) => void; + isBusy: boolean; + isConnected: boolean; + currentApiKey: string; + handleButtonClick: () => void; + getButtonLabel: () => string; + saveApiKey: ( apiKey: string ) => Promise< void >; + removeApiKey: () => Promise< void >; +} + +export function useConnectorPlugin( { + pluginSlug, + settingName, +}: UseConnectorPluginOptions ): UseConnectorPluginReturn { + const [ pluginStatus, setPluginStatus ] = + useState< PluginStatus >( 'checking' ); + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ isBusy, setIsBusy ] = useState( false ); + const [ currentApiKey, setCurrentApiKey ] = useState( '' ); + + const isConnected = + pluginStatus === 'active' && + currentApiKey !== '' && + currentApiKey !== 'invalid_key'; + + // Fetch the current API key + const fetchApiKey = useCallback( async () => { + try { + const settings = await apiFetch< Record< string, string > >( { + path: `/wp/v2/settings?_fields=${ settingName }`, + } ); + const key = settings[ settingName ] || ''; + setCurrentApiKey( key === 'invalid_key' ? '' : key ); + } catch { + // Ignore errors + } + }, [ settingName ] ); + + // Check plugin status on mount + useEffect( () => { + const checkPluginStatus = async () => { + try { + const plugins = await apiFetch< + Array< { plugin: string; status: string } > + >( { + path: '/wp/v2/plugins', + } ); + + const plugin = plugins.find( + ( p ) => p.plugin === `${ pluginSlug }/plugin` + ); + + if ( ! plugin ) { + setPluginStatus( 'not-installed' ); + } else if ( plugin.status === 'active' ) { + await fetchApiKey(); + setPluginStatus( 'active' ); + } else { + setPluginStatus( 'inactive' ); + } + } catch { + setPluginStatus( 'not-installed' ); + } + }; + + checkPluginStatus(); + }, [ pluginSlug, fetchApiKey ] ); + + const installPlugin = async () => { + setIsBusy( true ); + try { + await apiFetch( { + method: 'POST', + path: '/wp/v2/plugins', + data: { slug: pluginSlug, status: 'active' }, + } ); + setPluginStatus( 'active' ); + await fetchApiKey(); + setIsExpanded( true ); + } catch { + // Handle error + } finally { + setIsBusy( false ); + } + }; + + const activatePlugin = async () => { + setIsBusy( true ); + try { + await apiFetch( { + method: 'PUT', + path: `/wp/v2/plugins/${ pluginSlug }/plugin`, + data: { status: 'active' }, + } ); + setPluginStatus( 'active' ); + await fetchApiKey(); + setIsExpanded( true ); + } catch { + // Handle error + } finally { + setIsBusy( false ); + } + }; + + const handleButtonClick = () => { + if ( pluginStatus === 'not-installed' ) { + installPlugin(); + } else if ( pluginStatus === 'inactive' ) { + activatePlugin(); + } else { + setIsExpanded( ! isExpanded ); + } + }; + + const getButtonLabel = () => { + if ( isBusy ) { + return pluginStatus === 'not-installed' + ? __( 'Installing…' ) + : __( 'Activating…' ); + } + if ( isExpanded ) { + return __( 'Cancel' ); + } + if ( isConnected ) { + return __( 'Edit' ); + } + switch ( pluginStatus ) { + case 'checking': + return __( 'Checking…' ); + case 'not-installed': + return __( 'Install' ); + case 'inactive': + return __( 'Activate' ); + case 'active': + return __( 'Set up' ); + } + }; + + const saveApiKey = async ( apiKey: string ) => { + try { + const result = await apiFetch< Record< string, string > >( { + method: 'POST', + path: `/wp/v2/settings?_fields=${ settingName }`, + data: { + [ settingName ]: apiKey, + }, + } ); + + // If we sent a non-empty key but the returned value didn't + // change, the server rejected the update (validation failed). + if ( apiKey && result[ settingName ] === currentApiKey ) { + throw new Error( + 'It was not possible to connect to the provider using this key.' + ); + } + + setCurrentApiKey( result[ settingName ] || '' ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to save API key:', error ); + throw error; + } + }; + + const removeApiKey = async () => { + try { + await apiFetch( { + method: 'POST', + path: `/wp/v2/settings?_fields=${ settingName }`, + data: { + [ settingName ]: '', + }, + } ); + setCurrentApiKey( '' ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( 'Failed to remove API key:', error ); + throw error; + } + }; + + return { + pluginStatus, + isExpanded, + setIsExpanded, + isBusy, + isConnected, + currentApiKey, + handleButtonClick, + getButtonLabel, + saveApiKey, + removeApiKey, + }; +} diff --git a/test/e2e/specs/admin/connectors.spec.js b/test/e2e/specs/admin/connectors.spec.js new file mode 100644 index 00000000000000..9dc223f5fb0a68 --- /dev/null +++ b/test/e2e/specs/admin/connectors.spec.js @@ -0,0 +1,100 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const SETTINGS_PAGE_PATH = 'options-general.php'; +const CONNECTORS_PAGE_QUERY = 'page=connectors-wp-admin'; + +test.describe( 'Connectors', () => { + test( 'should show a Connectors link in the Settings menu', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( SETTINGS_PAGE_PATH ); + + const settingsMenu = page.locator( '#menu-settings' ); + const connectorsLink = settingsMenu.getByRole( 'link', { + name: 'Connectors', + } ); + await expect( connectorsLink ).toBeVisible(); + await expect( connectorsLink ).toHaveAttribute( + 'href', + `${ SETTINGS_PAGE_PATH }?${ CONNECTORS_PAGE_QUERY }` + ); + } ); + + test.describe( 'Connectors page', () => { + test.beforeEach( async ( { admin } ) => { + await admin.visitAdminPage( + SETTINGS_PAGE_PATH, + CONNECTORS_PAGE_QUERY + ); + } ); + + test( 'should display default providers with install buttons', async ( { + page, + } ) => { + // Verify the page title is visible. + await expect( + page.getByRole( 'heading', { name: 'Connectors' } ) + ).toBeVisible(); + + // Verify each connector card allows installing the plugin. + const openaiCard = page.locator( + '.connector-item--ai-provider-for-openai' + ); + await expect( openaiCard ).toBeVisible(); + await expect( + openaiCard.getByText( 'OpenAI', { exact: true } ) + ).toBeVisible(); + await expect( + openaiCard.getByText( + 'Text, image, and code generation with GPT and DALL-E.' + ) + ).toBeVisible(); + await expect( + openaiCard.getByRole( 'button', { name: 'Install' } ) + ).toBeVisible(); + + const claudeCard = page.locator( + '.connector-item--ai-provider-for-anthropic' + ); + await expect( claudeCard ).toBeVisible(); + await expect( + claudeCard.getByText( 'Claude', { exact: true } ) + ).toBeVisible(); + await expect( + claudeCard.getByText( + 'Writing, research, and analysis with Claude.' + ) + ).toBeVisible(); + await expect( + claudeCard.getByRole( 'button', { name: 'Install' } ) + ).toBeVisible(); + + const geminiCard = page.locator( + '.connector-item--ai-provider-for-google' + ); + await expect( geminiCard ).toBeVisible(); + await expect( + geminiCard.getByText( 'Gemini', { exact: true } ) + ).toBeVisible(); + await expect( + geminiCard.getByText( + "Content generation, translation, and vision with Google's Gemini." + ) + ).toBeVisible(); + await expect( + geminiCard.getByRole( 'button', { name: 'Install' } ) + ).toBeVisible(); + + // Verify the plugin directory search link is present. + await expect( + page.getByRole( 'link', { + name: 'the plugin directory', + } ) + ).toHaveAttribute( 'href', 'plugin-install.php' ); + } ); + } ); +} ); diff --git a/tsconfig.json b/tsconfig.json index 280170166fb0cb..7489ddc31c0bf7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ { "path": "packages/block-serialization-default-parser" }, { "path": "packages/boot" }, { "path": "packages/components" }, + { "path": "packages/connectors" }, { "path": "packages/compose" }, { "path": "packages/core-abilities" }, { "path": "packages/core-data" },