diff --git a/components/button/index.js b/components/button/index.js index dde66367f378d8..dda515022caba1 100644 --- a/components/button/index.js +++ b/components/button/index.js @@ -26,11 +26,24 @@ class Button extends Component { } render() { - const { href, target, isPrimary, isLarge, isToggled, className, disabled, ...additionalProps } = this.props; + const { + href, + target, + isPrimary, + isSecondary, + isLarge, + isSmall, + isToggled, + className, + disabled, + ...additionalProps, // eslint-disable-line comma-dangle + } = this.props; const classes = classnames( 'components-button', className, { - button: ( isPrimary || isLarge ), + button: ( isPrimary || isSecondary || isLarge ), 'button-primary': isPrimary, + 'button-secondary': isSecondary, 'button-large': isLarge, + 'button-small': isSmall, 'is-toggled': isToggled, } ); diff --git a/components/button/test/index.js b/components/button/test/index.js index 05224da7ce1d40..058e0f1743ca3a 100644 --- a/components/button/test/index.js +++ b/components/button/test/index.js @@ -17,7 +17,6 @@ describe( 'Button', () => { expect( button.hasClass( 'button-large' ) ).toBe( false ); expect( button.hasClass( 'button-primary' ) ).toBe( false ); expect( button.hasClass( 'is-toggled' ) ).toBe( false ); - expect( button.hasClass( 'is-borderless' ) ).toBe( false ); expect( button.prop( 'disabled' ) ).toBeUndefined(); expect( button.prop( 'type' ) ).toBe( 'button' ); expect( button.type() ).toBe( 'button' ); @@ -30,6 +29,13 @@ describe( 'Button', () => { expect( button.hasClass( 'button-primary' ) ).toBe( true ); } ); + it( 'should render a button element with button-secondary class', () => { + const button = shallow( + + + +
+ { __( 'Usage data is completely anonymous, does not include your post content, and will only be used to improve the editor.' ) } +
+ + ); +} + +export default connect( + undefined, + { removeNotice } +)( EnableTrackingPrompt ); + diff --git a/editor/enable-tracking-prompt/style.scss b/editor/enable-tracking-prompt/style.scss new file mode 100644 index 00000000000000..ed7d7f0382ac61 --- /dev/null +++ b/editor/enable-tracking-prompt/style.scss @@ -0,0 +1,21 @@ +.enable-tracking-prompt { + .enable-tracking-prompt__message { + font-weight: bold; + margin-top: 8px; + + .enable-tracking-prompt__buttons { + float: right; + + .button { + margin-left: 6px; + } + } + } + + .enable-tracking-prompt__clarification { + clear: both; + margin-bottom: 8px; + font-size: 90%; + font-style: italic; + } +} diff --git a/editor/enable-tracking-prompt/test/index.js b/editor/enable-tracking-prompt/test/index.js new file mode 100644 index 00000000000000..686fa0ec0dade2 --- /dev/null +++ b/editor/enable-tracking-prompt/test/index.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { + EnableTrackingPrompt, + TRACKING_PROMPT_NOTICE_ID, +} from '../'; + +describe( 'EnableTrackingPrompt', () => { + const tracking = require( '../../utils/tracking' ); // no default export + const originalSetUserSetting = window.setUserSetting; + const originalBumpStat = tracking.bumpStat; + let removeNotice; + + beforeEach( () => { + window.setUserSetting = jest.fn(); + tracking.bumpStat = jest.fn(); + removeNotice = jest.fn(); + } ); + + afterEach( () => { + window.setUserSetting = originalSetUserSetting; + tracking.bumpStat = originalBumpStat; + } ); + + it( 'should render a prompt with Yes and No buttons', () => { + const prompt = mount( + + ); + const buttons = prompt.find( '.button' ); + expect( buttons.length ).toBe( 2 ); + expect( buttons.at( 0 ).text() ).toBe( 'Yes' ); + expect( buttons.at( 1 ).text() ).toBe( 'No' ); + + expect( window.setUserSetting ) + .not.toHaveBeenCalled(); + expect( tracking.bumpStat ) + .not.toHaveBeenCalled(); + expect( removeNotice ) + .not.toHaveBeenCalled(); + } ); + + it( 'should enable tracking when clicking Yes', () => { + const prompt = mount( + + ); + const buttonYes = prompt.find( '.button' ) + .filterWhere( node => node.text() === 'Yes' ); + buttonYes.simulate( 'click' ); + + expect( window.setUserSetting ) + .toHaveBeenCalledWith( 'gutenberg_tracking', 'on' ); + expect( tracking.bumpStat ) + .toHaveBeenCalledWith( 'tracking', 'opt-in' ); + expect( removeNotice ) + .toHaveBeenCalledWith( TRACKING_PROMPT_NOTICE_ID ); + } ); + + it( 'should disable tracking when clicking No', () => { + const prompt = mount( + + ); + const buttonNo = prompt.find( '.button' ) + .filterWhere( node => node.text() === 'No' ); + buttonNo.simulate( 'click' ); + + expect( window.setUserSetting ) + .toHaveBeenCalledWith( 'gutenberg_tracking', 'off' ); + expect( tracking.bumpStat ) + .not.toHaveBeenCalled(); + expect( removeNotice ) + .toHaveBeenCalledWith( TRACKING_PROMPT_NOTICE_ID ); + } ); +} ); diff --git a/editor/index.js b/editor/index.js index 9a17f6ab72b8f0..c9e7ca22f63631 100644 --- a/editor/index.js +++ b/editor/index.js @@ -20,7 +20,8 @@ import { settings } from '@wordpress/date'; import './assets/stylesheets/main.scss'; import Layout from './layout'; import { createReduxStore } from './state'; -import { undo } from './actions'; +import { undo, createInfoNotice } from './actions'; +import EnableTrackingPrompt, { TRACKING_PROMPT_NOTICE_ID } from './enable-tracking-prompt'; import EditorSettingsProvider from './settings/provider'; /** @@ -98,6 +99,13 @@ export function createEditorInstance( id, post, editorSettings = DEFAULT_SETTING settings: editorSettings, } ); + if ( window.getUserSetting( 'gutenberg_tracking' ) === '' ) { + store.dispatch( createInfoNotice( + , + TRACKING_PROMPT_NOTICE_ID + ) ); + } + preparePostState( store, post ); render( diff --git a/editor/inserter/index.js b/editor/inserter/index.js index 1f210bdfc8964e..aee767446526d2 100644 --- a/editor/inserter/index.js +++ b/editor/inserter/index.js @@ -18,6 +18,7 @@ import { createBlock } from '@wordpress/blocks'; import InserterMenu from './menu'; import { getBlockInsertionPoint, getEditorMode } from '../selectors'; import { insertBlock, hideInsertionPoint } from '../actions'; +import { bumpStat } from '../utils/tracking'; class Inserter extends Component { constructor() { @@ -49,6 +50,8 @@ class Inserter extends Component { name, insertionPoint ); + bumpStat( 'add_block_inserter', name.replace( /\//g, '__' ) ); + bumpStat( 'add_block_total', name.replace( /\//g, '__' ) ); } this.close(); diff --git a/editor/modes/visual-editor/block-list.js b/editor/modes/visual-editor/block-list.js index ec1fca2cee3037..96148bf28cc62a 100644 --- a/editor/modes/visual-editor/block-list.js +++ b/editor/modes/visual-editor/block-list.js @@ -29,6 +29,7 @@ import { getMultiSelectedBlockUids, } from '../../selectors'; import { insertBlock, multiSelect } from '../../actions'; +import { bumpStat } from '../../utils/tracking'; const INSERTION_POINT_PLACEHOLDER = '[[insertion-point]]'; const { ENTER } = keycodes; @@ -201,6 +202,8 @@ class VisualEditorBlockList extends Component { insertBlock( name ) { const newBlock = createBlock( name ); this.props.onInsertBlock( newBlock ); + bumpStat( 'add_block_quick', name.replace( /\//g, '__' ) ); + bumpStat( 'add_block_total', name.replace( /\//g, '__' ) ); } toggleContinueWritingControls( showContinueWritingControls ) { diff --git a/editor/utils/test/tracking.js b/editor/utils/test/tracking.js new file mode 100644 index 00000000000000..8c0b11f9f3a0cb --- /dev/null +++ b/editor/utils/test/tracking.js @@ -0,0 +1,83 @@ +/* eslint-disable no-console */ + +/** + * Internal dependencies + */ +import { bumpStat } from '../tracking'; + +describe( 'bumpStat', () => { + const originalConsoleError = console.error; + const originalGetUserSetting = window.getUserSetting; + + beforeEach( () => { + console.error = jest.fn(); + window.getUserSetting = () => 'off'; + } ); + + afterEach( () => { + console.error = originalConsoleError; + window.getUserSetting = originalGetUserSetting; + } ); + + it( 'should reject non-string stat groups', () => { + expect( bumpStat( 42, 'valid-name' ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat group names and stat names must be strings.' + ); + } ); + + it( 'should reject non-string stat names', () => { + expect( bumpStat( 'valid_group', 42 ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat group names and stat names must be strings.' + ); + } ); + + it( 'should reject group names with invalid characters', () => { + expect( bumpStat( 'invalid-group', 'valid-name' ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat group names must consist of letters, numbers, and underscores.' + ); + } ); + + it( 'should reject group names longer than 22 chars', () => { + expect( bumpStat( Array( 23 + 1 ).join( 'x' ), 'valid-name' ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat group names cannot be longer than 22 characters.' + ); + } ); + + it( 'should reject stat names with invalid characters', () => { + expect( bumpStat( 'group', 'invalidName' ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat names must consist of letters, numbers, underscores, and dashes.' + ); + } ); + + it( 'should reject stat names longer than 32 chars', () => { + expect( bumpStat( 'name', Array( 33 + 1 ).join( 'x' ) ) ).toBeUndefined(); + expect( console.error ).toHaveBeenCalledWith( + 'Stat names cannot be longer than 32 characters.' + ); + } ); + + it( 'should do nothing if the user has not opted in', () => { + expect( bumpStat( 'valid_group', 'valid-name' ) ).toBeUndefined(); + expect( console.error ).not.toHaveBeenCalled(); + } ); + + it( 'should bump valid stats', () => { + window.getUserSetting = () => 'on'; + const url = bumpStat( 'valid_group', 'valid-name' ); + // There are a couple of pieces of the URL where we don't care about + // testing the specific value. Replace them with placeholders. + const urlMatch = url + .replace( /^[a-z]+:/, 'PROTOCOL:' ) + .replace( /t=[0-9.]+$/, 't=NUMBER' ); + expect( urlMatch ).toBe( + 'PROTOCOL://pixel.wp.com/g.gif?v=wpcom-no-pv' + + '&x_gutenberg_valid_group=valid-name' + + '&t=NUMBER' + ); + } ); +} ); diff --git a/editor/utils/tracking.js b/editor/utils/tracking.js new file mode 100644 index 00000000000000..a8bb4fd5febc2e --- /dev/null +++ b/editor/utils/tracking.js @@ -0,0 +1,61 @@ +/* eslint-disable no-console */ + +export function bumpStat( group, name ) { + if ( typeof group !== 'string' || typeof name !== 'string' ) { + console.error( + 'Stat group names and stat names must be strings.' + ); + return; + } + + if ( /[^a-z0-9_]/.test( group ) ) { + console.error( + 'Stat group names must consist of letters, numbers, and underscores.' + ); + return; + } + + if ( group.length > 22 ) { // 32 - 'gutenberg_'.length + console.error( + 'Stat group names cannot be longer than 22 characters.' + ); + return; + } + + if ( /[^a-z0-9_-]/.test( name ) ) { + console.error( + 'Stat names must consist of letters, numbers, underscores, and dashes.' + ); + return; + } + + if ( name.length > 32 ) { + console.error( + 'Stat names cannot be longer than 32 characters.' + ); + return; + } + + if ( window.getUserSetting( 'gutenberg_tracking' ) !== 'on' ) { + return; + } + + const src = document.location.protocol + + '//pixel.wp.com/g.gif?v=wpcom-no-pv' + + '&x_gutenberg_' + encodeURIComponent( group ) + + '=' + encodeURIComponent( name ) + + '&t=' + Math.random(); + + if ( process.env.NODE_ENV === 'development' ) { + console.log( + 'Skipping stats collection for development build:', + src + ); + } + + if ( process.env.NODE_ENV !== 'production' ) { + return src; + } + + new window.Image().src = src; +}