/** * WordPress dependencies */ import { Modal, Button, CheckboxControl, SelectControl, } from '@safe-wordpress/components'; import { useSelect } from '@safe-wordpress/data'; import { useEffect } from '@safe-wordpress/element'; import { _x, sprintf } from '@safe-wordpress/i18n'; /** * External dependencies */ import { drop, map, range, take, trim, values, without } from 'lodash'; import { store as NAB_DATA, usePageAttribute } from '@nab/data'; import { downloadAsCsv, getLetter, isDefined } from '@nab/utils'; import type { Alternative, Day, DayResults, Dict, Experiment, ExperimentId, ExportSettings, Goal, Maybe, Results, Segment, } from '@nab/types'; /** * Internal dependencies */ import './style.scss'; export const ExportModal = (): JSX.Element | null => { const exp = useActiveExperiment(); const results = useExperimentResults( exp?.id ); const [ isExporting ] = usePageAttribute( 'results/isExporting', false ); const [ settings, setSettings ] = useSettings( exp, results ); const options = useOptions( exp, settings ); const handleDownload = useDownloadHandle( settings, exp, results ); const [ isOpen, setOpen ] = usePageAttribute( 'results/isExportModalOpen', false ); const close = () => setOpen( false ); if ( ! isOpen ) { return null; } if ( ! exp ) { return null; } const canExport = !! settings.segments.length && !! settings.goals.length && !! settings.alternatives.length; const [ firstLevel, secondLevel, thirdLevel ] = settings.order; const firstLevelHierarchy = options.organizationHierarchy; const isFirstLevelValid = firstLevelHierarchy.some( ( o ) => o.value === firstLevel ); const secondLevelHierarchy = firstLevelHierarchy.filter( ( h ) => h.value !== firstLevel ); const isSecondLevelValid = secondLevelHierarchy.some( ( o ) => o.value === secondLevel ); const thirdLevelHierarchy = secondLevelHierarchy.filter( ( h ) => h.value !== secondLevel ); return (

{ options && _x( 'Customize your data export using the following options:', 'user', 'nelio-ab-testing' ) }

{ _x( 'Alternatives', 'text', 'nelio-ab-testing' ) }
{ options.alternatives.map( ( a, i ) => ( setSettings( { alternatives: toggle( map( options.alternatives, 'id' ), c, settings.alternatives, a.id ), } ) } label={ getAlternativeLabel( exp, a ) } /> ) ) }
{ 1 < options.goals.length && (
{ _x( 'Goals', 'text', 'nelio-ab-testing' ) }
{ exp.goals.map( ( goal, index ) => ( { const goals = toggle( map( options.goals, 'id' ), checked, settings.goals, goal.id ); setSettings( { goals, valueGoals: options.goals .filter( isValueGoal ) .filter( ( g ) => goals.includes( g.id ) ) .map( ( g ) => g.id ), } ); } } label={ getGoalLabel( exp, goal ) } /> ) ) }
) } { 1 < options.segments.length && (
{ _x( 'Segments', 'text', 'nelio-ab-testing' ) }
{ [ { id: 'default' as const }, ...exp.segments ].map( ( s, i ) => ( setSettings( { segments: toggle( map( options.segments, 'id' ), c, settings.segments, s.id ), } ) } label={ getSegmentLabel( exp, s ) } /> ) ) }
) } { 2 <= firstLevelHierarchy.length && (
{ _x( 'Organization', 'text', 'nelio-ab-testing' ) }
{ isFirstLevelValid && ( setSettings( { order: insertAt( 0, settings.order, value ), } ) } value={ firstLevel } label={ _x( 'Group results by:', 'command', 'nelio-ab-testing' ) } /> ) } { isSecondLevelValid && ( setSettings( { order: insertAt( 1, settings.order, value ), } ) } value={ secondLevel } label={ isFirstLevelValid ? _x( 'Then by:', 'text (then [group results] by)', 'nelio-ab-testing' ) : _x( 'Group results by:', 'text', 'nelio-ab-testing' ) } /> ) } { 1 === thirdLevelHierarchy.length && ( setSettings( { order: insertAt( 2, settings.order, value ), } ) } value={ thirdLevel } label={ _x( 'Then by:', 'text (then [group results] by)', 'nelio-ab-testing' ) } /> ) }
) } { options.hasUniqueResults && ( setSettings( { uniqueResults: c } ) } label={ _x( 'Unique Results', 'text', 'nelio-ab-testing' ) } /> ) }
); }; // ===== // HOOKS // ===== const useExperimentResults = ( id: Maybe< ExperimentId > ) => useSelect( ( select ) => select( NAB_DATA ).getExperimentResults( id ), [ id ] ); const useActiveExperiment = () => useSelect( ( select ) => { const activeExperimentId = select( NAB_DATA ).getPageAttribute( 'editor/activeExperiment' ); return select( NAB_DATA ).getExperiment( activeExperimentId ); }, [] ); function useOptions( exp: Maybe< Experiment >, settings: ExportSettings ) { const hasUniqueResults = useSelect( ( select ) => select( NAB_DATA ).areUniqueResultsAvailable( exp?.id ), [ exp?.id ] ); const segments = !! exp && exp.segments.length > 1 ? [ { id: 'default' as const }, ...exp.segments ] : [ exp?.segments[ 0 ] ?? { id: 'default' as const } ]; const goals = exp?.goals ?? []; const alternatives = exp?.alternatives ?? []; type Option = { readonly value: ExportSettings[ 'order' ][ number ]; readonly label: string; }; const organizationHierarchy = [ 1 < settings.segments.length && { value: 'segments', label: _x( 'Segment', 'text (groups by)', 'nelio-ab-testing' ), }, 1 < settings.goals.length && { value: 'goals', label: _x( 'Goal', 'text (group by)', 'nelio-ab-testing' ), }, 1 < settings.alternatives.length && { value: 'alternatives', label: _x( 'Variant', 'text (group by)', 'nelio-ab-testing' ), }, ].filter( ( x ): x is Option => !! x ); return { hasUniqueResults, segments, goals, alternatives, organizationHierarchy, }; } const EMPTY_EXPORT_SETTINGS: ExportSettings = { alternatives: [], goals: [], valueGoals: [], order: [ 'segments', 'goals', 'alternatives' ], segments: [ 'default' ], uniqueResults: false, }; function useSettings( exp: Maybe< Experiment >, results: Maybe< Results > ): [ ExportSettings, ( settings: Partial< ExportSettings > ) => void ] { const hasUniqueResults = useSelect( ( select ) => select( NAB_DATA ).areUniqueResultsAvailable( exp?.id ), [ exp?.id ] ); const [ uniqueResults ] = usePageAttribute( 'results/shouldShowUniqueResults', false ); const [ settings, setSettings ] = usePageAttribute( 'results/exportSettings', EMPTY_EXPORT_SETTINGS ); useEffect( () => { if ( exp && results && settings === EMPTY_EXPORT_SETTINGS ) { setSettings( { alternatives: exp.alternatives.map( ( a ) => a.id ), goals: exp.goals.map( ( g ) => g.id ), valueGoals: exp.goals .filter( isValueGoal ) .map( ( g ) => g.id ), segments: [ exp.segments.length !== 1 ? ( 'default' as const ) : undefined, ...exp.segments.map( ( s ) => s.id ), ].filter( isDefined ), order: [ 'segments', 'goals', 'alternatives' ], uniqueResults: uniqueResults && hasUniqueResults, } ); } }, [ exp, results, hasUniqueResults, settings, uniqueResults, setSettings, ] ); return [ settings, exp && results ? ( s ) => setSettings( { ...settings, ...s } ) : ( _ ) => void null, ]; } function useDownloadHandle( settings: ExportSettings, exp: Maybe< Experiment >, results: Maybe< Results > ): () => void { const [ _, markAsExporting ] = usePageAttribute( 'results/isExporting', false ); if ( ! exp || ! results ) { return () => void null; } return () => { markAsExporting( true ); const flatResults = flattenResults( settings, exp, results ); const table = tabulate( settings, exp, flatResults ); const filename = sprintf( /* translators: %s: Unique identifier. */ _x( 'results-export-%s.csv', 'text', 'nelio-ab-testing' ), exp?.id ?? 0 ); downloadAsCsv( table, filename ); markAsExporting( false ); }; } type FlatResults = Array< { day: string } & Record< string, string | number > >; function flattenResults( settings: ExportSettings, exp: Experiment, results: Results ): FlatResults { const data: Record< Day, FlatResults[ number ] > = {}; const getData = ( seg: string, alt: string, day: DayResults ) => ( { day: day.date, [ `s:${ seg }|a:${ alt }|v` ]: settings.uniqueResults ? day.uniqueVisits : day.visits, ...( exp?.goals ?? [] ).reduce( ( acc, goal ) => ( { ...acc, [ `s:${ seg }|a:${ alt }|c:${ goal.id }` ]: settings.uniqueResults ? day.uniqueConversions[ goal.id ] : day.conversions[ goal.id ], [ `s:${ seg }|a:${ alt }|val:${ goal.id }` ]: settings.uniqueResults ? day.uniqueValues[ goal.id ] : day.values[ goal.id ], } ), {} ), } ); for ( const alt of exp?.alternatives ?? [] ) { const altResult = results?.alternatives[ alt.id ]; for ( const day of altResult?.timeline ?? [] ) { data[ day.date ] = { ...( data[ day.date ] ?? {} ), ...getData( 'default', alt.id, day ), }; } } for ( const segment of exp?.segments ?? [] ) { const segmentResult = results?.segments[ segment.id ]; for ( const alt of exp?.alternatives ?? [] ) { const altResult = segmentResult?.alternatives[ alt.id ]; for ( const day of altResult?.timeline ?? [] ) { data[ day.date ] = { ...( data[ day.date ] ?? {} ), ...getData( segment.id, alt.id, day ), }; } } } return values( data ); } function tabulate( settings: ExportSettings, exp: Experiment, results: FlatResults ): ReadonlyArray< ReadonlyArray< string | number > > { const zeroes = () => initDataColumns( settings, 0 ); type Headers = Array< ReadonlyArray< string > >; const headers: Headers = getHeaders( settings, exp ); type FullDataRow = [ string, ...Array< number > ]; const dataRows: Array< FullDataRow > = []; type DataRow = Array< number >; const total: DataRow = zeroes(); const visitPattern = getVisitPattern( settings ); const visitIds1 = getVisitIds( settings, 0 ); const visitIds2 = getVisitIds( settings, 1 ); const conversionPattern = getConversionPattern( settings ); const conversionIds1 = getConversionIds( settings, 0 ); const conversionIds2 = getConversionIds( settings, 1 ); const conversionIds3 = getConversionIds( settings, 2 ); const valuePattern = getValuePattern( settings ); const conversionValueIds1 = getConversionValueIds( settings, 0 ); const conversionValueIds2 = getConversionValueIds( settings, 1 ); const conversionValueIds3 = getConversionValueIds( settings, 2 ); for ( const row of results ) { const dataRow: DataRow = zeroes(); let index = 0; for ( const id1 of visitIds1 ) { for ( const id2 of visitIds2 ) { const key = sprintf( visitPattern, id1, id2 ); const value = row[ key ]; dataRow[ index ] = 'number' === typeof value ? value : 0; ++index; } } for ( const id1 of conversionIds1 ) { for ( const id2 of conversionIds2 ) { for ( const id3 of conversionIds3 ) { const key = sprintf( conversionPattern, id1, id2, id3 ); const value = row[ key ]; dataRow[ index ] = 'number' === typeof value ? value : 0; ++index; } } } for ( const id1 of conversionValueIds1 ) { for ( const id2 of conversionValueIds2 ) { for ( const id3 of conversionValueIds3 ) { const key = sprintf( valuePattern, id1, id2, id3 ); const value = row[ key ]; dataRow[ index ] = 'number' === typeof value ? value : 0; ++index; } } } dataRows.push( [ row.day, ...dataRow ] ); for ( let i = 0; i < total.length; ++i ) { total[ i ] = ( total[ i ] ?? 0 ) + ( dataRow[ i ] ?? 0 ); } } return [ ...headers, ...dataRows, [ _x( 'Total', 'text', 'nelio-ab-testing' ), ...total ], ]; } function getHeaders( settings: ExportSettings, exp: Experiment ): Array< ReadonlyArray< string > > { // Helper objects and functions. const titles: Record< ExportSettings[ 'order' ][ number ] | 'valueGoals', string[] > = { segments: settings.segments .map( ( id ) => 'default' === id ? { id: 'default' as const } : exp.segments.find( ( s ) => s.id === id ) ) .map( ( s ) => ( s ? getSegmentLabel( exp, s ) : '' ) ), goals: settings.goals .map( ( id ) => exp.goals.find( ( g ) => g.id === id ) ) .map( ( g ) => ( g ? getGoalLabel( exp, g ) : '' ) ), valueGoals: settings.valueGoals .map( ( id ) => exp.goals.find( ( g ) => g.id === id ) ) .map( ( g ) => ( g ? getGoalLabel( exp, g ) : '' ) ), alternatives: settings.alternatives .map( ( id ) => exp.alternatives.find( ( a ) => a.id === id ) ) .map( ( a ) => ( a ? getAlternativeLabel( exp, a ) : '' ) ), }; const getTitles = ( mode: 'visits' | 'conversions' | 'conversionValues', level: number ) => { const dimension = settings.order[ level ]; if ( ! dimension ) { return [ '' ]; } if ( 'goals' === dimension && 'visits' === mode ) { return [ '' ]; } if ( 'goals' === dimension && 'conversionValues' === mode ) { return titles.valueGoals; } return titles[ dimension ]; }; // ================================================== // ================================================== const headers: Array< ReadonlyArray< string > > = []; // ================================================== // ================================================== const firstRow = initDataColumns( settings, '' ); let title = sprintf( /* translators: %1$d: Experiment id. %2$s: Experiment name. */ _x( 'Results of Test %1$d – %2$s', 'text', 'nelio-ab-testing' ), exp.id, exp.name ); if ( settings.segments.length === 1 && ( exp.segments.length > 1 || settings.segments[ 0 ] !== 'default' ) ) { title += ', ' + titles.segments[ 0 ]; } if ( settings.goals.length === 1 ) { title += ', ' + titles.goals[ 0 ]; } if ( settings.alternatives.length === 1 ) { title += ', ' + titles.alternatives[ 0 ]; } headers.push( [ title, ...firstRow ] ); // ================================================== // ================================================== const secondRow = initDataColumns( settings, '' ); secondRow[ 0 ] = _x( 'Page Views', 'text', 'nelio-ab-testing' ); const numOfVisits = settings.segments.length * settings.alternatives.length; secondRow[ numOfVisits ] = _x( 'Conversions', 'text', 'nelio-ab-testing' ); if ( settings.valueGoals.length ) { const numOfConversions = settings.segments.length * settings.goals.length * settings.alternatives.length; secondRow[ numOfVisits + numOfConversions ] = _x( 'Conversion Values', 'text', 'nelio-ab-testing' ); } headers.push( [ _x( 'Day', 'text', 'nelio-ab-testing' ), ...secondRow ] ); for ( const index of [ 0, 1, 2 ] ) { if ( getTitles( 'conversions', index ).length <= 1 ) { continue; } const visitBlockCount = getTitles( 'visits', index - 1 ).length * getTitles( 'visits', index - 2 ).length; const visitItemCount = getTitles( 'visits', index + 1 ).length * getTitles( 'visits', index + 2 ).length; const visitTitles = repeat( visitBlockCount, getTitles( 'visits', index ).flatMap( ( t ) => expand( visitItemCount, '', t ) ) ).flatMap( ( x ) => x ); const conversionBlockCount = getTitles( 'conversions', index - 1 ).length * getTitles( 'conversions', index - 2 ).length; const conversionItemCount = getTitles( 'conversions', index + 1 ).length * getTitles( 'conversions', index + 2 ).length; const conversionTitles = repeat( conversionBlockCount, getTitles( 'conversions', index ).flatMap( ( t ) => expand( conversionItemCount, '', t ) ) ).flatMap( ( x ) => x ); const valueBlockCount = getTitles( 'conversionValues', index - 1 ).length * getTitles( 'conversionValues', index - 2 ).length; const valueItemCount = getTitles( 'conversionValues', index + 1 ).length * getTitles( 'conversionValues', index + 2 ).length; const valueTitles = repeat( valueBlockCount, getTitles( 'conversionValues', index ).flatMap( ( t ) => expand( valueItemCount, '', t ) ) ).flatMap( ( x ) => x ); headers.push( [ '', ...visitTitles, ...conversionTitles, ...valueTitles, ] ); } return headers; } function getAlternativeLabel( exp: Experiment, alternative: Alternative< Dict > ) { const index = exp.alternatives.indexOf( alternative ); const defaultLabel = sprintf( /* translators: %s: Variant letter. */ _x( 'Variant %s', 'text', 'nelio-ab-testing' ), getLetter( index ) ); const name = 0 === index ? _x( 'Control Version', 'text', 'nelio-ab-testing' ) : trim( alternative.attributes.name ); return name ? sprintf( '%1$s – %2$s', defaultLabel, name ) : defaultLabel; } function getGoalLabel( exp: Experiment, goal: Goal ) { const index = exp.goals.indexOf( goal ); const defaultLabel = sprintf( /* translators: %s: Goal index. */ _x( 'Goal %s', 'text', 'nelio-ab-testing' ), index + 1 ); const name = trim( goal.attributes.name ); return name ? sprintf( '%1$s – %2$s', defaultLabel, name ) : defaultLabel; } function getSegmentLabel( exp: Experiment, segment: { id: 'default' } | Segment ) { if ( isDefaultSegment( segment ) ) { return _x( 'Combined Segment (Auto)', 'text', 'nelio-ab-testing' ); } const index = exp.segments.indexOf( segment ); const defaultLabel = sprintf( /* translators: %s: Segment index. */ _x( 'Segment %s', 'text', 'nelio-ab-testing' ), index + 1 ); const name = trim( segment.attributes.name ); return name ? sprintf( '%1$s – %2$s', defaultLabel, name ) : defaultLabel; } const isDefaultSegment = ( s: { id: 'default' } | Segment ): s is { id: 'default' } => s.id === 'default'; function toggle< T >( allSorted: ReadonlyArray< T >, include: boolean, items: ReadonlyArray< NoInfer< T > >, item: NoInfer< T > ): ReadonlyArray< T > { items = include ? [ ...items, item ] : without( items, item ); return allSorted.filter( ( i ) => items.includes( i ) ); } function insertAt< T >( position: number, items: ReadonlyArray< T >, item: T ): ReadonlyArray< T > { const clean = without( items, item ); return [ ...take( clean, position ), item, ...drop( clean, position ) ]; } const getVisitPattern = ( settings: ExportSettings ) => { const order = [ '', ...without( settings.order, 'goals' ) ]; return 's:${s}|a:${a}|v' .replace( '${s}', `%${ order.indexOf( 'segments' ) }$s` ) .replace( '${a}', `%${ order.indexOf( 'alternatives' ) }$s` ); }; const getVisitIds = ( settings: ExportSettings, i: number ): ReadonlyArray< string > => { const dim = without( settings.order, 'goals' )[ i ] ?? 'alternatives'; switch ( dim ) { case 'segments': return settings.segments; case 'goals': // We’ve excluded “goals” from the set of possible values in “dim”. return []; case 'alternatives': return settings.alternatives; } }; const getConversionPattern = ( settings: ExportSettings ) => { const order = [ '', ...settings.order ]; return 's:${s}|a:${a}|c:${g}' .replace( '${s}', '' + `%${ order.indexOf( 'segments' ) }$s` ) .replace( '${a}', '' + `%${ order.indexOf( 'alternatives' ) }$s` ) .replace( '${g}', '' + `%${ order.indexOf( 'goals' ) }$s` ); }; const getValuePattern = ( settings: ExportSettings ) => getConversionPattern( settings ).replace( 'c:', 'val:' ); const getConversionIds = ( settings: ExportSettings, i: number ): ReadonlyArray< string > => { const dim = settings.order[ i ] ?? 'alternatives'; switch ( dim ) { case 'segments': return settings.segments; case 'goals': return settings.goals; case 'alternatives': return settings.alternatives; } }; const getConversionValueIds = ( settings: ExportSettings, i: number ): ReadonlyArray< string > => { const dim = settings.order[ i ] ?? 'alternatives'; switch ( dim ) { case 'segments': return settings.segments; case 'goals': return settings.valueGoals; case 'alternatives': return settings.alternatives; } }; function initDataColumns< T >( settings: ExportSettings, initialValue: T ) { const numOfVisits = settings.segments.length * settings.alternatives.length; const numOfConversions = settings.segments.length * settings.goals.length * settings.alternatives.length; const numOfConversionValues = settings.segments.length * settings.valueGoals.length * settings.alternatives.length; const numOfColumns = 1 + numOfVisits + numOfConversions + numOfConversionValues; return range( numOfColumns - 1 ).map( () => initialValue ); } function expand< T >( times: number, value: T, firstValue: T ): Array< T > { const result = range( times ).map( () => value ); result[ 0 ] = firstValue; return result; } function repeat< T >( times: number, value: T ): Array< T > { return range( times ).map( () => value ); } const isValueGoal = ( g: Maybe< Goal > ): boolean => !! g?.attributes.useOrderRevenue;