/** * WordPress dependencies */ import { select as doSelect } from '@safe-wordpress/data'; import { _x, sprintf } from '@safe-wordpress/i18n'; /** * External dependencies */ import { countBy, toPairs, trim } from 'lodash'; import { getConversionActionScopeError, getLetter, isEmpty } from '@nab/utils'; import type { Alternative, ConversionActionType, ConversionActionTypeName, CustomPhpScopeRule, Experiment, ExperimentType, Goal, Maybe, ScopeRule, Segment, SegmentationRuleType, SegmentationRuleTypeName, TestedUrlWithQueryArgsScopeRule, } from '@nab/types'; type ExperimentSummary = Pick< Experiment, 'name' | 'goals' | 'segments' | 'scope' > & { readonly alternatives: ReadonlyArray< Alternative >; readonly control: Alternative; readonly type: ExperimentType; }; type ConversionActionTypes = Record< ConversionActionTypeName, ConversionActionType >; type SegmentationRuleTypes = Record< SegmentationRuleTypeName, SegmentationRuleType >; export function shouldExperimentBeDraft( experiment: ExperimentSummary, conversionActionTypes: ConversionActionTypes, segmentationRuleTypes: SegmentationRuleTypes ): string | false { const { control, alternatives, goals, scope, segments, name, type } = experiment; if ( ! name ) { return _x( 'Test is unnamed', 'text', 'nelio-ab-testing' ); } if ( alternatives.length < 2 ) { return _x( 'Test doesn’t have any variants', 'text', 'nelio-ab-testing' ); } const errorWithTestedElement = type.checks.getControlError( control.attributes, doSelect ); if ( errorWithTestedElement ) { return errorWithTestedElement; } const [ _, ...alts ] = alternatives; const errorWithAlternative: string | false = alts.reduce( ( error: false | string, alt, index ) => ! error ? type.checks.getAlternativeError( alt.attributes, getLetter( index + 1 ), control.attributes, doSelect ) : error, false ); if ( errorWithAlternative ) { return errorWithAlternative; } const aiErrorWithAlternative: string | false = alts.reduce( ( error: false | string, alt, index ) => ! error && !! alt.ai && ! alt.ai.isReady ? sprintf( /* translators: %1$s: Nelio AI. %2$s: Variant letter. */ _x( '%1$s Variant %2$s has pending changes that need to be applied', 'text', 'nelio-ab-testing' ), 'Nelio AI', getLetter( index + 1 ) ) : error, false ); if ( aiErrorWithAlternative ) { return aiErrorWithAlternative; } const errorWithScope = isScopeInvalid( scope ); if ( errorWithScope ) { return errorWithScope; } const errorWithGoals = areGoalsInvalid( goals, conversionActionTypes ); if ( errorWithGoals ) { return errorWithGoals; } const errorWithSegments = areSegmentsInvalid( segments, segmentationRuleTypes ); if ( errorWithSegments ) { return errorWithSegments; } return false; } // ======= // HELPERS // ======= function areGoalsInvalid( goals: ReadonlyArray< Goal >, conversionActionTypes: ConversionActionTypes ): string | false { if ( ! goals.length ) { return _x( 'Test doesn’t have any goals', 'text', 'nelio-ab-testing' ); } for ( let i = 0; i < goals.length; ++i ) { const goal = goals[ i ]; if ( ! goal ) { continue; } const errorWithGoal = isGoalInvalid( goal, i, conversionActionTypes ); if ( errorWithGoal ) { return errorWithGoal; } } return false; } function isGoalInvalid( goal: Goal, goalIndex: number, conversionActionTypes: ConversionActionTypes ): string | false { if ( ! goal.conversionActions.length ) { return sprintf( /* translators: %s: Goal name. */ _x( '%s doesn’t have any conversion actions', 'text', 'nelio-ab-testing' ), getGoalName( goal, goalIndex ) ); } for ( let i = 0; i < goal.conversionActions.length; ++i ) { const conversionAction = goal.conversionActions[ i ]; if ( ! conversionAction ) { continue; } const conversionActionType = conversionActionTypes[ conversionAction.type ]; if ( ! conversionActionType ) { return sprintf( /* translators: %s: Goal name. */ _x( '%s has one or more invalid conversion actions', 'text', 'nelio-ab-testing' ), getGoalName( goal, goalIndex ) ); } if ( ! isEmpty( conversionActionType.validate?.( conversionAction.attributes ) ) || ! isEmpty( getConversionActionScopeError( conversionAction.scope ) ) ) { return sprintf( /* translators: %s: Goal name, as in “Goal 2” or “Default Goal”. */ _x( 'One or more conversion actions in %s are invalid', 'text', 'nelio-ab-testing' ), getGoalName( goal, goalIndex ) ); } } return false; } function getGoalName( goal: Goal, index: number ): string { let goalName = sprintf( /* translators: %d: Goal id. */ _x( 'Goal %d', 'text', 'nelio-ab-testing' ), index + 1 ); if ( 0 === index ) { goalName = _x( 'Default Goal', 'text', 'nelio-ab-testing' ); } if ( trim( goal.attributes.name ) ) { goalName = sprintf( /* translators: %s: Goal name. */ _x( 'Goal “%s”', 'text', 'nelio-ab-testing' ), trim( goal.attributes.name ) ); } return goalName; } function areSegmentsInvalid( segments: ReadonlyArray< Segment >, segmentationRuleTypes: SegmentationRuleTypes ): string | false { for ( let i = 0; i < segments.length; ++i ) { const segment = segments[ i ]; if ( ! segment ) { continue; } const errorWithSegment = isSegmentInvalid( segment, i, segmentationRuleTypes ); if ( errorWithSegment ) { return errorWithSegment; } } return false; } function isSegmentInvalid( segment: Segment, segmentIndex: number, segmentationRuleTypes: SegmentationRuleTypes ) { if ( ! segment.segmentationRules.length ) { return sprintf( /* translators: %s: Segment name. */ _x( '%s doesn’t have any segmentation rules', 'text', 'nelio-ab-testing' ), getSegmentName( segment, segmentIndex ) ); } for ( let i = 0; i < segment.segmentationRules.length; ++i ) { const segmentationRule = segment.segmentationRules[ i ]; if ( ! segmentationRule ) { continue; } const segmentationRuleType = segmentationRuleTypes[ segmentationRule.type ]; if ( ! segmentationRuleType ) { return sprintf( /* translators: %s: Segment name. */ _x( '%s has one or more invalid segmentation rules', 'text', 'nelio-ab-testing' ), getSegmentName( segment, segmentIndex ) ); } if ( ! isEmpty( segmentationRuleType.validate?.( segmentationRule.attributes ) ) ) { return sprintf( /* translators: %s: Segment name, as in Segment 2”. */ _x( 'One or more segmentation rules in %s are invalid', 'text', 'nelio-ab-testing' ), getSegmentName( segment, segmentIndex ) ); } } return false; } function getSegmentName( segment: Segment, index: number ): string { let segmentName = sprintf( /* translators: %d: Segment id. */ _x( 'Segment %d', 'text', 'nelio-ab-testing' ), index + 1 ); if ( trim( segment.attributes.name ) ) { segmentName = sprintf( /* translators: %s: Segment name. */ _x( 'Segment “%s”', 'text', 'nelio-ab-testing' ), trim( segment.attributes.name ) ); } return segmentName; } function isScopeInvalid( scope: ReadonlyArray< ScopeRule > ): string | false { // First, we check if a PHP scope is invalid. const [ firstRule ] = scope; if ( isCustomPhpScopeRule( firstRule ) ) { return firstRule.attributes.value.errorMessage ? _x( 'Test scope is incorrect', 'text', 'nelio-ab-testing' ) : false; } // If there's no PHP scope, then we only need to check this type, because all other types are supposed to be valid by default. const rules = scope.filter( ( r ): r is TestedUrlWithQueryArgsScopeRule => 'tested-url-with-query-args' === r.attributes.type ); for ( const rule of rules ) { const names = toPairs( countBy( rule.attributes.value.args, 'name' ) ); const name = names.find( ( [ n, c ] ) => !! n && c >= 2 )?.[ 0 ]; if ( name ) { return sprintf( /* translators: %s: Query parameter’s name. */ _x( 'Query parameter “%s” should only appear once in the test scope', 'text', 'nelio-ab-testing' ), name ); } } return false; } const isCustomPhpScopeRule = ( r: Maybe< ScopeRule > ): r is CustomPhpScopeRule => 'php-snippet' === r?.attributes.type;