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( );
+ expect( button.hasClass( 'button' ) ).toBe( true );
+ expect( button.hasClass( 'button-large' ) ).toBe( false );
+ expect( button.hasClass( 'button-secondary' ) ).toBe( true );
+ } );
+
it( 'should render a button element with button-large class', () => {
const button = shallow( );
expect( button.hasClass( 'button' ) ).toBe( true );
@@ -37,6 +43,14 @@ describe( 'Button', () => {
expect( button.hasClass( 'button-primary' ) ).toBe( false );
} );
+ it( 'should render a button element with button-small class', () => {
+ const button = shallow( );
+ expect( button.hasClass( 'button' ) ).toBe( false );
+ expect( button.hasClass( 'button-large' ) ).toBe( false );
+ expect( button.hasClass( 'button-small' ) ).toBe( true );
+ expect( button.hasClass( 'button-primary' ) ).toBe( false );
+ } );
+
it( 'should render a button element with is-toggled without button class', () => {
const button = shallow( );
expect( button.hasClass( 'button' ) ).toBe( false );
diff --git a/components/notice/style.scss b/components/notice/style.scss
index a57109491d4683..eb91ea840d49ee 100644
--- a/components/notice/style.scss
+++ b/components/notice/style.scss
@@ -3,6 +3,6 @@
top: 40px;
right: 0;
min-width: 300px;
- max-width: 400px;
+ max-width: 650px;
z-index: z-index( ".components-notice-list" );
}
diff --git a/editor/actions.js b/editor/actions.js
index 73ca75e43f3565..a31046fc9592c6 100644
--- a/editor/actions.js
+++ b/editor/actions.js
@@ -234,5 +234,6 @@ export function removeNotice( id ) {
}
export const createSuccessNotice = partial( createNotice, 'success' );
+export const createInfoNotice = partial( createNotice, 'info' );
export const createErrorNotice = partial( createNotice, 'error' );
export const createWarningNotice = partial( createNotice, 'warning' );
diff --git a/editor/enable-tracking-prompt/index.js b/editor/enable-tracking-prompt/index.js
new file mode 100644
index 00000000000000..ba33868af453a0
--- /dev/null
+++ b/editor/enable-tracking-prompt/index.js
@@ -0,0 +1,65 @@
+/**
+ * External dependencies
+ */
+import { connect } from 'react-redux';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import { bumpStat } from '../utils/tracking';
+import { removeNotice } from '../actions';
+
+export const TRACKING_PROMPT_NOTICE_ID = 'notice:enable-tracking-prompt';
+
+export function EnableTrackingPrompt( props ) {
+ function dismissTrackingPrompt( enableTracking ) {
+ window.setUserSetting(
+ 'gutenberg_tracking',
+ enableTracking ? 'on' : 'off'
+ );
+ if ( enableTracking ) {
+ bumpStat( 'tracking', 'opt-in' );
+ }
+ props.removeNotice( TRACKING_PROMPT_NOTICE_ID );
+ }
+
+ return (
+
+
+ { __( 'Can Gutenberg collect data about your usage of the editor?' ) }
+
+
+
+
+
+
+ { __( '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;
+}