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..86ca993727 --- /dev/null +++ b/includes/experimental-tools/class-experimental-tools.php @@ -0,0 +1,467 @@ + \WP_REST_Server::READABLE, + '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' => \WP_REST_Server::CREATABLE, + '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' => \WP_REST_Server::CREATABLE, + 'callback' => [ __CLASS__, 'api_save_settings' ], + 'permission_callback' => [ __CLASS__, 'check_permission' ], + 'args' => [ + 'slug' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_title', + ], + 'fields' => [ + 'required' => true, + 'type' => 'object', + 'validate_callback' => function ( $value ) { + return is_array( $value ); + }, + ], + ], + ] + ); + } + + /** + * 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'] ) ) { + $slug = sanitize_title( $tool['slug'] ); + $tool['slug'] = $slug; + $tools[ $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'] ]; + } elseif ( ! isset( $field['value'] ) && isset( $field['default'] ) ) { + $field['value'] = $field['default']; + } + } + unset( $field ); + + $tools[] = [ + 'slug' => $slug, + 'label' => $tool['label'] ?? $slug, + 'description' => $tool['description'] ?? '', + 'disclosure' => $tool['disclosure'] ?? '', + 'constant' => $tool['constant'] ?? null, + 'constant_active' => $constant_active, + 'enabled' => $enabled, + 'enabled_at' => $saved['enabled_at'] ?? null, + 'enabled_by' => $saved['enabled_by'] ?? null, + 'fields' => $fields, + 'usage_count' => self::get_usage_count( $slug ), + ]; + } + + 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 ) { + if ( ! is_array( $fields ) ) { + return; + } + $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 ); + + /** + * 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'] ); + } + + /** + * 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 ] = [ + 'daily' => [], + ]; + } + + $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 ] ); + } + } + + update_option( self::OPTION_NAME, $all_settings ); + } + + /** + * 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_usage_count( $slug, $days = 30 ) { + $settings = self::get_tool_settings( $slug ); + $total = 0; + $cutoff = gmdate( 'Y-m-d', time() - $days * DAY_IN_SECONDS ); + + if ( ! empty( $settings['users'] ) ) { + foreach ( $settings['users'] as $user_data ) { + foreach ( $user_data['daily'] ?? [] as $date => $count ) { + if ( $date >= $cutoff ) { + $total += (int) $count; + } + } + } + } + return $total; + } + + /** + * Get usage count for a specific user within a number of recent days. + * + * @param string $slug Tool slug. + * @param int $user_id User ID. + * @param int $days Number of days to look back. Default 30. + * @return int + */ + public static function get_user_usage_count( $slug, $user_id, $days = 30 ) { + $settings = self::get_tool_settings( $slug ); + $user_key = (string) $user_id; + $total = 0; + $cutoff = gmdate( 'Y-m-d', time() - $days * DAY_IN_SECONDS ); + + if ( ! empty( $settings['users'][ $user_key ]['daily'] ) ) { + foreach ( $settings['users'][ $user_key ]['daily'] as $date => $count ) { + if ( $date >= $cutoff ) { + $total += (int) $count; + } + } + } + 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..3d577c963c 100644 --- a/includes/wizards/newspack/class-newspack-settings.php +++ b/includes/wizards/newspack/class-newspack-settings.php @@ -147,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..ac0e02cef3 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/configure-view.tsx @@ -0,0 +1,163 @@ +/** + * 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 ) => ( + {} } /> + ) ) } +
+ + + +

+ { + /* 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/enable-modal.tsx b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx new file mode 100644 index 0000000000..481db5f181 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/enable-modal.tsx @@ -0,0 +1,48 @@ +/** + * 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 ( + + { tool.disclosure ? ( +

{ tool.disclosure }

+ ) : ( +

{ __( 'This tool is in active development. Your experience using it directly shapes what it becomes.', '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..1d356ac892 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/style.scss @@ -0,0 +1,42 @@ +.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__usage-note { + color: rgb( 117, 117, 117 ); + font-size: 13px; + margin-top: 16px; +} + +.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..040899ab90 --- /dev/null +++ b/src/wizards/newspack/views/settings/experimental-tools/types.ts @@ -0,0 +1,27 @@ +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; + disclosure?: string; + constant: string | null; + constant_active: boolean; + enabled: boolean; + enabled_at: number | null; + enabled_by: number | null; + fields: ToolField[]; + usage_count: number; +} 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..493f275dd1 --- /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_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'] ); + } +}