From 5c75e7a9c8861885c228d5a73860a83cfac96bcb Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Fri, 20 Mar 2026 23:33:33 +0100 Subject: [PATCH 1/5] feat(settings): add Experimental Tools PHP framework --- includes/class-newspack.php | 2 + .../class-experimental-tools.php | 410 ++++++++++++++++++ .../newspack/class-newspack-settings.php | 15 + 3 files changed, 427 insertions(+) create mode 100644 includes/experimental-tools/class-experimental-tools.php diff --git a/includes/class-newspack.php b/includes/class-newspack.php index a47a58dbb2..3e2780f7da 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -246,6 +246,8 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/optional-modules/class-indesign-exporter.php'; include_once NEWSPACK_ABSPATH . 'includes/optional-modules/class-nextdoor.php'; + include_once NEWSPACK_ABSPATH . 'includes/experimental-tools/class-experimental-tools.php'; + if ( Donations::is_platform_nrh() ) { include_once NEWSPACK_ABSPATH . 'includes/class-nrh.php'; } diff --git a/includes/experimental-tools/class-experimental-tools.php b/includes/experimental-tools/class-experimental-tools.php new file mode 100644 index 0000000000..6fcdaedd99 --- /dev/null +++ b/includes/experimental-tools/class-experimental-tools.php @@ -0,0 +1,410 @@ + 'GET', + 'callback' => [ __CLASS__, 'api_get_tools' ], + 'permission_callback' => [ __CLASS__, 'check_permission' ], + ] + ); + register_rest_route( + self::REST_NAMESPACE, + self::REST_ROUTE . '/(?P[a-z0-9-]+)/toggle', + [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'api_toggle_tool' ], + 'permission_callback' => [ __CLASS__, 'check_permission' ], + 'args' => [ + 'slug' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_title', + ], + 'enabled' => [ + 'required' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], + ], + ] + ); + register_rest_route( + self::REST_NAMESPACE, + self::REST_ROUTE . '/(?P[a-z0-9-]+)/settings', + [ + 'methods' => 'POST', + 'callback' => [ __CLASS__, 'api_save_settings' ], + 'permission_callback' => [ __CLASS__, 'check_permission' ], + 'args' => [ + 'slug' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_title', + ], + 'fields' => [ + 'required' => true, + ], + ], + ] + ); + } + + /** + * Permission callback -- require manage_options. + * + * @return bool|\WP_Error + */ + public static function check_permission() { + if ( ! current_user_can( 'manage_options' ) ) { + return new \WP_Error( + 'newspack_rest_forbidden', + __( 'You cannot access this resource.', 'newspack-plugin' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + return true; + } + + /** + * GET handler -- return all registered tools with state. + * + * @return \WP_REST_Response + */ + public static function api_get_tools() { + return rest_ensure_response( self::get_tools() ); + } + + /** + * POST handler -- toggle a tool on/off. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_toggle_tool( $request ) { + $slug = $request['slug']; + $enabled = $request['enabled']; + + $tools = self::get_registered_tools(); + if ( ! isset( $tools[ $slug ] ) ) { + return new \WP_Error( + 'newspack_tool_not_found', + __( 'Tool not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + // Reject toggle if a constant override is active. + $tool = $tools[ $slug ]; + if ( ! empty( $tool['constant'] ) && defined( $tool['constant'] ) ) { + return new \WP_Error( + 'newspack_tool_constant_override', + /* translators: %s: constant name. */ + sprintf( __( 'This tool is controlled by the %s constant and cannot be toggled.', 'newspack-plugin' ), $tool['constant'] ), + [ 'status' => 403 ] + ); + } + + self::toggle_tool( $slug, $enabled ); + return rest_ensure_response( self::get_tools() ); + } + + /** + * POST handler -- save tool field values. + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_save_settings( $request ) { + $slug = $request['slug']; + $fields = $request['fields']; + + $tools = self::get_registered_tools(); + if ( ! isset( $tools[ $slug ] ) ) { + return new \WP_Error( + 'newspack_tool_not_found', + __( 'Tool not found.', 'newspack-plugin' ), + [ 'status' => 404 ] + ); + } + + self::save_tool_fields( $slug, $fields ); + return rest_ensure_response( self::get_tools() ); + } + + // ─── Data accessors ────────────────────────────────────────── + + /** + * Get registered tools from the filter (raw, without saved state). + * + * @return array Keyed by slug. + */ + private static function get_registered_tools() { + $raw = apply_filters( 'newspack_experimental_tools', [] ); + $tools = []; + foreach ( $raw as $tool ) { + if ( ! empty( $tool['slug'] ) ) { + $tools[ $tool['slug'] ] = $tool; + } + } + return $tools; + } + + /** + * Get all tools merged with saved state. + * + * @return array Flat array of tool objects for REST/JS consumption. + */ + public static function get_tools() { + $registered = self::get_registered_tools(); + $all_settings = get_option( self::OPTION_NAME, [] ); + $tools = []; + + foreach ( $registered as $slug => $tool ) { + $saved = isset( $all_settings[ $slug ] ) ? $all_settings[ $slug ] : []; + + // Determine enabled state. + $constant_active = ! empty( $tool['constant'] ) && defined( $tool['constant'] ); + if ( $constant_active ) { + $enabled = (bool) constant( $tool['constant'] ); + } else { + $enabled = ! empty( $saved['enabled'] ); + } + + // Merge saved field values into declared fields. + $fields = isset( $tool['fields'] ) ? $tool['fields'] : []; + $saved_fields = isset( $saved['fields'] ) ? $saved['fields'] : []; + foreach ( $fields as &$field ) { + if ( isset( $saved_fields[ $field['key'] ] ) ) { + $field['value'] = $saved_fields[ $field['key'] ]; + } + } + unset( $field ); + + $tools[] = [ + 'slug' => $slug, + 'label' => $tool['label'] ?? $slug, + 'description' => $tool['description'] ?? '', + 'constant' => $tool['constant'] ?? null, + 'constant_active' => $constant_active, + 'enabled' => $enabled, + 'enabled_at' => $saved['enabled_at'] ?? null, + 'enabled_by' => $saved['enabled_by'] ?? null, + 'fields' => $fields, + ]; + } + + return $tools; + } + + /** + * Get saved settings for a single tool. + * + * @param string $slug Tool slug. + * @return array + */ + public static function get_tool_settings( $slug ) { + $all_settings = get_option( self::OPTION_NAME, [] ); + return isset( $all_settings[ $slug ] ) ? $all_settings[ $slug ] : [ + 'enabled' => false, + 'enabled_at' => null, + 'enabled_by' => null, + 'users' => [], + 'fields' => [], + ]; + } + + /** + * Check if a tool is enabled (option only -- does not check constants). + * + * @param string $slug Tool slug. + * @return bool + */ + public static function is_tool_enabled( $slug ) { + $settings = self::get_tool_settings( $slug ); + return ! empty( $settings['enabled'] ); + } + + /** + * Toggle a tool on or off. + * + * @param string $slug Tool slug. + * @param bool $enabled Whether to enable. + */ + public static function toggle_tool( $slug, $enabled ) { + $all_settings = get_option( self::OPTION_NAME, [] ); + if ( ! isset( $all_settings[ $slug ] ) ) { + $all_settings[ $slug ] = [ + 'enabled' => false, + 'enabled_at' => null, + 'enabled_by' => null, + 'users' => [], + 'fields' => [], + ]; + } + + $all_settings[ $slug ]['enabled'] = (bool) $enabled; + if ( $enabled ) { + $all_settings[ $slug ]['enabled_at'] = time(); + $all_settings[ $slug ]['enabled_by'] = get_current_user_id(); + } + + update_option( self::OPTION_NAME, $all_settings ); + + /** + * Fires when an experimental tool is toggled. + * + * @param string $slug Tool slug. + * @param bool $enabled Whether the tool was enabled or disabled. + */ + do_action( 'newspack_experimental_tool_toggled', $slug, $enabled ); + } + + /** + * Save field values for a tool. + * + * @param string $slug Tool slug. + * @param array $fields Key-value pairs of field values. + */ + public static function save_tool_fields( $slug, $fields ) { + $registered = self::get_registered_tools(); + $all_settings = get_option( self::OPTION_NAME, [] ); + + if ( ! isset( $all_settings[ $slug ] ) ) { + $all_settings[ $slug ] = [ + 'enabled' => false, + 'enabled_at' => null, + 'enabled_by' => null, + 'users' => [], + 'fields' => [], + ]; + } + + // Only accept keys declared in the tool registration. + $valid_keys = []; + if ( isset( $registered[ $slug ]['fields'] ) ) { + foreach ( $registered[ $slug ]['fields'] as $field ) { + if ( ! empty( $field['key'] ) && ( $field['type'] ?? '' ) !== 'display' ) { + $valid_keys[] = $field['key']; + } + } + } + + foreach ( $fields as $key => $value ) { + if ( in_array( $key, $valid_keys, true ) ) { + $all_settings[ $slug ]['fields'][ $key ] = sanitize_textarea_field( $value ); + } + } + + update_option( self::OPTION_NAME, $all_settings ); + } + + /** + * Track usage for a tool by the current user. + * + * @param string $slug Tool slug. + * @param int $user_id User ID. + */ + public static function track_usage( $slug, $user_id ) { + $all_settings = get_option( self::OPTION_NAME, [] ); + if ( ! isset( $all_settings[ $slug ] ) ) { + $all_settings[ $slug ] = [ + 'enabled' => false, + 'enabled_at' => null, + 'enabled_by' => null, + 'users' => [], + 'fields' => [], + ]; + } + + $user_id = (string) $user_id; + if ( ! isset( $all_settings[ $slug ]['users'] ) ) { + $all_settings[ $slug ]['users'] = []; + } + if ( ! isset( $all_settings[ $slug ]['users'][ $user_id ] ) ) { + $all_settings[ $slug ]['users'][ $user_id ] = [ + 'count' => 0, + 'first_use' => null, + 'last_use' => null, + ]; + } + + $now = time(); + $all_settings[ $slug ]['users'][ $user_id ]['count']++; + if ( ! $all_settings[ $slug ]['users'][ $user_id ]['first_use'] ) { + $all_settings[ $slug ]['users'][ $user_id ]['first_use'] = $now; + } + $all_settings[ $slug ]['users'][ $user_id ]['last_use'] = $now; + + update_option( self::OPTION_NAME, $all_settings ); + } + + /** + * Get total usage count across all users for a tool. + * + * @param string $slug Tool slug. + * @return int + */ + public static function get_total_usage_count( $slug ) { + $settings = self::get_tool_settings( $slug ); + $total = 0; + if ( ! empty( $settings['users'] ) ) { + foreach ( $settings['users'] as $user_data ) { + $total += (int) ( $user_data['count'] ?? 0 ); + } + } + return $total; + } +} + +Experimental_Tools::init(); diff --git a/includes/wizards/newspack/class-newspack-settings.php b/includes/wizards/newspack/class-newspack-settings.php index ae4fae7d37..0604b04b5d 100644 --- a/includes/wizards/newspack/class-newspack-settings.php +++ b/includes/wizards/newspack/class-newspack-settings.php @@ -113,6 +113,21 @@ public function get_local_data() { 'label' => __( 'Advanced Settings', 'newspack-plugin' ), ], ]; + $experimental_tools = \Newspack\Experimental_Tools::get_tools(); + if ( ! empty( $experimental_tools ) ) { + // Insert before 'advanced-settings'. + $insert_position = array_search( 'advanced-settings', array_keys( $newspack_settings ), true ); + $newspack_settings = array_slice( $newspack_settings, 0, $insert_position, true ) + + [ + 'experimental-tools' => [ + 'label' => __( 'Experimental tools', 'newspack-plugin' ), + 'sections' => [ + 'tools' => $experimental_tools, + ], + ], + ] + + array_slice( $newspack_settings, $insert_position, null, true ); + } if ( \Newspack\Optional_Modules\Collections::is_feature_enabled() ) { $newspack_settings['collections'] = [ 'label' => __( 'Collections', 'newspack-plugin' ), From 72109e8c0b93067114ddeac47e9200534b3b7736 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 23 Mar 2026 12:44:38 +0100 Subject: [PATCH 2/5] feat(settings): add Experimental Tools UI and tests --- .../class-experimental-tools.php | 29 ++- .../newspack/class-newspack-settings.php | 26 +-- .../components/src/card-feature/style.scss | 16 +- .../experimental-tools/configure-view.tsx | 156 +++++++++++++ .../experimental-tools/enable-modal.tsx | 52 +++++ .../settings/experimental-tools/index.tsx | 141 ++++++++++++ .../settings/experimental-tools/style.scss | 36 +++ .../settings/experimental-tools/types.ts | 25 +++ .../newspack/views/settings/sections.tsx | 7 + tests/unit-tests/experimental-tools.php | 209 ++++++++++++++++++ 10 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx create mode 100644 src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx create mode 100644 src/wizards/newspack/views/settings/experimental-tools/index.tsx create mode 100644 src/wizards/newspack/views/settings/experimental-tools/style.scss create mode 100644 src/wizards/newspack/views/settings/experimental-tools/types.ts create mode 100644 tests/unit-tests/experimental-tools.php diff --git a/includes/experimental-tools/class-experimental-tools.php b/includes/experimental-tools/class-experimental-tools.php index 6fcdaedd99..428f6cab88 100644 --- a/includes/experimental-tools/class-experimental-tools.php +++ b/includes/experimental-tools/class-experimental-tools.php @@ -56,7 +56,7 @@ public static function register_routes() { self::REST_NAMESPACE, self::REST_ROUTE, [ - 'methods' => 'GET', + 'methods' => \WP_REST_Server::READABLE, 'callback' => [ __CLASS__, 'api_get_tools' ], 'permission_callback' => [ __CLASS__, 'check_permission' ], ] @@ -65,7 +65,7 @@ public static function register_routes() { self::REST_NAMESPACE, self::REST_ROUTE . '/(?P[a-z0-9-]+)/toggle', [ - 'methods' => 'POST', + 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ __CLASS__, 'api_toggle_tool' ], 'permission_callback' => [ __CLASS__, 'check_permission' ], 'args' => [ @@ -84,7 +84,7 @@ public static function register_routes() { self::REST_NAMESPACE, self::REST_ROUTE . '/(?P[a-z0-9-]+)/settings', [ - 'methods' => 'POST', + 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ __CLASS__, 'api_save_settings' ], 'permission_callback' => [ __CLASS__, 'check_permission' ], 'args' => [ @@ -93,7 +93,11 @@ public static function register_routes() { 'sanitize_callback' => 'sanitize_title', ], 'fields' => [ - 'required' => true, + 'required' => true, + 'type' => 'object', + 'validate_callback' => function ( $value ) { + return is_array( $value ); + }, ], ], ] @@ -194,7 +198,9 @@ private static function get_registered_tools() { $tools = []; foreach ( $raw as $tool ) { if ( ! empty( $tool['slug'] ) ) { - $tools[ $tool['slug'] ] = $tool; + $slug = sanitize_title( $tool['slug'] ); + $tool['slug'] = $slug; + $tools[ $slug ] = $tool; } } return $tools; @@ -227,6 +233,8 @@ public static function get_tools() { foreach ( $fields as &$field ) { if ( isset( $saved_fields[ $field['key'] ] ) ) { $field['value'] = $saved_fields[ $field['key'] ]; + } elseif ( ! isset( $field['value'] ) && isset( $field['default'] ) ) { + $field['value'] = $field['default']; } } unset( $field ); @@ -317,6 +325,9 @@ public static function toggle_tool( $slug, $enabled ) { * @param array $fields Key-value pairs of field values. */ public static function save_tool_fields( $slug, $fields ) { + if ( ! is_array( $fields ) ) { + return; + } $registered = self::get_registered_tools(); $all_settings = get_option( self::OPTION_NAME, [] ); @@ -347,6 +358,14 @@ public static function save_tool_fields( $slug, $fields ) { } update_option( self::OPTION_NAME, $all_settings ); + + /** + * Fires after a tool's field values are saved. + * + * @param string $slug Tool slug. + * @param array $fields Saved key-value pairs. + */ + do_action( 'newspack_experimental_tool_fields_saved', $slug, $all_settings[ $slug ]['fields'] ); } /** diff --git a/includes/wizards/newspack/class-newspack-settings.php b/includes/wizards/newspack/class-newspack-settings.php index 0604b04b5d..3d577c963c 100644 --- a/includes/wizards/newspack/class-newspack-settings.php +++ b/includes/wizards/newspack/class-newspack-settings.php @@ -113,21 +113,6 @@ public function get_local_data() { 'label' => __( 'Advanced Settings', 'newspack-plugin' ), ], ]; - $experimental_tools = \Newspack\Experimental_Tools::get_tools(); - if ( ! empty( $experimental_tools ) ) { - // Insert before 'advanced-settings'. - $insert_position = array_search( 'advanced-settings', array_keys( $newspack_settings ), true ); - $newspack_settings = array_slice( $newspack_settings, 0, $insert_position, true ) - + [ - 'experimental-tools' => [ - 'label' => __( 'Experimental tools', 'newspack-plugin' ), - 'sections' => [ - 'tools' => $experimental_tools, - ], - ], - ] - + array_slice( $newspack_settings, $insert_position, null, true ); - } if ( \Newspack\Optional_Modules\Collections::is_feature_enabled() ) { $newspack_settings['collections'] = [ 'label' => __( 'Collections', 'newspack-plugin' ), @@ -162,6 +147,17 @@ function( $menu ) { ], ]; } + $experimental_tools = \Newspack\Experimental_Tools::get_tools(); + if ( ! empty( $experimental_tools ) ) { + $newspack_settings['experimental-tools'] = [ + 'label' => __( 'Experimental tools', 'newspack-plugin' ), + 'activeTabPaths' => [ '/experimental-tools/*' ], + 'sections' => [ + 'tools' => $experimental_tools, + ], + ]; + } + return $newspack_settings; } diff --git a/packages/components/src/card-feature/style.scss b/packages/components/src/card-feature/style.scss index dacba6549f..55a283dfb5 100644 --- a/packages/components/src/card-feature/style.scss +++ b/packages/components/src/card-feature/style.scss @@ -6,8 +6,22 @@ @use "~@wordpress/base-styles/variables" as wp; .newspack-card-feature { + height: 100%; + + .newspack-card--core__header { + height: 100%; + } + .newspack-card--core__header-content { - grid-gap: 16px; + gap: 16px; + display: flex; + flex-direction: column; + height: 100%; + + // Push the action row (buttons + badge) to the bottom. + > :last-child { + margin-top: auto; + } } &__content { diff --git a/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx b/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx new file mode 100644 index 0000000000..f6fb62f134 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx @@ -0,0 +1,156 @@ +/** + * Inline configure view for an experimental tool. + * Replaces the tab content; the Settings nav tabs remain visible. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { TextareaControl, TextControl, SelectControl, ToggleControl } from '@wordpress/components'; +import { chevronLeft } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { Button } from '../../../../../../packages/components/src'; +import type { Tool, ToolField } from './types'; + +function FieldRenderer( { + field, + value, + onChange, + error, +}: { + field: ToolField; + value: string | number | boolean | undefined; + onChange: ( val: string | boolean ) => void; + error?: string; +} ) { + const help = error ? { error } : field.help; + switch ( field.type ) { + case 'textarea': + return ; + case 'text': + return ; + case 'select': + return ( + + ); + case 'toggle': + return ; + case 'display': + return ( +
+ { field.label } + { String( field.value ?? '' ) } +
+ ); + default: + return null; + } +} + +export default function ConfigureView( { + tool, + isFetching, + onSave, + onBack, +}: { + tool: Tool; + isFetching?: boolean; + onSave: ( fields: Record< string, string | boolean > ) => void; + onBack: () => void; +} ) { + const editableFields = tool.fields.filter( ( f: ToolField ) => f.type !== 'display' ); + const displayFields = tool.fields.filter( ( f: ToolField ) => f.type === 'display' ); + + const initialValues: Record< string, string | boolean > = {}; + editableFields.forEach( ( field: ToolField ) => { + initialValues[ field.key ] = ( field.value as string | boolean ) ?? field.default ?? ''; + } ); + const [ values, setValues ] = useState< Record< string, string | boolean > >( initialValues ); + + const [ errors, setErrors ] = useState< Record< string, string > >( {} ); + + const handleChange = ( key: string, value: string | boolean ) => { + setValues( prev => ( { ...prev, [ key ]: value } ) ); + setErrors( prev => { + const next = { ...prev }; + delete next[ key ]; + return next; + } ); + }; + + const validate = (): boolean => { + const newErrors: Record< string, string > = {}; + editableFields.forEach( ( field: ToolField ) => { + const val = values[ field.key ]; + if ( ( field.validation === 'float' || field.validation === 'integer' ) && typeof val === 'string' && val !== '' ) { + const num = Number( val ); + if ( isNaN( num ) || val.trim() === '' || ( field.validation === 'integer' && ! /^\d+$/.test( val ) ) ) { + newErrors[ field.key ] = + field.validation === 'integer' + ? __( 'Must be a whole number.', 'newspack-plugin' ) + : __( 'Must be a number.', 'newspack-plugin' ); + } else if ( field.min !== undefined && num < field.min ) { + /* translators: %s: minimum allowed value. */ + newErrors[ field.key ] = sprintf( __( 'Minimum value is %s.', 'newspack-plugin' ), String( field.min ) ); + } else if ( field.max !== undefined && num > field.max ) { + /* translators: %s: maximum allowed value. */ + newErrors[ field.key ] = sprintf( __( 'Maximum value is %s.', 'newspack-plugin' ), String( field.max ) ); + } + } + } ); + setErrors( newErrors ); + return Object.keys( newErrors ).length === 0; + }; + + const handleSave = () => { + if ( validate() ) { + onSave( values ); + } + }; + + return ( +
{ + e.preventDefault(); + handleSave(); + } } + > +
+
+

{ tool.description }

+ +
+ { editableFields.map( ( field: ToolField ) => ( + handleChange( field.key, val ) } + error={ errors[ field.key ] } + /> + ) ) } + { displayFields.map( ( field: ToolField ) => ( + {} } /> + ) ) } +
+ + +
+ ); +} diff --git a/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx new file mode 100644 index 0000000000..46a9e149df --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx @@ -0,0 +1,52 @@ +/** + * Confirmation modal for enabling an experimental tool. + */ + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Card, Button, Modal } from '../../../../../../packages/components/src'; +import type { Tool } from './types'; + +export default function EnableModal( { + tool, + disabled, + onConfirm, + onClose, +}: { + tool: Tool; + disabled?: boolean; + onConfirm: () => void; + onClose: () => void; +} ) { + return ( + + { /* Both initial tools (Roundup Block, Editorial Assistant) use OpenAI. + When non-OpenAI tools are added, make this configurable via a + `disclosure` field on the tool registration. */ } +

+ { __( + "Your content is sent to OpenAI to generate suggestions, but it is not used to train their models. This tool is in active development. We'll check in after you've had a chance to use it.", + 'newspack-plugin' + ) } +

+ + + + +
+ ); +} diff --git a/src/wizards/newspack/views/settings/experimental-tools/index.tsx b/src/wizards/newspack/views/settings/experimental-tools/index.tsx new file mode 100644 index 0000000000..30f39c4f5f --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/index.tsx @@ -0,0 +1,141 @@ +/** + * Newspack > Settings > Experimental Tools. + */ + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useState, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CardFeature, Grid, Router } from '../../../../../../packages/components/src'; +import WizardsTab from '../../../../wizards-tab'; +import { useWizardApiFetch } from '../../../../hooks/use-wizard-api-fetch'; +import EnableModal from './enable-modal'; +import ConfigureView from './configure-view'; +import type { Tool } from './types'; +import './style.scss'; + +const { useHistory, useRouteMatch, Route, Switch } = Router; + +const { 'experimental-tools': experimentalToolsData } = window.newspackSettings; +const initialTools: Tool[] = experimentalToolsData?.sections?.tools ?? []; + +export default function ExperimentalTools() { + const { wizardApiFetch, isFetching } = useWizardApiFetch( 'newspack-settings/experimental-tools' ); + const [ tools, setTools ] = useState< Tool[] >( initialTools ); + const [ enableSlug, setEnableSlug ] = useState< string | null >( null ); + const history = useHistory(); + const match = useRouteMatch(); + + const updateTools = useCallback( ( updatedTools: Tool[] ) => { + setTools( updatedTools ); + }, [] ); + + const handleToggle = useCallback( + ( slug: string, enabled: boolean ) => { + wizardApiFetch< Tool[] >( + { + path: `/newspack/v1/experimental-tools/${ slug }/toggle`, + method: 'POST', + data: { enabled }, + isCached: false, + }, + { onSuccess: updateTools } + ); + }, + [ wizardApiFetch, updateTools ] + ); + + const handleSaveFields = useCallback( + ( slug: string, fields: Record< string, string | boolean > ) => { + wizardApiFetch< Tool[] >( + { + path: `/newspack/v1/experimental-tools/${ slug }/settings`, + method: 'POST', + data: { fields }, + isCached: false, + }, + { onSuccess: updateTools } + ); + }, + [ wizardApiFetch, updateTools ] + ); + + const enableTool = tools.find( t => t.slug === enableSlug ); + const hasConfigurableFields = ( tool: Tool ) => tool.fields.length > 0; + + const ToolList = () => ( + + + { tools.map( ( tool: Tool ) => ( + setEnableSlug( tool.slug ) } + onConfigure={ hasConfigurableFields( tool ) ? () => history.push( `${ match.url }/${ tool.slug }` ) : undefined } + moreControls={ [ + { + title: __( 'Disable', 'newspack-plugin' ), + onClick: () => handleToggle( tool.slug, false ), + }, + ] } + /> + ) ) } + + + { enableTool && ( + { + handleToggle( enableTool.slug, true ); + setEnableSlug( null ); + } } + onClose={ () => setEnableSlug( null ) } + /> + ) } + + ); + + const ToolConfigure = ( { slug }: { slug: string } ) => { + const tool = tools.find( t => t.slug === slug ); + if ( ! tool ) { + history.push( match.url ); + return null; + } + return ( + handleSaveFields( tool.slug, fields ) } + onBack={ () => history.push( match.url ) } + /> + ); + }; + + return ( + + ( + + ) } + /> + + + ); +} diff --git a/src/wizards/newspack/views/settings/experimental-tools/style.scss b/src/wizards/newspack/views/settings/experimental-tools/style.scss new file mode 100644 index 0000000000..baacf6d321 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/style.scss @@ -0,0 +1,36 @@ +.experimental-tools__configure { + padding-left: 40px; +} + +.experimental-tools__configure-header { + display: flex; + align-items: center; + gap: 4px; + margin-left: -40px; + + button, + button:hover, + button:focus { + color: #1e1e1e !important; + } + + h1 { + margin: 0; + } +} + +.experimental-tools__configure-fields { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; +} + +.experimental-tools__display-field { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var( --wp-admin-theme-color-darker-10, #ddd ); + font-size: 14px; +} diff --git a/src/wizards/newspack/views/settings/experimental-tools/types.ts b/src/wizards/newspack/views/settings/experimental-tools/types.ts new file mode 100644 index 0000000000..2959a1a5ce --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/types.ts @@ -0,0 +1,25 @@ +export interface ToolField { + type: 'textarea' | 'text' | 'select' | 'toggle' | 'display'; + key: string; + label: string; + help?: string; + default?: string; + placeholder?: string; + value?: string | number | boolean; + options?: Array< { label: string; value: string } >; + validation?: 'float' | 'integer'; + min?: number; + max?: number; +} + +export interface Tool { + slug: string; + label: string; + description: string; + constant: string | null; + constant_active: boolean; + enabled: boolean; + enabled_at: number | null; + enabled_by: number | null; + fields: ToolField[]; +} diff --git a/src/wizards/newspack/views/settings/sections.tsx b/src/wizards/newspack/views/settings/sections.tsx index 2205d5ab3d..85491b89ad 100644 --- a/src/wizards/newspack/views/settings/sections.tsx +++ b/src/wizards/newspack/views/settings/sections.tsx @@ -40,6 +40,13 @@ if ( 'additional-brands' in settingsTabs ) { sectionComponents[ 'additional-brands' ] = lazy( () => import( /* webpackChunkName: "newspack-wizards" */ './additional-brands' ) ); } +/** + * Load experimental tools section if tools are registered. + */ +if ( 'experimental-tools' in settingsTabs ) { + sectionComponents[ 'experimental-tools' ] = lazy( () => import( /* webpackChunkName: "newspack-wizards" */ './experimental-tools' ) ); +} + const settingsSectionKeys = Object.keys( settingsTabs ) as SectionKeys[]; export default settingsSectionKeys.reduce( ( acc: any[], sectionPath ) => { diff --git a/tests/unit-tests/experimental-tools.php b/tests/unit-tests/experimental-tools.php new file mode 100644 index 0000000000..63aa346c1f --- /dev/null +++ b/tests/unit-tests/experimental-tools.php @@ -0,0 +1,209 @@ + $tool_slug, + 'label' => 'Test Tool', + 'description' => 'A tool for testing.', + 'fields' => [ + [ + 'type' => 'text', + 'key' => 'api_key', + 'label' => 'API Key', + 'default' => 'default-key', + ], + [ + 'type' => 'display', + 'key' => 'status', + 'label' => 'Status', + 'value' => 'OK', + ], + ], + ], + $overrides + ); + add_filter( + 'newspack_experimental_tools', + function ( $tools ) use ( $tool_def ) { + $tools[] = $tool_def; + return $tools; + } + ); + return $tool_slug; + } + + /** + * Tools registered via filter appear in get_tools(). + */ + public function test_filter_registration() { + $slug = $this->register_test_tool(); + $tools = Experimental_Tools::get_tools(); + + $this->assertCount( 1, $tools ); + $this->assertEquals( $slug, $tools[0]['slug'] ); + $this->assertEquals( 'Test Tool', $tools[0]['label'] ); + } + + /** + * Tools start disabled and can be toggled on. + */ + public function test_toggle_on() { + $slug = $this->register_test_tool(); + + $this->assertFalse( Experimental_Tools::is_tool_enabled( $slug ) ); + + Experimental_Tools::toggle_tool( $slug, true ); + + $this->assertTrue( Experimental_Tools::is_tool_enabled( $slug ) ); + } + + /** + * Toggling off a previously enabled tool works. + */ + public function test_toggle_off() { + $slug = $this->register_test_tool(); + Experimental_Tools::toggle_tool( $slug, true ); + Experimental_Tools::toggle_tool( $slug, false ); + + $this->assertFalse( Experimental_Tools::is_tool_enabled( $slug ) ); + } + + /** + * Toggle records the timestamp and user who enabled. + */ + public function test_toggle_records_metadata() { + $slug = $this->register_test_tool(); + $user = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $user ); + + Experimental_Tools::toggle_tool( $slug, true ); + $settings = Experimental_Tools::get_tool_settings( $slug ); + + $this->assertEquals( $user, $settings['enabled_by'] ); + $this->assertIsInt( $settings['enabled_at'] ); + $this->assertGreaterThan( 0, $settings['enabled_at'] ); + } + + /** + * Saving fields stores only declared keys and ignores display/unknown keys. + */ + public function test_save_fields_filters_keys() { + $slug = $this->register_test_tool(); + Experimental_Tools::toggle_tool( $slug, true ); + + Experimental_Tools::save_tool_fields( + $slug, + [ + 'api_key' => 'my-secret', + 'status' => 'should-be-ignored', // Display field. + 'unknown' => 'also-ignored', // Not declared. + ] + ); + + $settings = Experimental_Tools::get_tool_settings( $slug ); + + $this->assertEquals( 'my-secret', $settings['fields']['api_key'] ); + $this->assertArrayNotHasKey( 'status', $settings['fields'] ); + $this->assertArrayNotHasKey( 'unknown', $settings['fields'] ); + } + + /** + * Saved field values are merged into the tool's fields in get_tools(). + */ + public function test_saved_values_appear_in_get_tools() { + $slug = $this->register_test_tool(); + Experimental_Tools::toggle_tool( $slug, true ); + Experimental_Tools::save_tool_fields( $slug, [ 'api_key' => 'saved-value' ] ); + + $tools = Experimental_Tools::get_tools(); + $text_field = $tools[0]['fields'][0]; + + $this->assertEquals( 'api_key', $text_field['key'] ); + $this->assertEquals( 'saved-value', $text_field['value'] ); + } + + /** + * Usage tracking increments per-user counters. + */ + public function test_track_usage() { + $slug = $this->register_test_tool(); + $user_id = self::factory()->user->create(); + + Experimental_Tools::track_usage( $slug, $user_id ); + Experimental_Tools::track_usage( $slug, $user_id ); + Experimental_Tools::track_usage( $slug, $user_id ); + + $this->assertEquals( 3, Experimental_Tools::get_total_usage_count( $slug ) ); + } + + /** + * Returns empty when no tools are registered. + */ + public function test_empty_when_no_tools_registered() { + $tools = Experimental_Tools::get_tools(); + $this->assertEmpty( $tools ); + } + + /** + * Toggling a non-registered slug still creates an entry (no validation + * against registered tools at the storage layer). The REST endpoint + * handles validation separately. + */ + public function test_toggle_unregistered_slug_creates_entry() { + Experimental_Tools::toggle_tool( 'unregistered', true ); + $this->assertTrue( Experimental_Tools::is_tool_enabled( 'unregistered' ) ); + } + + /** + * The newspack_experimental_tool_fields_saved action fires with correct data. + */ + public function test_fields_saved_action_fires() { + $slug = $this->register_test_tool(); + $captured_slug = null; + $captured_fields = null; + + add_action( + 'newspack_experimental_tool_fields_saved', + function ( $action_slug, $fields ) use ( &$captured_slug, &$captured_fields ) { + $captured_slug = $action_slug; + $captured_fields = $fields; + }, + 10, + 2 + ); + + Experimental_Tools::save_tool_fields( $slug, [ 'api_key' => 'hook-test' ] ); + + $this->assertEquals( $slug, $captured_slug ); + $this->assertEquals( 'hook-test', $captured_fields['api_key'] ); + } +} From 3d7327eb7b7507e2768e791556ed75b5c1f4f2e8 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Mon, 23 Mar 2026 23:30:21 +0100 Subject: [PATCH 3/5] feat(settings): add daily usage tracking and usage note in configure view --- .../class-experimental-tools.php | 37 +++++++++++++------ .../experimental-tools/configure-view.tsx | 7 ++++ .../settings/experimental-tools/style.scss | 6 +++ .../settings/experimental-tools/types.ts | 1 + tests/unit-tests/experimental-tools.php | 2 +- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/includes/experimental-tools/class-experimental-tools.php b/includes/experimental-tools/class-experimental-tools.php index 428f6cab88..e61c251396 100644 --- a/includes/experimental-tools/class-experimental-tools.php +++ b/includes/experimental-tools/class-experimental-tools.php @@ -249,6 +249,7 @@ public static function get_tools() { 'enabled_at' => $saved['enabled_at'] ?? null, 'enabled_by' => $saved['enabled_by'] ?? null, 'fields' => $fields, + 'usage_count' => self::get_usage_count( $slug ), ]; } @@ -392,34 +393,46 @@ public static function track_usage( $slug, $user_id ) { } if ( ! isset( $all_settings[ $slug ]['users'][ $user_id ] ) ) { $all_settings[ $slug ]['users'][ $user_id ] = [ - 'count' => 0, - 'first_use' => null, - 'last_use' => null, + 'daily' => [], ]; } - $now = time(); - $all_settings[ $slug ]['users'][ $user_id ]['count']++; - if ( ! $all_settings[ $slug ]['users'][ $user_id ]['first_use'] ) { - $all_settings[ $slug ]['users'][ $user_id ]['first_use'] = $now; + $today = gmdate( 'Y-m-d' ); + if ( ! isset( $all_settings[ $slug ]['users'][ $user_id ]['daily'][ $today ] ) ) { + $all_settings[ $slug ]['users'][ $user_id ]['daily'][ $today ] = 0; + } + $all_settings[ $slug ]['users'][ $user_id ]['daily'][ $today ]++; + + // Prune buckets older than 90 days to keep the option compact. + $cutoff = gmdate( 'Y-m-d', time() - 90 * DAY_IN_SECONDS ); + foreach ( $all_settings[ $slug ]['users'][ $user_id ]['daily'] as $date => $count ) { + if ( $date < $cutoff ) { + unset( $all_settings[ $slug ]['users'][ $user_id ]['daily'][ $date ] ); + } } - $all_settings[ $slug ]['users'][ $user_id ]['last_use'] = $now; update_option( self::OPTION_NAME, $all_settings ); } /** - * Get total usage count across all users for a tool. + * Get usage count for a tool within a number of recent days. * * @param string $slug Tool slug. + * @param int $days Number of days to look back. Default 30. * @return int */ - public static function get_total_usage_count( $slug ) { + public static function get_usage_count( $slug, $days = 30 ) { $settings = self::get_tool_settings( $slug ); - $total = 0; + $total = 0; + $cutoff = gmdate( 'Y-m-d', time() - $days * DAY_IN_SECONDS ); + if ( ! empty( $settings['users'] ) ) { foreach ( $settings['users'] as $user_data ) { - $total += (int) ( $user_data['count'] ?? 0 ); + foreach ( $user_data['daily'] ?? [] as $date => $count ) { + if ( $date >= $cutoff ) { + $total += (int) $count; + } + } } } return $total; diff --git a/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx b/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx index f6fb62f134..ac0e02cef3 100644 --- a/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx +++ b/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx @@ -151,6 +151,13 @@ export default function ConfigureView( { + +

+ { + /* translators: 1: tool name, 2: usage count. */ + sprintf( __( '%1$s was used %2$s times in the last 30 days.', 'newspack-plugin' ), tool.label, String( tool.usage_count ) ) + } +

); } diff --git a/src/wizards/newspack/views/settings/experimental-tools/style.scss b/src/wizards/newspack/views/settings/experimental-tools/style.scss index baacf6d321..1d356ac892 100644 --- a/src/wizards/newspack/views/settings/experimental-tools/style.scss +++ b/src/wizards/newspack/views/settings/experimental-tools/style.scss @@ -26,6 +26,12 @@ margin-bottom: 24px; } +.experimental-tools__usage-note { + color: rgb( 117, 117, 117 ); + font-size: 13px; + margin-top: 16px; +} + .experimental-tools__display-field { display: flex; justify-content: space-between; diff --git a/src/wizards/newspack/views/settings/experimental-tools/types.ts b/src/wizards/newspack/views/settings/experimental-tools/types.ts index 2959a1a5ce..dd108a9004 100644 --- a/src/wizards/newspack/views/settings/experimental-tools/types.ts +++ b/src/wizards/newspack/views/settings/experimental-tools/types.ts @@ -22,4 +22,5 @@ export interface Tool { enabled_at: number | null; enabled_by: number | null; fields: ToolField[]; + usage_count: number; } diff --git a/tests/unit-tests/experimental-tools.php b/tests/unit-tests/experimental-tools.php index 63aa346c1f..493f275dd1 100644 --- a/tests/unit-tests/experimental-tools.php +++ b/tests/unit-tests/experimental-tools.php @@ -162,7 +162,7 @@ public function test_track_usage() { Experimental_Tools::track_usage( $slug, $user_id ); Experimental_Tools::track_usage( $slug, $user_id ); - $this->assertEquals( 3, Experimental_Tools::get_total_usage_count( $slug ) ); + $this->assertEquals( 3, Experimental_Tools::get_usage_count( $slug ) ); } /** From 1cb5990e96a5effec6bba2a9e34714b347f8f564 Mon Sep 17 00:00:00 2001 From: Adam Cassis Date: Wed, 25 Mar 2026 23:26:09 +0100 Subject: [PATCH 4/5] feat(settings): add per-tool disclosure field to enable modal --- .../class-experimental-tools.php | 1 + .../settings/experimental-tools/enable-modal.tsx | 14 +++++--------- .../views/settings/experimental-tools/types.ts | 1 + 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/includes/experimental-tools/class-experimental-tools.php b/includes/experimental-tools/class-experimental-tools.php index e61c251396..98df955f7c 100644 --- a/includes/experimental-tools/class-experimental-tools.php +++ b/includes/experimental-tools/class-experimental-tools.php @@ -243,6 +243,7 @@ public static function get_tools() { 'slug' => $slug, 'label' => $tool['label'] ?? $slug, 'description' => $tool['description'] ?? '', + 'disclosure' => $tool['disclosure'] ?? '', 'constant' => $tool['constant'] ?? null, 'constant_active' => $constant_active, 'enabled' => $enabled, diff --git a/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx index 46a9e149df..481db5f181 100644 --- a/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx +++ b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx @@ -30,15 +30,11 @@ export default function EnableModal( { title={ sprintf( __( 'Enable %s?', 'newspack-plugin' ), tool.label ) } onRequestClose={ onClose } > - { /* Both initial tools (Roundup Block, Editorial Assistant) use OpenAI. - When non-OpenAI tools are added, make this configurable via a - `disclosure` field on the tool registration. */ } -

- { __( - "Your content is sent to OpenAI to generate suggestions, but it is not used to train their models. This tool is in active development. We'll check in after you've had a chance to use it.", - 'newspack-plugin' - ) } -

+ { tool.disclosure ? ( +

{ tool.disclosure }

+ ) : ( +

{ __( 'This tool is in active development. Your experience using it directly shapes what it becomes.', 'newspack-plugin' ) }

+ ) }