diff --git a/includes/class-newspack.php b/includes/class-newspack.php index d5b1a01113..a47a58dbb2 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -182,6 +182,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/wizards/audience/class-audience-content-gates.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/audience/class-audience-donations.php'; include_once NEWSPACK_ABSPATH . 'includes/wizards/audience/class-audience-subscriptions.php'; + include_once NEWSPACK_ABSPATH . 'includes/wizards/audience/class-audience-integrations.php'; // Network Wizard. include_once NEWSPACK_ABSPATH . 'includes/wizards/class-network-wizard.php'; diff --git a/includes/class-wizards.php b/includes/class-wizards.php index df1f3c6ba5..cd621e0ea4 100644 --- a/includes/class-wizards.php +++ b/includes/class-wizards.php @@ -66,6 +66,7 @@ public static function init_wizards() { 'audience-campaigns' => new Audience_Campaigns(), 'audience-content-gates' => new Audience_Content_Gates(), 'audience-donations' => new Audience_Donations(), + 'audience-integrations' => new Audience_Integrations(), 'listings' => new Listings_Wizard(), 'network' => new Network_Wizard(), 'newsletters' => new Newsletters_Wizard(), diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index 76558804ec..621231c8f8 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -240,6 +240,47 @@ public static function are_integrations_registered() { return self::$integrations_registered; } + /** + * Get settings config for all integrations that have settings fields. + * + * @return array Keyed array of integration settings. + */ + public static function get_all_integration_settings() { + $result = []; + foreach ( self::$integrations as $id => $integration ) { + $fields = $integration->get_settings_fields(); + if ( empty( $fields ) ) { + continue; + } + $result[ $id ] = [ + 'id' => $id, + 'name' => $integration->get_name(), + 'description' => $integration->get_description(), + 'enabled' => self::is_enabled( $id ), + 'settings' => $integration->get_settings_config(), + ]; + } + return $result; + } + + /** + * Update settings for a specific integration. + * + * @param string $integration_id The integration ID. + * @param array $settings Key-value pairs of settings to update. + * @return bool|null True if updated, null if integration not found. + */ + public static function update_integration_settings( $integration_id, $settings ) { + $integration = self::get_integration( $integration_id ); + if ( ! $integration ) { + return null; + } + foreach ( $settings as $key => $value ) { + $integration->update_settings_field_value( $key, $value ); + } + return true; + } + /** * Register a data event handler for an integration. * diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 04af2d85fb..1b8315d166 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -431,6 +431,13 @@ public static function get_setting( $name ) { if ( ! isset( $config[ $name ] ) ) { return null; } + + // Route ESP settings to the integration. + $esp_setting = self::get_esp_integration_setting( $name ); + if ( null !== $esp_setting ) { + return apply_filters( 'newspack_reader_activation_setting', $esp_setting, $name ); + } + $value = \get_option( self::OPTIONS_PREFIX . $name, $config[ $name ] ); // Use default value type for casting bool option value. @@ -440,6 +447,38 @@ public static function get_setting( $name ) { return apply_filters( 'newspack_reader_activation_setting', $value, $name ); } + /** + * Get an ESP setting value from the integration instance. + * + * @param string $name Setting name. + * @return mixed|null The setting value, or null if this setting is not an ESP integration setting. + */ + private static function get_esp_integration_setting( $name ) { + static $esp_keys = [ + 'mailchimp_audience_id', + 'mailchimp_reader_default_status', + 'active_campaign_master_list', + 'constant_contact_list_id', + 'sync_esp_delete', + 'sync_esp', + ]; + + if ( ! in_array( $name, $esp_keys, true ) ) { + return null; + } + + $esp = Reader_Activation\Integrations::get_integration( 'esp' ); + if ( ! $esp ) { + return null; + } + + if ( 'sync_esp' === $name ) { + return $esp->is_enabled(); + } + + return $esp->get_settings_field_value( $name ); + } + /** * Update a setting value. * @@ -472,6 +511,24 @@ public static function update_setting( $key, $value ) { return Sync\Metadata::update_fields( $value ); } + // Route sync_esp to the integration enabled state. + if ( 'sync_esp' === $key ) { + if ( $value ) { + Reader_Activation\Integrations::enable( 'esp' ); + } else { + Reader_Activation\Integrations::disable( 'esp' ); + } + // Also write to legacy option for backward compat with external hooks. + \update_option( self::OPTIONS_PREFIX . $key, $value ); + return true; + } + + // Route ESP settings to the integration. + $esp = Reader_Activation\Integrations::get_integration( 'esp' ); + if ( $esp && null !== self::get_esp_integration_setting( $key ) ) { + return $esp->update_settings_field_value( $key, $value ); + } + return \update_option( self::OPTIONS_PREFIX . $key, $value ); } diff --git a/includes/reader-activation/integrations/class-contact-pull.php b/includes/reader-activation/integrations/class-contact-pull.php index 66a2f6e96a..937537cf99 100644 --- a/includes/reader-activation/integrations/class-contact-pull.php +++ b/includes/reader-activation/integrations/class-contact-pull.php @@ -144,7 +144,7 @@ private static function pull_sync( $user_id, $integrations ) { $failed = []; foreach ( $integrations as $id => $integration ) { - $selected_fields = $integration->get_selected_fields(); + $selected_fields = $integration->get_enabled_incoming_fields(); if ( empty( $selected_fields ) ) { continue; } @@ -235,9 +235,9 @@ public static function handle_ajax_pull() { * @return true|\WP_Error True on success, WP_Error on failure. */ public static function pull_single_integration( $user_id, $integration ) { - $selected_fields = $integration->get_selected_fields(); + $selected_fields = $integration->get_enabled_incoming_fields(); if ( empty( $selected_fields ) ) { - return new \WP_Error( 'no_selected_fields', 'No selected fields for ' . $integration->get_id() ); + return new \WP_Error( 'no_selected_incoming_fields', 'No selected incoming fields for ' . $integration->get_id() ); } try { @@ -275,7 +275,7 @@ private static function schedule_async_pulls( $user_id, $integrations ) { } foreach ( $integrations as $integration ) { - $selected_fields = $integration->get_selected_fields(); + $selected_fields = $integration->get_enabled_incoming_fields(); if ( empty( $selected_fields ) ) { continue; } diff --git a/includes/reader-activation/integrations/class-esp.php b/includes/reader-activation/integrations/class-esp.php index f4854b1deb..d49355b428 100644 --- a/includes/reader-activation/integrations/class-esp.php +++ b/includes/reader-activation/integrations/class-esp.php @@ -9,6 +9,8 @@ use Newspack\Reader_Activation\Integration; use Newspack\Reader_Activation\Sync; +use Newspack\Reader_Activation\Sync\Metadata; +use Newspack\Reader_Activation\Integrations; use Newspack\Reader_Activation; use Newspack_Newsletters_Contacts; use Newspack_Newsletters_Subscription; @@ -25,7 +27,162 @@ class ESP extends Integration { * Constructor. */ public function __construct() { - parent::__construct( 'esp', __( 'ESPs Integration', 'newspack-plugin' ) ); + parent::__construct( + 'esp', + __( 'ESP', 'newspack-plugin' ), + __( 'Sync reader data and activity to the connected email service provider.', 'newspack-plugin' ) + ); + } + + /** + * Register the settings fields declared by this integration. + * + * Dynamically builds the field list based on the active ESP provider. + * Only returns fields when ESP is configured. + * + * @return array Array of settings field declarations. + */ + public function register_settings_fields() { + $fields = []; + if ( ! Reader_Activation::is_esp_configured() ) { + return $fields; + } + $list_options = $this->get_list_options(); + $provider = $this->get_provider(); + if ( $provider ) { + switch ( $provider->service ) { + case 'mailchimp': + $fields[] = [ + 'key' => 'mailchimp_audience_id', + 'type' => 'select', + 'label' => __( 'Mailchimp Audience', 'newspack-plugin' ), + 'description' => __( 'Choose an audience to receive reader activity data.', 'newspack-plugin' ), + 'options' => $list_options, + 'default' => '', + ]; + $fields[] = [ + 'key' => 'mailchimp_reader_default_status', + 'type' => 'select', + 'label' => __( 'Default reader status', 'newspack-plugin' ), + 'description' => __( 'Choose which Mailchimp status readers should have by default if they are not subscribed to any newsletters.', 'newspack-plugin' ), + 'options' => [ + [ + 'label' => __( 'Transactional/Non-Subscribed', 'newspack-plugin' ), + 'value' => 'transactional', + ], + [ + 'label' => __( 'Subscribed', 'newspack-plugin' ), + 'value' => 'subscribed', + ], + ], + 'default' => 'transactional', + ]; + break; + case 'active_campaign': + $fields[] = [ + 'key' => 'active_campaign_master_list', + 'type' => 'select', + 'label' => __( 'ActiveCampaign Master List', 'newspack-plugin' ), + 'description' => __( 'Choose a master list to which all registered readers will be added.', 'newspack-plugin' ), + 'options' => $list_options, + 'default' => '', + ]; + break; + case 'constant_contact': + $fields[] = [ + 'key' => 'constant_contact_list_id', + 'type' => 'select', + 'label' => __( 'Constant Contact Master List', 'newspack-plugin' ), + 'description' => __( 'Choose a master list to which all registered readers will be added.', 'newspack-plugin' ), + 'options' => $list_options, + 'default' => '', + ]; + break; + } + } + $fields[] = [ + 'key' => 'sync_esp_delete', + 'type' => 'checkbox', + 'label' => __( 'Sync user account deletion', 'newspack-plugin' ), + 'description' => __( 'When a reader account is deleted, also remove the contact from the ESP.', 'newspack-plugin' ), + 'default' => true, + ]; + return $fields; + } + + /** + * Get the active ESP provider name. + * + * @return Newspack_Newsletters_Service_Provider|null The service provider object or null if not available. + */ + private function get_provider() { + if ( class_exists( 'Newspack_Newsletters' ) ) { + return \Newspack_Newsletters::get_service_provider(); + } + return null; + } + + /** + * Get list options from the Newsletters API for select fields. + * + * @return array Array of options with label and value keys. + */ + private function get_list_options() { + if ( ! method_exists( 'Newspack_Newsletters_Subscription', 'get_lists' ) ) { + return []; + } + + $lists = Newspack_Newsletters_Subscription::get_lists(); + if ( is_wp_error( $lists ) || ! is_array( $lists ) ) { + return []; + } + + $provider = $this->get_provider(); + + // For Mailchimp, filter out groups and tags, only include remote lists. + if ( 'mailchimp' === $provider->service ) { + $lists = $provider->get_lists( true ); + } + + $options = [ + [ + 'label' => __( 'None', 'newspack-plugin' ), + 'value' => '', + ], + ]; + foreach ( $lists as $list ) { + $options[] = [ + 'label' => $list['name'] ?? $list['id'], + 'value' => $list['id'], + ]; + } + + return $options; + } + + /** + * Get the master list ID from integration settings. + * + * @return string|false The master list ID or false. + */ + public function get_master_list_id() { + $provider = $this->get_provider(); + if ( ! $provider ) { + return false; + } + switch ( $provider->service ) { + case 'mailchimp': + $audience_id = $this->get_settings_field_value( 'mailchimp_audience_id' ); + return ! empty( $audience_id ) ? $audience_id : false; + case 'active_campaign': + $list_id = $this->get_settings_field_value( 'active_campaign_master_list' ); + return ! empty( $list_id ) ? $list_id : false; + case 'constant_contact': + $list_id = $this->get_settings_field_value( 'constant_contact_list_id' ); + return ! empty( $list_id ) ? $list_id : false; + default: + return false; + } } /** @@ -84,14 +241,13 @@ public function can_sync( $return_errors = false ) { ); } - if ( ! Reader_Activation::get_setting( 'sync_esp' ) ) { + if ( ! Integrations::is_enabled( $this->get_id() ) ) { $errors->add( 'ras_esp_sync_not_enabled', __( 'ESP sync is not enabled.', 'newspack-plugin' ) ); } - - if ( ! Reader_Activation::get_esp_master_list_id() ) { + if ( ! $this->get_master_list_id() ) { $errors->add( 'ras_esp_master_list_id_not_found', __( 'ESP master list ID is not set.', 'newspack-plugin' ) @@ -119,13 +275,12 @@ public function can_sync( $return_errors = false ) { * @return true|\WP_Error True on success or WP_Error on failure. */ public function push_contact_data( $contact, $context = '', $existing_contact = null ) { - $can_sync = $this->can_sync( true ); if ( $can_sync->has_errors() ) { return $can_sync; } - $master_list_id = Reader_Activation::get_esp_master_list_id(); + $master_list_id = $this->get_master_list_id(); return Newspack_Newsletters_Contacts::upsert( $contact, $master_list_id, $context, $existing_contact ); } @@ -181,8 +336,7 @@ public function test_connection() { * * @return Incoming_Contact_Field[]|\WP_Error Array of incoming contact field objects or WP_Error on failure. */ - public function get_incoming_available_contact_fields() { - + public function get_available_incoming_contact_fields() { if ( ! class_exists( 'Newspack_Newsletters_Contacts' ) ) { return new \WP_Error( 'newspack_newsletters_contacts_not_found', @@ -190,7 +344,7 @@ public function get_incoming_available_contact_fields() { ); } - $master_list_id = Reader_Activation::get_esp_master_list_id(); + $master_list_id = $this->get_master_list_id(); if ( empty( $master_list_id ) ) { return new \WP_Error( diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 54830c5438..949a57c6e9 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -16,11 +16,24 @@ */ abstract class Integration { /** - * Option name prefix for storing selected fields per integration. + * Map of ESP setting keys to their legacy option names. + * + * @var array + */ + private static $legacy_option_map = [ + 'mailchimp_audience_id' => 'newspack_reader_activation_mailchimp_audience_id', + 'mailchimp_reader_default_status' => 'newspack_reader_activation_mailchimp_reader_default_status', + 'active_campaign_master_list' => 'newspack_reader_activation_active_campaign_master_list', + 'constant_contact_list_id' => 'newspack_reader_activation_constant_contact_list_id', + 'sync_esp_delete' => 'newspack_reader_activation_sync_esp_delete', + ]; + + /** + * Option name prefix for storing enabled incoming metadata fields per integration. * * @var string */ - const OPTION_PREFIX = 'newspack_integration_selected_fields_'; + const INCOMING_FIELDS_OPTION_PREFIX = 'newspack_integration_incoming_fields_'; /** * Option name prefix for storing enabled outgoing metadata fields per integration. @@ -29,6 +42,20 @@ abstract class Integration { */ const OUTGOING_FIELDS_OPTION_PREFIX = 'newspack_integration_outgoing_fields_'; + /** + * Option name prefix for storing all integration settings. + * + * @var string + */ + const SETTINGS_OPTION_PREFIX = 'newspack_integration_settings_'; + + /** + * Option name prefix for storing metadata prefix per integration. + * + * @var string + */ + const METADATA_PREFIX_OPTION_PREFIX = 'newspack_integration_metadata_prefix_'; + /** * The unique identifier for this integration. * @@ -43,6 +70,13 @@ abstract class Integration { */ protected $name; + /** + * A short description for this integration. + * + * @var string + */ + protected $description = ''; + /** * Settings fields for this integration. * @@ -53,12 +87,16 @@ abstract class Integration { /** * Constructor. * - * @param string $id The unique identifier for this integration. - * @param string $name The display name for this integration. + * @param string $id The unique identifier for this integration. + * @param string $name The display name for this integration. + * @param string $description Optional. A short description for this integration. */ - public function __construct( $id, $name ) { - $this->id = $id; - $this->name = $name; + public function __construct( $id, $name, $description = '' ) { + $this->id = $id; + $this->name = $name; + $this->description = $description; + + add_action( 'init', [ $this, 'init' ] ); } /** @@ -79,6 +117,34 @@ public function get_name() { return $this->name; } + /** + * Get the integration description. + * + * @return string The integration description. + */ + public function get_description() { + return $this->description; + } + + /** + * Initialize the integration, performing any necessary setup or validation. + * + * Currently only initializes settings fields, but can be extended by child classes for additional setup. + */ + public function init() { + $this->settings_fields = $this->register_settings_fields(); + } + + /** + * Register settings fields for this integration. + * + * Child classes should override this method to define their settings fields. + * Each field should be an associative array with keys: key, label, type, default, options (for select), etc. + * + * @return array Array of settings field declarations. + */ + abstract public function register_settings_fields(); + /** * Whether contacts can be synced to the ESP. * @@ -168,54 +234,36 @@ public function pull_contact_data( $user_id ) { * * @return Integrations\Incoming_Contact_Field[]|\WP_Error Array of incoming contact field objects or WP_Error on failure. */ - public function get_incoming_available_contact_fields() { + public function get_available_incoming_contact_fields() { return []; } /** - * Get incoming contact fields that are not already in the metadata. - * - * This method filters the available contact fields to exclude fields - * whose keys already exist in the synced metadata. + * Get filtered incoming contact fields from the integration. * - * @return Integrations\Incoming_Contact_Field[]|\WP_Error Array of filtered incoming contact field objects or WP_Error on failure. + * @return Integrations\Incoming_Contact_Field[] Array of incoming contact field objects. */ - public function get_incoming_contact_fields() { - $available_fields = $this->get_incoming_available_contact_fields(); - - if ( is_wp_error( $available_fields ) ) { - return $available_fields; + public function get_filtered_incoming_contact_fields() { + $fields = $this->get_available_incoming_contact_fields(); + if ( is_wp_error( $fields ) ) { + return []; } - - $prefixed_keys = Sync\Metadata::get_all_prefixed_keys(); - - return array_filter( - $available_fields, - function( $field ) use ( $prefixed_keys ) { - return ! in_array( $field->get_key(), $prefixed_keys, true ); - } + $keys_to_filter = Sync\Metadata::get_all_prefixed_keys(); + return array_values( + array_filter( + $fields, + function( $field ) use ( $keys_to_filter ) { + foreach ( $keys_to_filter as $key_to_filter ) { + if ( strpos( $field->get_key(), $key_to_filter ) === 0 ) { + return false; + } + } + return true; + } + ) ); } - /** - * Get the selected fields for this integration. - * - * @return array Array of selected field keys. - */ - public function get_selected_fields() { - return \get_option( self::OPTION_PREFIX . $this->id, [] ); - } - - /** - * Set the selected fields for this integration. - * - * @param array $fields Array of field keys to store. - * @return bool True if the option was updated, false otherwise. - */ - public function set_selected_fields( $fields ) { - return \update_option( self::OPTION_PREFIX . $this->id, array_values( $fields ) ); - } - /** * Test the live connection to the integration service. * @@ -249,13 +297,33 @@ final public function health_check() { return true; } + /** + * Get the enabled incoming metadata fields for this integration. + * + * @return string[] List of enabled field names. + */ + public function get_enabled_incoming_fields() { + return \get_option( self::INCOMING_FIELDS_OPTION_PREFIX . $this->id, [] ); + } + /** * Get the enabled outgoing metadata fields for this integration. * * @return string[] List of enabled field names. */ public function get_enabled_outgoing_fields() { - return array_values( \get_option( self::OUTGOING_FIELDS_OPTION_PREFIX . $this->id, Sync\Metadata::get_default_fields() ) ); + return array_values( \get_option( self::OUTGOING_FIELDS_OPTION_PREFIX . $this->id, [] ) ); + } + + /** + * Update the enabled incoming metadata fields for this integration. + * + * @param array $fields List of field names to enable. + * + * @return bool True if updated, false otherwise. + */ + public function update_enabled_incoming_fields( $fields ) { + return \update_option( self::INCOMING_FIELDS_OPTION_PREFIX . $this->id, $fields ); } /** @@ -280,46 +348,248 @@ public function filter_enabled_outgoing_fields( $keys ) { $enabled_fields = $this->get_enabled_outgoing_fields(); return array_filter( Sync\Metadata::get_keys(), - function( $val, $key ) use ( $keys, $enabled_fields ) { - return in_array( $key, $keys ) && in_array( $val, $enabled_fields ); + function ( $val, $key ) use ( $keys, $enabled_fields ) { + return in_array( $key, $keys, true ) && in_array( $val, $enabled_fields, true ); }, ARRAY_FILTER_USE_BOTH ); } /** - * Get the raw (unprefixed) metadata keys enabled for outgoing sync. + * Get the metadata keys enabled for outgoing sync. + * + * @param bool $prefixed Optional. Whether to return prefixed keys instead of raw keys. Default false. * * @return string[] List of raw metadata keys. */ - public function get_enabled_outgoing_fields_raw_keys() { + public function get_enabled_outgoing_fields_keys( $prefixed = false ) { $enabled_fields = $this->get_enabled_outgoing_fields(); - $raw_keys = []; + $keys = []; foreach ( Sync\Metadata::get_keys() as $raw_key => $field_name ) { if ( in_array( $field_name, $enabled_fields, true ) ) { - $raw_keys[] = $raw_key; + $keys[] = $prefixed ? $this->get_metadata_prefix() . $field_name : $raw_key; } } - return array_unique( $raw_keys ); + return array_unique( $keys ); } /** - * Get the prefixed metadata keys enabled for outgoing sync. + * Get the metadata fields declared by this integration. * - * @return string[] List of prefixed metadata keys. + * @return array Array of settings field declarations. */ - public function get_enabled_outgoing_fields_prefixed_keys() { - $enabled_fields = $this->get_enabled_outgoing_fields(); - $prefixed_keys = []; + public function get_metadata_fields() { + return [ + [ + 'key' => 'metadata_prefix', + 'type' => 'text', + 'label' => __( 'Metadata field prefix', 'newspack-plugin' ), + 'description' => __( 'A string to prefix metadata fields synced to the integration. Required to ensure that metadata field names are unique. Default: NP_', 'newspack-plugin' ), + 'default' => 'NP_', + ], + [ + 'key' => 'outgoing_metadata_fields', + 'type' => 'metadata', + 'label' => __( 'Outgoing metadata fields', 'newspack-plugin' ), + 'default' => [], + ], + [ + 'key' => 'incoming_metadata_fields', + 'type' => 'metadata', + 'label' => __( 'Incoming metadata fields', 'newspack-plugin' ), + 'default' => [], + ], + ]; + } - foreach ( Sync\Metadata::get_keys() as $raw_key => $field_name ) { - if ( in_array( $field_name, $enabled_fields, true ) ) { - $prefixed_keys[] = Sync\Metadata::get_key( $raw_key ); + /** + * Get the metadata prefix for this integration. + * + * @return string The metadata prefix. + */ + public function get_metadata_prefix() { + $value = \get_option( self::METADATA_PREFIX_OPTION_PREFIX . $this->id, null ); + if ( null !== $value ) { + return $value; + } + // Lazy migrate from legacy global option. + $legacy_value = \get_option( Sync\Metadata::PREFIX_OPTION, null ); + if ( null !== $legacy_value ) { + // update option directly to avoid infinite loop. + \update_option( self::METADATA_PREFIX_OPTION_PREFIX . $this->id, $legacy_value ); + return $legacy_value; + } + return 'NP_'; + } + + /** + * Update the metadata prefix for this integration. + * + * @param string $prefix The new prefix value. + * @return bool True if updated, false otherwise. + */ + public function update_metadata_prefix( $prefix ) { + if ( empty( $prefix ) ) { + $prefix = 'NP_'; + } + return \update_option( self::METADATA_PREFIX_OPTION_PREFIX . $this->id, \sanitize_text_field( $prefix ) ); + } + + /** + * Get the settings fields declared by this integration. + * + * @return array Array of settings field declarations. + */ + public function get_settings_fields() { + return array_merge( + $this->settings_fields, + $this->get_metadata_fields() + ); + } + + /** + * Get the value of a settings field. + * + * @param string $key The field key. + * @return mixed The field value, or the default if not set. + */ + public function get_settings_field_value( $key ) { + // Route metadata fields to their dedicated getters. + if ( 'metadata_prefix' === $key ) { + return $this->get_metadata_prefix(); + } + if ( 'outgoing_metadata_fields' === $key ) { + return $this->get_enabled_outgoing_fields(); + } + if ( 'incoming_metadata_fields' === $key ) { + return $this->get_enabled_incoming_fields(); + } + + $field = $this->get_settings_field_by_key( $key ); + if ( ! $field ) { + return null; + } + $option_name = self::SETTINGS_OPTION_PREFIX . $this->id . '_' . $key; + $value = \get_option( $option_name, null ); + + if ( null !== $value ) { + return $value; + } + // Attempt to migrate old setting if the field is found in the key map. + if ( isset( self::$legacy_option_map[ $key ] ) ) { + // Lazy migrate from legacy option. + $legacy_value = \get_option( self::$legacy_option_map[ $key ], null ); + if ( null !== $legacy_value ) { + // update option directly to avoid infinite loop. + \update_option( $option_name, $legacy_value ); + return $legacy_value; } } + return $field['default'] ?? ''; + } - return array_unique( $prefixed_keys ); + /** + * Update the value of a settings field. + * + * @param string $key The field key. + * @param mixed $value The new value. + * @return bool True if updated, false otherwise. + */ + public function update_settings_field_value( $key, $value ) { + $field = $this->get_settings_field_by_key( $key ); + if ( ! $field ) { + return false; + } + $sanitized = $this->sanitize_settings_field_value( $field, $value ); + + // Route metadata fields to their dedicated setters. + if ( 'metadata_prefix' === $key ) { + return $this->update_metadata_prefix( $sanitized ); + } + if ( 'outgoing_metadata_fields' === $key ) { + return $this->update_enabled_outgoing_fields( $sanitized ); + } + if ( 'incoming_metadata_fields' === $key ) { + return $this->update_enabled_incoming_fields( $sanitized ); + } + + $option_name = self::SETTINGS_OPTION_PREFIX . $this->id . '_' . $key; + return \update_option( $option_name, $sanitized ); + } + + /** + * Get settings config with current values populated, for API responses. + * + * @return array Array of field declarations with current values. + */ + public function get_settings_config() { + $fields = $this->get_settings_fields(); + $config = []; + foreach ( $fields as $field ) { + $field['value'] = $this->get_settings_field_value( $field['key'] ); + // Inject metadata options for metadata fields. + if ( 'incoming_metadata_fields' === $field['key'] ) { + $incoming_fields = $this->get_filtered_incoming_contact_fields( true ); + $field['options'] = array_map( + function ( $incoming_field ) { + return $incoming_field->get_key(); + }, + is_wp_error( $incoming_fields ) ? [] : $incoming_fields + ); + } + if ( 'outgoing_metadata_fields' === $field['key'] ) { + $field['options'] = Sync\Metadata::get_default_fields(); + } + $config[] = $field; + } + return $config; + } + + /** + * Get a settings field declaration by key. + * + * @param string $key The field key. + * @return array|null The field declaration or null if not found. + */ + private function get_settings_field_by_key( $key ) { + foreach ( $this->get_settings_fields() as $field ) { + if ( $field['key'] === $key ) { + return $field; + } + } + return null; + } + + /** + * Sanitize a settings field value based on its type. + * + * @param array $field The field declaration. + * @param mixed $value The value to sanitize. + * @return mixed The sanitized value. + */ + private function sanitize_settings_field_value( $field, $value ) { + $type = $field['type'] ?? 'text'; + switch ( $type ) { + case 'checkbox': + return (bool) $value; + case 'number': + return is_numeric( $value ) ? $value + 0 : ( $field['default'] ?? 0 ); + case 'select': + $valid_values = array_column( $field['options'] ?? [], 'value' ); + return in_array( $value, $valid_values, true ) ? $value : ( $field['default'] ?? '' ); + case 'metadata': + if ( ! is_array( $value ) ) { + return $field['default'] ?? []; + } + return array_values( array_map( 'sanitize_text_field', $value ) ); + case 'textarea': + return \sanitize_textarea_field( $value ); + case 'text': + case 'password': + default: + return \sanitize_text_field( $value ); + } } } diff --git a/includes/reader-activation/sync/class-metadata.php b/includes/reader-activation/sync/class-metadata.php index 4f0f6c01ab..f29e68fd12 100644 --- a/includes/reader-activation/sync/class-metadata.php +++ b/includes/reader-activation/sync/class-metadata.php @@ -70,9 +70,24 @@ public static function get_keys() { * Fetch the prefix for synced metadata fields. * Default is NP_ but it can be configured in the Reader Activation settings page. * + * This method is deprecated. Now, each integration has its own metadata prefix, which can be retrieved with Integration::get_metadata_prefix(). + * As a fallback, this method returns the metadata prefix for the ESP Integration. + * + * @deprecated Use Integration::get_metadata_prefix() instead. + * * @return string */ public static function get_prefix() { + $esp_integration = Integrations::get_integration( 'esp' ); + if ( $esp_integration ) { + $prefix = $esp_integration->get_metadata_prefix(); + if ( ! empty( $prefix ) ) { + /** This filter is documented below. */ + return apply_filters( 'newspack_ras_metadata_prefix', $prefix ); + } + } + + // Fallback for edge case where integration isn't registered yet (before init priority 5). $prefix = \get_option( self::PREFIX_OPTION, self::PREFIX ); // Guard against empty strings and falsy values. @@ -96,11 +111,8 @@ public static function get_prefix() { * @return boolean True if updated, false otherwise. */ public static function update_prefix( $prefix ) { - if ( empty( $prefix ) ) { - $prefix = self::PREFIX; - } - - return \update_option( self::PREFIX_OPTION, $prefix ); + $esp_integration = Integrations::get_integration( 'esp' ); + return $esp_integration ? $esp_integration->update_metadata_prefix( $prefix ) : false; } /** @@ -174,12 +186,12 @@ public static function update_fields( $fields ) { * This method is deprecated. Now, each integration has its own set of enabled fields. * As a fallback, this method delegates to the ESP Integration. * - * @deprecated Use Integration::get_enabled_outgoing_fields_raw_keys() instead. + * @deprecated Use Integration::get_enabled_outgoing_fields_keys() instead. * @return string[] List of raw metadata keys. */ public static function get_raw_keys() { $esp_integration = Integrations::get_integration( 'esp' ); - return $esp_integration ? $esp_integration->get_enabled_outgoing_fields_raw_keys() : []; + return $esp_integration ? $esp_integration->get_enabled_outgoing_fields_keys() : []; } /** @@ -188,12 +200,12 @@ public static function get_raw_keys() { * This method is deprecated. Now, each integration has its own set of enabled fields. * As a fallback, this method delegates to the ESP Integration. * - * @deprecated Use Integration::get_enabled_outgoing_fields_prefixed_keys() instead. + * @deprecated Use Integration::get_enabled_outgoing_fields_keys() instead. * @return string[] List of prefixed metadata keys. */ public static function get_prefixed_keys() { $esp_integration = Integrations::get_integration( 'esp' ); - return $esp_integration ? $esp_integration->get_enabled_outgoing_fields_prefixed_keys() : []; + return $esp_integration ? $esp_integration->get_enabled_outgoing_fields_keys( true ) : []; } /** diff --git a/includes/wizards/audience/class-audience-integrations.php b/includes/wizards/audience/class-audience-integrations.php new file mode 100644 index 0000000000..84565da2d8 --- /dev/null +++ b/includes/wizards/audience/class-audience-integrations.php @@ -0,0 +1,205 @@ +parent_slug, + $this->get_name(), + esc_html__( 'Integrations', 'newspack-plugin' ), + $this->capability, + $this->slug, + [ $this, 'render_wizard' ] + ); + } + + /** + * Enqueue scripts and styles. + */ + public function enqueue_scripts_and_styles() { + if ( ! $this->is_wizard_page() ) { + return; + } + + parent::enqueue_scripts_and_styles(); + + wp_enqueue_script( 'newspack-wizards' ); + + $localized_data = [ + 'integrations_settings_enabled' => self::is_enabled(), + ]; + + if ( class_exists( 'Newspack_Newsletters' ) ) { + $localized_data['esp_provider'] = \Newspack_Newsletters::service_provider(); + } + + \wp_localize_script( + 'newspack-wizards', + 'newspackAudienceIntegrations', + $localized_data + ); + } + + /** + * Register the endpoints needed for the wizard screens. + */ + public function register_api_endpoints() { + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'api_get_integration_settings' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + ] + ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)', + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'api_update_integration_settings' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + ] + ); + + register_rest_route( + NEWSPACK_API_NAMESPACE, + '/wizard/' . $this->slug . '/settings/(?P[a-zA-Z0-9_-]+)/enabled', + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'api_update_integration_enabled' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + ] + ); + } + + /** + * Get all integration settings. + * + * @return WP_REST_Response + */ + public function api_get_integration_settings() { + return rest_ensure_response( Integrations::get_all_integration_settings() ); + } + + /** + * Update settings for a specific integration. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_update_integration_settings( WP_REST_Request $request ) { + $integration_id = $request->get_param( 'integration_id' ); + $settings = $request->get_param( 'settings' ); + + if ( ! is_array( $settings ) ) { + return new WP_Error( + 'newspack_invalid_param', + esc_html__( 'Settings must be an object of key-value pairs.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + $result = Integrations::update_integration_settings( $integration_id, $settings ); + if ( null === $result ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + return rest_ensure_response( Integrations::get_all_integration_settings() ); + } + + /** + * Update the enabled state of a specific integration. + * + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error + */ + public function api_update_integration_enabled( WP_REST_Request $request ) { + $integration_id = $request->get_param( 'integration_id' ); + $enabled = $request->get_param( 'enabled' ); + + $integration = Integrations::get_integration( $integration_id ); + if ( ! $integration ) { + return new WP_Error( + 'newspack_integration_not_found', + esc_html__( 'Integration not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + if ( $enabled ) { + Integrations::enable( $integration_id ); + } else { + Integrations::disable( $integration_id ); + } + + return rest_ensure_response( Integrations::get_all_integration_settings() ); + } +} diff --git a/src/wizards/audience/views/integrations/index.js b/src/wizards/audience/views/integrations/index.js new file mode 100644 index 0000000000..c7d5a45d2a --- /dev/null +++ b/src/wizards/audience/views/integrations/index.js @@ -0,0 +1,114 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { forwardRef, useState, useEffect, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { Wizard, withWizard } from '../../../../../packages/components/src'; +import { SettingsSection } from './settings-section'; + +const API_PATH = '/newspack/v1/wizard/newspack-audience-integrations/settings'; + +const AudienceIntegrations = ( props, ref ) => { + const [ integrations, setIntegrations ] = useState( {} ); + const [ pendingChanges, setPendingChanges ] = useState( {} ); + const [ saving, setSaving ] = useState( {} ); + const [ toggling, setToggling ] = useState( {} ); + const [ loading, setLoading ] = useState( true ); + + const fetchSettings = useCallback( () => { + setLoading( true ); + apiFetch( { path: API_PATH } ) + .then( data => { + setIntegrations( data ); + setPendingChanges( {} ); + } ) + .finally( () => setLoading( false ) ); + }, [] ); + + useEffect( () => { + fetchSettings(); + }, [ fetchSettings ] ); + + const handleFieldChange = useCallback( ( integrationId, fieldKey, value ) => { + setPendingChanges( prev => ( { + ...prev, + [ integrationId ]: { + ...( prev[ integrationId ] || {} ), + [ fieldKey ]: value, + }, + } ) ); + }, [] ); + + const handleSave = useCallback( integrationId => { + setPendingChanges( currentPendingChanges => { + const changes = currentPendingChanges[ integrationId ]; + if ( ! changes || Object.keys( changes ).length === 0 ) { + return currentPendingChanges; + } + setSaving( prev => ( { ...prev, [ integrationId ]: true } ) ); + apiFetch( { + path: `${ API_PATH }/${ integrationId }`, + method: 'POST', + data: { settings: changes }, + } ) + .then( data => { + setIntegrations( data ); + setPendingChanges( prev => { + const next = { ...prev }; + delete next[ integrationId ]; + return next; + } ); + } ) + .finally( () => { + setSaving( prev => ( { ...prev, [ integrationId ]: false } ) ); + } ); + return currentPendingChanges; + } ); + }, [] ); + + const handleToggleEnabled = useCallback( ( integrationId, enabled ) => { + setToggling( prev => ( { ...prev, [ integrationId ]: true } ) ); + apiFetch( { + path: `${ API_PATH }/${ integrationId }/enabled`, + method: 'POST', + data: { enabled }, + } ) + .then( data => { + setIntegrations( data ); + } ) + .finally( () => { + setToggling( prev => ( { ...prev, [ integrationId ]: false } ) ); + } ); + }, [] ); + + return ( + + ); +}; + +export default withWizard( forwardRef( AudienceIntegrations ) ); diff --git a/src/wizards/audience/views/integrations/settings-field.js b/src/wizards/audience/views/integrations/settings-field.js new file mode 100644 index 0000000000..ba2514d063 --- /dev/null +++ b/src/wizards/audience/views/integrations/settings-field.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; +import { CheckboxControl, ExternalLink } from '@wordpress/components'; + +/** + * Internal dependencies. + */ +import { Grid, SelectControl, TextControl } from '../../../../../packages/components/src'; + +/** + * Render a single settings field. + * + * @param {Object} props Component props. + * @param {Object} props.field Field declaration. + * @param {*} props.value Current value. + * @param {Function} props.onChange Change handler. + */ +export const SettingsField = ( { field, value, onChange } ) => { + const { key, type, label, description, placeholder, options, help_url: helpUrl } = field; + const help = ( + <> + { description } + { helpUrl && ( + <> + { ' ' } + { __( 'Learn more', 'newspack-plugin' ) } + + ) } + + ); + + switch ( type ) { + case 'metadata': { + const selectedFields = Array.isArray( value ) ? value : []; + return ( +
+

