/**
* WordPress dependencies
*/
import { Button } from '@safe-wordpress/components';
import {
useDispatch,
useSelect,
type SelectFunction,
} from '@safe-wordpress/data';
import { createInterpolateElement } from '@safe-wordpress/element';
import { _n, _x, sprintf } from '@safe-wordpress/i18n';
/**
* External dependencies
*/
import { isFunction } from 'lodash';
import { HelpIcon } from '@nab/components';
import { store as NAB_DATA, usePageAttribute } from '@nab/data';
import { formatI18nDate } from '@nab/date';
import { store as NAB_EXPERIMENTS } from '@nab/experiments';
import { computeDuration, getLetter, hasHead } from '@nab/utils';
import type {
Alternative,
AlternativeId,
AlternativeTrackingData,
ECommercePlugin,
Experiment,
ExperimentId,
GoalId,
Maybe,
Opportunity,
PostId,
Url,
} from '@nab/types';
/**
* Internal dependencies
*/
import './style.scss';
import { useExperimentAttribute } from '../hooks';
import { getMoneyLabel } from '../utils';
import { TypicalSampleSize } from './typical-sample-size';
import type { ControlAttributes as PostControlAttributes } from '../../../../../../../packages/experiment-library/post/types';
import type { ControlAttributes as UrlControlAttributes } from '../../../../../../../packages/experiment-library/url/types';
type ExperimentStatus = 'running' | 'finished';
type Results =
| { readonly type: 'loading' }
| { readonly type: 'collecting-results' }
| ( { readonly type: 'winner' } & DataDetails & Winner )
| ( { readonly type: 'possible-winner' } & DataDetails & Winner )
| ( { readonly type: 'no-winner' } & DataDetails );
type Winner = {
readonly winner: {
readonly id: AlternativeId;
readonly name: string;
readonly isLastApplied: boolean;
};
};
type DataDetails = {
readonly alternativeCount: number;
readonly minConfidence: number;
readonly confidence: number;
readonly uplift: number;
readonly estimatedImpact?: {
readonly value: string;
readonly uplift?: number;
};
};
type SummaryContent = {
readonly eyebrow: string;
readonly title: string;
readonly subtitle: string | JSX.Element;
readonly description: string | JSX.Element;
readonly icon: JSX.Element;
readonly primaryAction?: {
readonly label: string;
readonly isBusy: boolean;
readonly isDisabled: boolean;
readonly onClick: () => void;
};
readonly secondaryAction?: {
readonly label: string;
readonly isBusy: boolean;
readonly isDisabled: boolean;
readonly onClick: () => void;
};
};
export const Summary = (): JSX.Element => {
const {
id: experimentId,
status: experimentStatus,
startDate,
endDate,
hasValueEstimate,
} = useExperiment();
const results = useResults();
const winner = getWinner( results );
const [ _, doApplyWinner ] = usePageAttribute(
'results/alternativeToApply',
false
);
const applyWinner = useCanApplyAlternativeWinner( experimentId, winner )
? () => doApplyWinner( winner )
: undefined;
const { stopExperiment: doStopTest } = useDispatch( NAB_DATA );
const stopTest = useCanStopTest() ? doStopTest : undefined;
const [ isStoppingTest ] = usePageAttribute(
'editor/isExperimentBeingStopped',
false
);
const opportunity = useOpportunity( experimentId );
const { generateExperimentSuggestions } = useDispatch( NAB_DATA );
const doCreateFollowUpTest = () =>
opportunity && generateExperimentSuggestions( opportunity );
const createFollowUpTest =
useCanCreateFollowUpTest() && !! opportunity
? doCreateFollowUpTest
: undefined;
const [ isCreatingFollowUpTest ] = usePageAttribute(
'results/isCreatingFollowUpTest',
false
);
const areActionsDisabled = isStoppingTest || isCreatingFollowUpTest;
const args = {
areActionsDisabled,
isCreatingFollowUpTest,
isStoppingTest,
isWinnerLastApplied: getIsWinnerLastApplied( results ),
results,
status: experimentStatus,
applyWinner,
createFollowUpTest,
stopTest,
};
const content =
'running' === experimentStatus
? getRunningSummaryContent( args )
: getFinishedSummaryContent( args );
return (
{ ! content ? (
) : (
<>
{ content.icon }
{ content.eyebrow }
{ content.title }
{ content.subtitle }
{ content.description }
{ hasEstimatedImpact( results ) && (
) }
{ ( content.primaryAction || content.secondaryAction ) && (
{ content.secondaryAction && (
) }
{ content.primaryAction && (
) }
) }
>
) }
);
};
type GetSummaryContentArgs = {
readonly areActionsDisabled: boolean;
readonly isCreatingFollowUpTest: boolean;
readonly isStoppingTest: boolean;
readonly isWinnerLastApplied: boolean;
readonly results: Results;
readonly applyWinner?: () => void;
readonly createFollowUpTest?: () => void;
readonly stopTest?: () => void;
};
const getRunningSummaryContent = (
args: GetSummaryContentArgs
): Maybe< SummaryContent > => {
switch ( args.results.type ) {
case 'loading':
return undefined;
case 'winner': {
const alternativeName = args.results.winner.name;
const alternativeId = args.results.winner.id;
return {
eyebrow: _x( 'Winner found', 'text', 'nelio-ab-testing' ),
title: sprintf(
/* translators: %s: Variant name (as in “Variant X”). */
_x( '%s is the clear winner', 'text', 'nelio-ab-testing' ),
alternativeName
),
subtitle: getUpliftHeadline( args.results, 'running' ),
description: sprintf(
'%1$s %2$s',
// eslint-disable-next-line no-nested-ternary
'control' === alternativeId
? 2 === args.results.alternativeCount
? _x(
'The control is outperforming variant B.',
'text',
'nelio-ab-testing'
)
: _x(
'The control is outperforming the other variants.',
'text',
'nelio-ab-testing'
)
: sprintf(
/* translators: %s: Variant name (as in “Variant X”). */
_x(
'%s is outperforming the control.',
'text',
'nelio-ab-testing'
),
alternativeName
),
_x(
'We recommend stopping the test and applying the winning variant to all visitors.',
'text',
'nelio-ab-testing'
)
),
icon: ,
primaryAction: args.stopTest
? {
isBusy: args.isStoppingTest,
isDisabled:
args.areActionsDisabled &&
! args.isStoppingTest,
label: _x(
'Stop Test Now',
'command',
'nelio-ab-testing'
),
onClick: args.stopTest,
}
: undefined,
};
}
case 'possible-winner': {
const alternativeName = args.results.winner.name;
return {
eyebrow: _x( 'Promising result', 'text', 'nelio-ab-testing' ),
title: sprintf(
/* translators: %s: Variant name. */
_x( '%s is currently leading', 'text', 'nelio-ab-testing' ),
alternativeName
),
subtitle: getUpliftHeadline( args.results, 'running' ),
description: _x(
'This variant is currently ahead, but more data is needed before declaring a winner.',
'text',
'nelio-ab-testing'
),
icon: ,
};
}
case 'no-winner':
return {
eyebrow: _x(
'No significant difference',
'text',
'nelio-ab-testing'
),
title: _x( 'No clear winner yet', 'text', 'nelio-ab-testing' ),
subtitle: _x(
'No variant is clearly outperforming the control',
'text',
'nelio-ab-testing'
),
description: _x(
'The test hasn’t identified a clear winner yet. Keep it running or try a different hypothesis.',
'text',
'nelio-ab-testing'
),
icon: ,
};
case 'collecting-results':
return {
eyebrow: _x( 'Collecting results', 'text', 'nelio-ab-testing' ),
title: _x( 'Not enough data yet', 'text', 'nelio-ab-testing' ),
subtitle: _x(
'The test is still collecting data',
'text',
'nelio-ab-testing'
),
description: _x(
'Keep the test running until there’s enough data to evaluate the variants reliably.',
'text',
'nelio-ab-testing'
),
icon: ,
};
}
};
const getFinishedSummaryContent = (
args: {
readonly results: Results;
} & GetSummaryContentArgs
): Maybe< SummaryContent > => {
switch ( args.results.type ) {
case 'loading':
return undefined;
case 'winner': {
const alternativeName = args.results.winner.name;
const alternativeId = args.results.winner.id;
return {
eyebrow: _x( 'Winner found', 'text', 'nelio-ab-testing' ),
title: sprintf(
/* translators: %s: Variant name (as in “Variant X”). */
_x( '%s won', 'text', 'nelio-ab-testing' ),
alternativeName
),
subtitle: getUpliftHeadline( args.results, 'finished' ),
description: sprintf(
'%1$s %2$s',
'control' === alternativeId
? _x(
'This test protected your site from a change that would have reduced performance. Keeping the control was the right decision!',
'text',
'nelio-ab-testing'
)
: _x(
'This test uncovered a better-performing version of your page. Your visitors will benefit from this improvement.',
'text',
'nelio-ab-testing'
),
! args.isWinnerLastApplied && 'control' !== alternativeId
? _x(
'We recommend applying the winning version to all visitors.',
'text',
'nelio-ab-testing'
)
: ''
),
icon: ,
primaryAction: args.applyWinner
? {
isBusy: false,
isDisabled: args.areActionsDisabled,
label: sprintf(
/* translators: %s: Variant name (as in “Variant X”). */
_x( 'Apply %s', 'command', 'nelio-ab-testing' ),
alternativeName
),
onClick: args.applyWinner,
}
: undefined,
secondaryAction: args.createFollowUpTest
? {
isBusy: args.isCreatingFollowUpTest,
isDisabled:
args.areActionsDisabled &&
! args.isCreatingFollowUpTest,
label: _x(
'Create Follow-Up Test',
'command',
'nelio-ab-testing'
),
onClick: args.createFollowUpTest,
}
: undefined,
};
}
case 'possible-winner': {
const alternativeName = args.results.winner.name;
return {
eyebrow: _x( 'Promising result', 'text', 'nelio-ab-testing' ),
title: sprintf(
/* translators: %s: Variant name. */
_x( '%s finished ahead', 'text', 'nelio-ab-testing' ),
alternativeName
),
subtitle: getUpliftHeadline( args.results, 'finished' ),
description: _x(
'The result was promising, but there wasn’t enough evidence to declare a clear winner.',
'text',
'nelio-ab-testing'
),
icon: ,
primaryAction: args.createFollowUpTest
? {
isBusy: args.isCreatingFollowUpTest,
isDisabled:
args.areActionsDisabled &&
! args.isCreatingFollowUpTest,
label: _x(
'Create Follow-Up Test',
'command',
'nelio-ab-testing'
),
onClick: args.createFollowUpTest,
}
: undefined,
};
}
case 'no-winner':
return {
eyebrow: _x(
'No significant difference',
'text',
'nelio-ab-testing'
),
title: _x( 'No clear winner', 'text', 'nelio-ab-testing' ),
subtitle: _x(
'None of the variants clearly outperformed the control',
'text',
'nelio-ab-testing'
),
description: _x(
'The test did not identify a clear improvement. Consider trying a different hypothesis.',
'text',
'nelio-ab-testing'
),
icon: ,
secondaryAction: args.createFollowUpTest
? {
isBusy: args.isCreatingFollowUpTest,
isDisabled:
args.areActionsDisabled &&
! args.isCreatingFollowUpTest,
label: _x(
'Create Follow-Up Test',
'command',
'nelio-ab-testing'
),
onClick: args.createFollowUpTest,
}
: undefined,
};
case 'collecting-results':
// NOTE. This branch will never run, because this state only occurs while the test is running.
// Therefore, there’s no need to do anything (other than returning something so that TS doesn’t complain).
return getRunningSummaryContent( args );
}
};
type SummaryMetricProps = {
label: string;
help: string;
value: string;
detail?: string;
};
const SummaryMetric = ( {
label,
help,
value,
detail,
}: SummaryMetricProps ) => (
{ label }
{ value }
{ detail && (
{ createInterpolateElement( detail.replaceAll( '\n', '
' ), {
br:
,
} ) }
) }
);
type ExperimentStatusMetricProps = {
status: ExperimentStatus;
startDate: string | false;
endDate: string | false;
};
const ExperimentStatusMetric = ( {
status,
startDate,
endDate,
}: ExperimentStatusMetricProps ) => {
const start = toDate( startDate );
const end =
status === 'finished' && endDate ? toDate( endDate ) : new Date();
return (
Status
{ status === 'running'
? _x(
'Running',
'text (experiment status)',
'nelio-ab-testing'
)
: _x(
'Finished',
'text (experiment status)',
'nelio-ab-testing'
) }
{ status === 'running'
? sprintf(
/* translators: %s: Start date. */
_x( 'Started %s', 'text', 'nelio-ab-testing' ),
formatI18nDate( start )
)
: sprintf(
/* translators: %s: End date. */
_x( 'Ended %s', 'text', 'nelio-ab-testing' ),
formatI18nDate( end )
) }
{ status === 'running'
? sprintf(
/* translators: %s: Day number. */
_x( 'Day %s', 'text', 'nelio-ab-testing' ),
getDurationInDays( start, end )
)
: sprintf(
/* translators: %s: Amount of time, like “2 months and 3 days.” */
_x( 'Ran for %s', 'text', 'nelio-ab-testing' ),
getBeautifulDuration( start, end )
) }
);
};
const ResultsSummarySkeleton = ( {
hasValueEstimate,
}: {
readonly hasValueEstimate: boolean;
} ) => (
{ hasValueEstimate && (
) }
);
const formatConfidence = ( results: Results ): string => {
if ( 'loading' === results.type || 'collecting-results' === results.type ) {
return '—';
}
if ( 0 === results.confidence ) {
return '—';
}
return `${ Math.floor( results.confidence ) }%`;
};
const getConfidenceLabel = ( results: Results ): Maybe< string > => {
if ( 'loading' === results.type || 'collecting-results' === results.type ) {
return undefined;
}
if ( results.confidence >= 95 ) {
return _x( 'Very high', 'text', 'nelio-ab-testing' );
}
if ( results.confidence >= 80 ) {
return _x( 'High', 'text', 'nelio-ab-testing' );
}
if ( results.confidence >= 50 ) {
return _x( 'Low to medium', 'text', 'nelio-ab-testing' );
}
return _x( 'Low', 'text', 'nelio-ab-testing' );
};
const getUpliftHeadline = (
results: Results,
status: 'running' | 'finished'
): string | JSX.Element => {
if ( 'loading' === results.type || 'collecting-results' === results.type ) {
return '';
}
if ( 'winner' === results.type || 'possible-winner' === results.type ) {
if ( 'control' === results.winner.id ) {
return results.alternativeCount > 2
? _x(
'No variant improved on the control',
'text',
'nelio-ab-testing'
)
: _x(
'Variant B didn’t improve on the control',
'text',
'nelio-ab-testing'
);
}
}
if ( 'no-winner' === results.type || ! results.uplift ) {
return status === 'running'
? _x( 'Performance is being evaluated', 'text', 'nelio-ab-testing' )
: _x( 'No measurable improvement', 'text', 'nelio-ab-testing' );
}
return createInterpolateElement(
sprintf(
/* translators: %s: Percentage value. */
_x( '%s uplift over the control', 'text', 'nelio-ab-testing' ),
`${ results.uplift.toFixed( 1 ) }%`
),
{ strong: }
);
};
function hasEstimatedImpact( results: Results ): boolean {
if ( 'winner' !== results.type && 'possible-winner' !== results.type ) {
return false;
}
return !! results.estimatedImpact;
}
const formatEstimatedImpact = ( results: Results ): string => {
if ( 'loading' === results.type || 'collecting-results' === results.type ) {
return '—';
}
if ( results.estimatedImpact ) {
return results.estimatedImpact.value;
}
if ( results.uplift !== null && Number.isFinite( results.uplift ) ) {
return formatSignedPercentage( results.uplift );
}
return '—';
};
const getImpactDetail = ( results: Results ): Maybe< string > => {
if ( 'loading' === results.type || 'collecting-results' === results.type ) {
return undefined;
}
const parts: string[] = [];
if ( results.estimatedImpact ) {
const period = _x( 'month', 'text', 'nelio-ab-testing' );
parts.push( `/${ period }` );
if (
results.estimatedImpact.uplift &&
Number.isFinite( results.estimatedImpact.uplift )
) {
parts.push(
`(${ formatSignedPercentage(
results.estimatedImpact.uplift
) })`
);
}
}
return parts.join( ' ' ) || undefined;
};
const formatSignedPercentage = ( value: number ): string =>
`${ value > 0 ? '+' : '' }${ value.toFixed( 1 ) }%`;
const getDurationInDays = ( startDate: Date, endDate: Date ): number => {
const millisecondsPerDay = 1000 * 60 * 60 * 24;
return Math.max(
1,
Math.ceil(
( endDate.getTime() - startDate.getTime() ) / millisecondsPerDay
)
);
};
const getBeautifulDuration = ( startDate: Date, endDate: Date ): string => {
const duration = computeDuration(
startDate.toISOString(),
endDate.toISOString()
);
const snippets = [
duration.years,
duration.months,
duration.days,
duration.hours,
duration.minutes,
];
const { years, months, days, hours, minutes } = duration;
const stringifiedSnippets = [
sprintf(
/* translators: %d: Number of years. */
_n( '%d year', '%d years', years, 'nelio-ab-testing' ),
years
),
sprintf(
/* translators: %d: Number of months. */
_n( '%d month', '%d months', months, 'nelio-ab-testing' ),
months
),
/* translators: %d: Number of days. */
sprintf( _n( '%d day', '%d days', days, 'nelio-ab-testing' ), days ),
sprintf(
/* translators: %d: Number of hours. */
_n( '%d hour', '%d hours', hours, 'nelio-ab-testing' ),
hours
),
sprintf(
/* translators: %d: Number of minutes. */
_n( '%d minute', '%d minutes', minutes, 'nelio-ab-testing' ),
minutes
),
];
for ( let i = 0; i < snippets.length - 1; ++i ) {
if ( snippets[ i ] && snippets[ i + 1 ] ) {
return joinTimes(
stringifiedSnippets[ i ],
stringifiedSnippets[ i + 1 ]
);
}
if ( snippets[ i ] ) {
return stringifiedSnippets[ i ] || '';
}
}
if ( snippets[ snippets.length - 1 ] ) {
return stringifiedSnippets[ snippets.length - 1 ] || '';
}
return _x( 'less than a minute', 'text', 'nelio-ab-testing' );
};
function joinTimes( a: string | undefined, b: string | undefined ): string {
if ( a && b ) {
const and = _x( 'and', 'text', 'nelio-ab-testing' );
return `${ a } ${ and } ${ b }`;
}
return a || b || '';
}
const toDate = ( value: string | false ): Date =>
value ? new Date( value ) : new Date();
const TrophyIcon = () => (
);
const TrendIcon = () => (
);
const NoDifferenceIcon = () => (
);
const CollectingIcon = () => (
);
// =====
// HOOKS
// =====
const useResults = () =>
useSelect( ( select ): Results => {
const activeExperiment = select( NAB_DATA ).getPageAttribute(
'editor/activeExperiment'
);
const isLoading = ! select( NAB_DATA ).hasFinishedResolution(
'getExperimentResults',
[ activeExperiment ]
);
if ( ! activeExperiment || isLoading ) {
return { type: 'loading' };
}
const minSampleSize =
select( NAB_DATA ).getPluginSetting( 'minSampleSize' );
const pageViews =
select( NAB_DATA ).getPageViews( activeExperiment ) || 0;
const experimentStatus = select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'status'
);
if ( 'running' === experimentStatus && pageViews < minSampleSize ) {
return { type: 'collecting-results' };
}
const goals =
select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'goals'
) || [];
const activeGoalId =
select( NAB_DATA ).getPageAttribute( 'editor/activeGoal' ) ||
( goals[ 0 ]?.id ?? '' );
const winners =
select( NAB_DATA ).getWinnersInExperiment( activeExperiment );
const winner = winners?.[ activeGoalId ];
const minConfidence =
select( NAB_DATA ).getPluginSetting( 'minConfidence' );
const confidence = winner?.confidence || 0;
const alternatives =
select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'alternatives'
) || [];
const isUnique =
select( NAB_DATA ).areUniqueResultsVisible( activeExperiment );
const resultsOfAlternatives =
select( NAB_DATA ).getResultsOfAlternatives( activeExperiment );
const durationInDays = getDurationInDays(
toDate(
select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'startDate'
) || false
),
'finished' === experimentStatus
? toDate(
select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'endDate'
) || false
)
: new Date()
);
const ecommerce = select( NAB_DATA ).getECommercePlugin(
activeExperiment,
activeGoalId
);
const estimatedImpact = ecommerce
? getEstimatedImpact(
activeGoalId,
alternatives,
resultsOfAlternatives,
isUnique,
ecommerce,
durationInDays
)
: undefined;
const details: DataDetails = {
alternativeCount: alternatives.length,
minConfidence,
confidence,
uplift: 0,
estimatedImpact,
};
const winningAlternative = alternatives[ winner?.alternative ?? 0 ];
if ( winner === undefined || ! winningAlternative ) {
return {
type: 'no-winner',
...details,
};
}
const winnerResults = resultsOfAlternatives?.[ winningAlternative.id ];
const uplift = isUnique
? winnerResults?.uniqueImprovementFactors[ activeGoalId ] || 0
: winnerResults?.improvementFactors[ activeGoalId ] || 0;
return {
type: confidence < minConfidence ? 'possible-winner' : 'winner',
...details,
uplift,
winner: {
id: winningAlternative.id,
name: sprintf(
/* translators: %s: Variant letter. */
_x( 'Variant %s', 'text', 'nelio-ab-testing' ),
getLetter( winner.alternative )
),
isLastApplied: !! winningAlternative.isLastApplied,
},
};
}, [] );
const useExperiment = () => ( {
id: useExperimentAttribute( 'id' ),
status:
'running' === useExperimentAttribute( 'status' )
? ( 'running' as const )
: ( 'finished' as const ),
startDate: useExperimentAttribute( 'startDate' ) || ( false as const ),
endDate: useExperimentAttribute( 'endDate' ) || ( false as const ),
hasValueEstimate: useHasValueEstimate(),
} );
const useHasValueEstimate = (): boolean =>
useSelect( ( select ) => {
const activeExperiment = select( NAB_DATA ).getPageAttribute(
'editor/activeExperiment'
);
if ( ! activeExperiment ) {
return false;
}
const goals =
select( NAB_DATA ).getExperimentAttribute(
activeExperiment,
'goals'
) || [];
const activeGoalId =
select( NAB_DATA ).getPageAttribute( 'editor/activeGoal' ) ||
( goals[ 0 ]?.id ?? '' );
return !! select( NAB_DATA ).getECommercePlugin(
activeExperiment,
activeGoalId
);
}, [] );
// =======
// HELPERS
// =======
function getEstimatedImpact(
goalId: GoalId,
alternatives: ReadonlyArray< Alternative >,
resultsOfAlternatives: Maybe<
Record< AlternativeId, AlternativeTrackingData >
>,
isUnique: boolean,
ecommerce: ECommercePlugin,
durationInDays: number
): DataDetails[ 'estimatedImpact' ] {
if ( ! alternatives.length || ! resultsOfAlternatives ) {
return undefined;
}
const ids = alternatives.map( ( a ) => a.id );
const values = isUnique
? ids.map(
( id ) =>
resultsOfAlternatives[ id ]?.uniqueValues[ goalId ] || 0
)
: ids.map(
( id ) => resultsOfAlternatives[ id ]?.values[ goalId ] || 0
);
const [ controlValue = 0, ...alternativeValues ] = values;
const bestAlternativeValue = alternativeValues.reduce(
( r, x ) => ( x > r ? x : r ),
0
);
if ( ! bestAlternativeValue ) {
return undefined;
}
const impact = bestAlternativeValue - controlValue;
if ( impact <= 0 ) {
return undefined;
}
const months = durationInDays / 30;
return {
value: '+' + getMoneyLabel( Math.floor( impact / months ), ecommerce ),
uplift: controlValue
? ( 100 * bestAlternativeValue ) / controlValue
: undefined,
};
}
function getWinner( results: Results ): Maybe< AlternativeId > {
if ( 'winner' !== results.type && 'possible-winner' !== results.type ) {
return undefined;
}
return results.winner.id;
}
const useCanApplyAlternativeWinner = (
experimentId?: ExperimentId,
winner?: AlternativeId
): winner is AlternativeId =>
useSelect(
( select ): boolean => {
const canUserApplyAlternatives = select( NAB_DATA ).hasCapability(
'edit_nab_experiments'
);
if ( ! canUserApplyAlternatives ) {
return false;
}
const isPublicView = select( NAB_DATA ).getPageAttribute(
'results/isPublicView'
);
if ( isPublicView ) {
return false;
}
const experiment = select( NAB_DATA ).getExperiment( experimentId );
if ( ! experiment || ! winner || 'control' === winner ) {
return false;
}
const alternative = experiment.alternatives.find(
( a ) => a.id === winner
);
if ( ! alternative || alternative.isLastApplied ) {
return false;
}
const { getExperimentSupport } = select( NAB_EXPERIMENTS );
const alternativeApplication = getExperimentSupport(
experiment.type,
'alternativeApplication'
);
const supportsAlternativeApplication = isFunction(
alternativeApplication
)
? alternativeApplication(
experiment.alternatives[ 0 ]?.attributes
)
: !! alternativeApplication;
return (
experiment.status === 'finished' &&
!! supportsAlternativeApplication
);
},
[ experimentId, winner ]
);
const useCanCreateFollowUpTest = () =>
useSelect(
( select ): boolean =>
select( NAB_DATA ).hasCapability( 'edit_nab_experiments' ) &&
!! select( NAB_DATA ).getPluginSetting( 'aiSettings' ),
[]
);
const useCanStopTest = () =>
useSelect(
( select ) =>
select( NAB_DATA ).hasCapability( 'stop_nab_experiments' ),
[]
);
const POST_OPPORTUNITY_TYPES = [
'nab/page',
'nab/post',
'nab/custom-post-type',
];
const useOpportunity = ( experimentId: Maybe< ExperimentId > ) =>
useSelect(
( select ): Maybe< Opportunity > => {
const experiment = select( NAB_DATA ).getExperiment( experimentId );
if ( ! isOpportunityFeasible( experiment ) ) {
return undefined;
}
const target = getOpportunityTarget( select, experiment );
if ( ! target ) {
return undefined;
}
return {
type: 'recently-finished-experiment',
target,
score: 100,
meta: {
experiment: {
type: experiment.type,
id: experiment.id,
name: experiment.name,
description: experiment.description,
alternatives: experiment.alternatives,
goals: experiment.goals,
segments: experiment.segments,
testedAt: experiment.endDate,
},
},
};
},
[ experimentId ]
);
function isOpportunityFeasible(
experiment: Maybe< Experiment >
): experiment is Experiment {
if ( ! experiment ) {
return false;
}
if ( POST_OPPORTUNITY_TYPES.includes( experiment.type ) ) {
return true;
}
return !! getSingleUrlScope( experiment );
}
function getOpportunityTarget(
select: SelectFunction,
experiment: Experiment
): Maybe< Opportunity[ 'target' ] > {
if ( POST_OPPORTUNITY_TYPES.includes( experiment.type ) ) {
const attrs = experiment.alternatives[ 0 ]
.attributes as PostControlAttributes;
const post = select( NAB_DATA ).getEntityRecord(
attrs.postType,
attrs.postId
);
if ( ! post ) {
return undefined;
}
return {
type: 'post',
postId: post.id as PostId,
postType: post.type,
title: post.title,
url: post.link as Url,
};
}
const url = getSingleUrlScope( experiment );
if ( url ) {
return {
type: 'url',
title: '',
url,
};
}
return undefined;
}
function getSingleUrlScope( experiment: Experiment ): Maybe< Url > {
if ( 'nab/url' === experiment.type ) {
return (
experiment.alternatives[ 0 ].attributes as UrlControlAttributes
).url as Url;
}
const scope = experiment.scope ?? [];
if ( ! hasHead( scope ) || scope.length !== 1 ) {
return undefined;
}
const rule = scope[ 0 ].attributes;
return rule.type === 'exact' ? ( rule.value as Url ) : undefined;
}
function getIsWinnerLastApplied( results: Results ): boolean {
if ( results.type !== 'winner' && results.type !== 'possible-winner' ) {
return false;
}
return results.winner.isLastApplied;
}