{ label }

+ + { options.map( fieldName => ( + { + const newFields = checked ? [ ...selectedFields, fieldName ] : selectedFields.filter( f => f !== fieldName ); + onChange( newFields ); + } } + /> + ) ) } + +
+ ); + } + case 'checkbox': + return ; + case 'select': + return ( + ( { + label: opt.label, + value: opt.value, + } ) ) } + onChange={ onChange } + /> + ); + case 'textarea': + return ( + + ); + case 'number': + return ( + + ); + case 'password': + return ( + + ); + case 'text': + default: + return ; + } +}; diff --git a/src/wizards/audience/views/integrations/settings-section.js b/src/wizards/audience/views/integrations/settings-section.js new file mode 100644 index 0000000000..4f4af7d0ea --- /dev/null +++ b/src/wizards/audience/views/integrations/settings-section.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import { __ } from '@wordpress/i18n'; +import { ActionCard, Button, Card, Grid } from '../../../../../packages/components/src'; +import WizardsTab from '../../../wizards-tab'; +import WizardSection from '../../../wizards-section'; + +import { SettingsField } from './settings-field'; + +export const SettingsSection = ( { integrations, pendingChanges, saving, toggling, loading, onFieldChange, onSave, onToggleEnabled } ) => { + const getFieldValue = ( integrationId, field ) => { + if ( pendingChanges[ integrationId ] && field.key in pendingChanges[ integrationId ] ) { + return pendingChanges[ integrationId ][ field.key ]; + } + return field.value; + }; + + const integrationIds = Object.keys( integrations ); + + return ( + + + { loading &&

{ __( 'Loading…', 'newspack-plugin' ) }

} + { ! loading && integrationIds.length === 0 && ( + +

{ __( 'No integrations with configurable settings are registered.', 'newspack-plugin' ) }

+
+ ) } + { ! loading && + integrationIds.map( id => { + const integration = integrations[ id ]; + const hasPending = pendingChanges[ id ] && Object.keys( pendingChanges[ id ] ).length > 0; + const isEnabled = integration.enabled; + return ( + onToggleEnabled( id, ! isEnabled ) } + disabled={ toggling[ id ] } + hasGreyHeader={ isEnabled } + actionContent={ + isEnabled ? ( + + ) : null + } + > + { isEnabled && ( + <> + + { integration.settings.map( field => ( + onFieldChange( id, field.key, val ) } + /> + ) ) } + + + ) } + + ); + } ) } +
+
+ ); +}; diff --git a/src/wizards/index.tsx b/src/wizards/index.tsx index d23c7964e3..4511dac1e7 100644 --- a/src/wizards/index.tsx +++ b/src/wizards/index.tsx @@ -58,6 +58,14 @@ const components: Record< string, any > = { }, } as const; +// Conditionally add the Audience Integrations page if the feature is enabled. +if ( window.newspackAudienceIntegrations?.integrations_settings_enabled ) { + components[ 'newspack-audience-integrations' ] = { + label: __( 'Audience Integrations', 'newspack-plugin' ), + component: lazy( () => import( /* webpackChunkName: "audience-wizards" */ './audience/views/integrations' ) ), + }; +} + const AdminPageLoader = ( { label }: { label: string } ) => { return (
diff --git a/src/wizards/types/window.d.ts b/src/wizards/types/window.d.ts index 680adb981c..db80805c35 100644 --- a/src/wizards/types/window.d.ts +++ b/src/wizards/types/window.d.ts @@ -57,6 +57,9 @@ declare global { }>; upgrade_subscription_url: string; }; + newspackAudienceIntegrations: { + integrations_settings_enabled: boolean; + }; newspackAudienceContentGates: { api: string; available_access_rules: AccessRules; diff --git a/tests/mocks/newsletters-mocks.php b/tests/mocks/newsletters-mocks.php index 6c935dd3f8..de28e8b1d4 100644 --- a/tests/mocks/newsletters-mocks.php +++ b/tests/mocks/newsletters-mocks.php @@ -11,5 +11,47 @@ class Newspack_Newsletters { public static function service_provider() { return get_option( 'newspack_newsletters_service_provider', false ); } + + public static function get_service_provider() { + return new Newspack_Newsletters_Service_Provider(); + } + + public static function is_service_provider_configured() { + return true; + } + } +} + +if ( ! class_exists( 'Newspack_Newsletters_Settings' ) ) { + class Newspack_Newsletters_Settings {} +} + +if ( ! class_exists( 'Newspack_Newsletters_Subscription' ) ) { + class Newspack_Newsletters_Subscription { + public static function get_lists() { + return [ + [ + 'active' => true, + 'name' => 'test', + 'id' => '123', + ], + ]; + } + } +} + +if ( ! class_exists( 'Newspack_Newsletters_Service_Provider' ) ) { + class Newspack_Newsletters_Service_Provider { + public $service = 'mailchimp'; + + public static function get_lists() { + return [ + [ + 'active' => true, + 'name' => 'test', + 'id' => '123', + ], + ]; + } } } diff --git a/tests/unit-tests/integrations/class-failing-sample-integration.php b/tests/unit-tests/integrations/class-failing-sample-integration.php index 0622098cf0..b2f7846356 100644 --- a/tests/unit-tests/integrations/class-failing-sample-integration.php +++ b/tests/unit-tests/integrations/class-failing-sample-integration.php @@ -25,6 +25,14 @@ class Failing_Sample_Integration extends Integration { */ public static $push_count = 0; + /** + * Register settings fields (test implementation). + */ + public function register_settings_fields() { + // No settings fields for this test implementation. + return []; + } + /** * Push contact data (test implementation). * @@ -66,7 +74,7 @@ public function can_sync( $return_errors = false ) { * * @return array */ - public function get_incoming_available_contact_fields() { + public function get_available_incoming_contact_fields() { return []; } diff --git a/tests/unit-tests/integrations/class-sample-integration.php b/tests/unit-tests/integrations/class-sample-integration.php index 8c732569d9..69e5014318 100644 --- a/tests/unit-tests/integrations/class-sample-integration.php +++ b/tests/unit-tests/integrations/class-sample-integration.php @@ -18,6 +18,14 @@ class Sample_Integration extends Integration { */ public static $handler_args = null; + /** + * Register settings fields (test implementation). + */ + public function register_settings_fields() { + // No settings fields for this test implementation. + return []; + } + /** * Push contact data (test implementation). * @@ -91,7 +99,7 @@ public static function reset() { * * @return Integrations\Incoming_Contact_Field[]|\WP_Error Array of incoming contact field objects or WP_Error on failure. */ - public function get_incoming_available_contact_fields() { + public function get_available_incoming_contact_fields() { return []; } } diff --git a/tests/unit-tests/integrations/class-test-integrations.php b/tests/unit-tests/integrations/class-test-integrations.php index d639514053..cdb380b1eb 100644 --- a/tests/unit-tests/integrations/class-test-integrations.php +++ b/tests/unit-tests/integrations/class-test-integrations.php @@ -281,36 +281,36 @@ public function test_dispatch_throws_when_integration_missing() { } /** - * Test get_incoming_contact_fields returns empty array when no fields available. + * Test get_available_incoming_contact_fields returns empty array when no fields available. */ - public function test_get_incoming_contact_fields_empty() { + public function test_get_available_incoming_contact_fields_empty() { $integration = new Sample_Integration( 'test-id', 'Test Integration' ); Integrations::register( $integration ); - $fields = $integration->get_incoming_contact_fields(); + $fields = $integration->get_available_incoming_contact_fields(); $this->assertIsArray( $fields ); $this->assertEmpty( $fields ); } /** - * Test get_incoming_contact_fields propagates WP_Error from get_incoming_available_contact_fields. + * Test get_available_incoming_contact_fields propagates WP_Error from get_available_incoming_contact_fields. */ - public function test_get_incoming_contact_fields_propagates_error() { + public function test_get_available_incoming_contact_fields_propagates_error() { $integration = new class( 'error-test', 'Error Test' ) extends Sample_Integration { /** * Get incoming available contact fields (returns error for test). * * @return \WP_Error */ - public function get_incoming_available_contact_fields() { + public function get_available_incoming_contact_fields() { return new \WP_Error( 'test_error', 'Test error message' ); } }; Integrations::register( $integration ); - $result = $integration->get_incoming_contact_fields(); + $result = $integration->get_available_incoming_contact_fields(); $this->assertWPError( $result ); $this->assertEquals( 'test_error', $result->get_error_code() ); @@ -318,36 +318,36 @@ public function get_incoming_available_contact_fields() { } /** - * Test get_selected_fields returns empty array by default. + * Test get_incoming_fields returns empty array by default. */ - public function test_get_selected_fields_default_empty() { + public function test_get_incoming_fields_default_empty() { $integration = new Sample_Integration( 'test-id', 'Test Integration' ); - $this->assertSame( [], $integration->get_selected_fields() ); + $this->assertSame( [], $integration->get_enabled_incoming_fields() ); } /** - * Test set_selected_fields and get_selected_fields round-trip. + * Test update_incoming_fields and get_incoming_fields round-trip. */ - public function test_set_and_get_selected_fields() { + public function test_set_and_get_enabled_incoming_fields() { $integration = new Sample_Integration( 'test-id', 'Test Integration' ); $fields = [ 'first_name', 'last_name', 'phone' ]; - $integration->set_selected_fields( $fields ); + $integration->update_enabled_incoming_fields( $fields ); - $this->assertSame( $fields, $integration->get_selected_fields() ); + $this->assertSame( $fields, $integration->get_enabled_incoming_fields() ); } /** - * Test set_selected_fields stores any keys without validation. + * Test update_incoming_fields stores any keys without validation. */ - public function test_set_selected_fields_stores_any_keys() { + public function test_update_incoming_fields_stores_any_keys() { $integration = new Sample_Integration( 'test-id', 'Test Integration' ); $fields = [ 'nonexistent_field', 'another_unknown' ]; - $integration->set_selected_fields( $fields ); + $integration->update_enabled_incoming_fields( $fields ); - $this->assertSame( $fields, $integration->get_selected_fields() ); + $this->assertSame( $fields, $integration->get_enabled_incoming_fields() ); } /** @@ -403,7 +403,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'favorite_color' ] ); + $integration->update_enabled_incoming_fields( [ 'favorite_color' ] ); Integrations::register( $integration ); Integrations::enable( 'pull-test' ); @@ -422,7 +422,7 @@ public function pull_contact_data( $user_id ) { /** * Test sync pull filters returned data by selected fields only. */ - public function test_sync_pull_filters_by_selected_fields() { + public function test_sync_pull_filters_by_incoming_fields() { $user_id = $this->factory()->user->create(); wp_set_current_user( $user_id ); @@ -445,7 +445,7 @@ public function pull_contact_data( $user_id ) { }; // Only select fields a and c. - $integration->set_selected_fields( [ 'field_a', 'field_c' ] ); + $integration->update_enabled_incoming_fields( [ 'field_a', 'field_c' ] ); Integrations::register( $integration ); Integrations::enable( 'filter-test' ); @@ -481,7 +481,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'some_field' ] ); + $integration->update_enabled_incoming_fields( [ 'some_field' ] ); Integrations::register( $integration ); Integrations::enable( 'throw-test' ); @@ -516,7 +516,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'city' ] ); + $integration->update_enabled_incoming_fields( [ 'city' ] ); Integrations::register( $integration ); Integrations::enable( 'async-test' ); @@ -554,7 +554,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'language' ] ); + $integration->update_enabled_incoming_fields( [ 'language' ] ); Integrations::register( $integration ); Integrations::enable( 'handle-test' ); @@ -587,7 +587,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'pet' ] ); + $integration->update_enabled_incoming_fields( [ 'pet' ] ); Integrations::register( $integration ); // Not enabled. @@ -623,7 +623,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'first_field' ] ); + $integration->update_enabled_incoming_fields( [ 'first_field' ] ); Integrations::register( $integration ); Integrations::enable( 'first-test' ); @@ -656,7 +656,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'timeout_field' ] ); + $integration->update_enabled_incoming_fields( [ 'timeout_field' ] ); Integrations::register( $integration ); Integrations::enable( 'timeout-test' ); @@ -817,6 +817,106 @@ public function test_connection() { $this->assertEquals( 'Fatal: something exploded', $result->get_error_message() ); } + /** + * Test get_metadata_prefix returns default 'NP_' when no custom prefix is set. + */ + public function test_get_metadata_prefix_default() { + $integration = new Sample_Integration( 'prefix-test', 'Prefix Test' ); + + $this->assertSame( 'NP_', $integration->get_metadata_prefix() ); + } + + /** + * Test update_metadata_prefix stores and retrieves a custom prefix. + */ + public function test_update_and_get_metadata_prefix() { + $integration = new Sample_Integration( 'prefix-test', 'Prefix Test' ); + + $integration->update_metadata_prefix( 'CUSTOM_' ); + + $this->assertSame( 'CUSTOM_', $integration->get_metadata_prefix() ); + $this->assertSame( 'CUSTOM_', get_option( 'newspack_integration_metadata_prefix_prefix-test' ) ); + } + + /** + * Test update_metadata_prefix with empty string falls back to 'NP_'. + */ + public function test_update_metadata_prefix_empty_falls_back() { + $integration = new Sample_Integration( 'prefix-test', 'Prefix Test' ); + + $integration->update_metadata_prefix( 'CUSTOM_' ); + $integration->update_metadata_prefix( '' ); + + $this->assertSame( 'NP_', $integration->get_metadata_prefix() ); + } + + /** + * Test metadata prefix is isolated per integration. + */ + public function test_metadata_prefix_per_integration_isolation() { + $integration_a = new Sample_Integration( 'iso-a', 'Integration A' ); + $integration_b = new Sample_Integration( 'iso-b', 'Integration B' ); + + $integration_a->update_metadata_prefix( 'AAA_' ); + $integration_b->update_metadata_prefix( 'BBB_' ); + + $this->assertSame( 'AAA_', $integration_a->get_metadata_prefix() ); + $this->assertSame( 'BBB_', $integration_b->get_metadata_prefix() ); + } + + /** + * Test settings field value routing for metadata_prefix. + */ + public function test_settings_field_value_routes_metadata_prefix() { + $integration = new Sample_Integration( 'route-test', 'Route Test' ); + $integration->init(); + + $this->assertTrue( $integration->update_settings_field_value( 'metadata_prefix', 'API_' ) ); + $this->assertSame( 'API_', $integration->get_settings_field_value( 'metadata_prefix' ) ); + + // Verify it wrote to the dedicated option, not the generic settings option. + $this->assertSame( 'API_', get_option( 'newspack_integration_metadata_prefix_route-test' ) ); + $this->assertFalse( get_option( 'newspack_integration_settings_route-test_metadata_prefix' ) ); + } + + /** + * Test get_enabled_outgoing_fields_keys uses integration prefix when prefixed flag is true. + */ + public function test_get_enabled_outgoing_fields_keys_uses_integration_prefix() { + $integration = new Sample_Integration( 'keys-test', 'Keys Test' ); + $integration->update_metadata_prefix( 'TEST_' ); + $integration->update_enabled_outgoing_fields( [ 'Account' ] ); + + $keys = $integration->get_enabled_outgoing_fields_keys( true ); + + $this->assertNotEmpty( $keys ); + foreach ( $keys as $key ) { + $this->assertStringStartsWith( 'TEST_', $key, "Key '$key' should start with 'TEST_'" ); + } + } + + /** + * Test get_settings_config includes metadata_prefix field with correct value. + */ + public function test_get_settings_config_includes_metadata_prefix() { + $integration = new Sample_Integration( 'config-test', 'Config Test' ); + $integration->init(); + $integration->update_metadata_prefix( 'CFG_' ); + + $config = $integration->get_settings_config(); + + $prefix_field = null; + foreach ( $config as $field ) { + if ( 'metadata_prefix' === $field['key'] ) { + $prefix_field = $field; + break; + } + } + + $this->assertNotNull( $prefix_field, 'Settings config should contain a metadata_prefix field.' ); + $this->assertSame( 'CFG_', $prefix_field['value'] ); + } + /** * Test handle_ajax_pull processes data when called directly. */ @@ -836,7 +936,7 @@ public function pull_contact_data( $user_id ) { } }; - $integration->set_selected_fields( [ 'ajax_field' ] ); + $integration->update_enabled_incoming_fields( [ 'ajax_field' ] ); Integrations::register( $integration ); Integrations::enable( 'ajax-test' ); diff --git a/tests/unit-tests/reader-activation-sync.php b/tests/unit-tests/reader-activation-sync.php index aa473c39c6..fa4228830e 100644 --- a/tests/unit-tests/reader-activation-sync.php +++ b/tests/unit-tests/reader-activation-sync.php @@ -64,8 +64,8 @@ public function test_can_esp_sync() { * Test specific ESP integration checks. */ public function test_esp_integration_checks() { - $esp_integration = new Integrations\ESP(); + $esp_integration->init(); $errors = $esp_integration->can_sync( true ); $this->assertInstanceOf( 'WP_Error', $errors ); $this->assertTrue( $errors->has_errors() ); @@ -74,12 +74,13 @@ public function test_esp_integration_checks() { $this->assertContains( 'ras_esp_master_list_id_not_found', $error_codes, 'Missing master list ID' ); // Disable ESP sync. - Reader_Activation::update_setting( 'sync_esp', false ); + Integrations::disable( 'esp' ); + $esp_integration->update_settings_field_value( 'sync_esp', false ); $errors = $esp_integration->can_sync( true ); $this->assertContains( 'ras_esp_sync_not_enabled', $errors->get_error_codes(), 'RAS ESP Sync is disabled' ); // Reenable ESP sync. - Reader_Activation::update_setting( 'sync_esp', true ); + Integrations::enable( 'esp' ); // Allow ESP sync via constant. We're not testing `Newspack_Manager::is_connected_to_production_manager()` here. define( 'NEWSPACK_ALLOW_READER_SYNC', true ); @@ -87,8 +88,7 @@ public function test_esp_integration_checks() { $this->assertNotContains( 'esp_sync_not_allowed', $errors->get_error_codes(), 'RAS ESP Sync is allowed via constant' ); // Set master list ID. - update_option( 'newspack_newsletters_service_provider', 'mailchimp' ); - Reader_Activation::update_setting( 'mailchimp_audience_id', '123' ); + $esp_integration->update_settings_field_value( 'mailchimp_audience_id', '123' ); $errors = $esp_integration->can_sync( true ); $this->assertNotContains( 'ras_esp_master_list_id_not_found', $errors->get_error_codes(), 'Master list ID is set' ); @@ -148,7 +148,7 @@ public function test_sync_contact_data() { ); // Clear from last test. - \delete_option( Sync\Metadata::PREFIX_OPTION ); + Sync\Metadata::update_prefix( '' ); $contact_data_with_prefixed_keys['metadata']['NP_Invalid_Key'] = 'Invalid data'; $this->assertEquals(