Skip to content

Commit

Permalink
Editor Stats - track core global styles updates & saves (#53801)
Browse files Browse the repository at this point in the history
* Track global styles button click in header

* Rename file and fix tracking

* Track open and close

* Add e2e tests

* Fix wrong tab value when closing sidebar by another sidebar

* Beautify e2e tests

* track color, typography, and basic block settings

* generalize the process to find the updated item

* handle elements and reverse compare when necessary

* stringify value if object

* remove double import

* yeah, dont nest that.. bleh rebase/conflicts

* find all changed values and fix palette discrepency

* add saving event

* fix save tracking

* fix array ordering bug

* add debouncing

* add function docs

* comment updates

* start adding e2e tests

* fix loss of fidelity with palette reset

* fix palette reset test

* block level update tests

* *_update tests complete, now todo *_save

* fix update tests when running after other GS test

* save event tests

* only save global styles

* Update test/e2e/specs/specs-wpcom/wp-calypso-gutenberg-site-editor-tracking-spec.js

Co-authored-by: Bart Kalisz <[email protected]>

* fix checkbox clicking

* move sleep to saveGlobalStyles

* use setCheckbox function

Co-authored-by: David Szabo <[email protected]>
Co-authored-by: Bart Kalisz <[email protected]>
  • Loading branch information
3 people authored Jun 29, 2021
1 parent 87872be commit 4edfb08
Show file tree
Hide file tree
Showing 3 changed files with 566 additions and 1 deletion.
53 changes: 52 additions & 1 deletion apps/wpcom-block-editor/src/wpcom/features/tracking.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { use, select } from '@wordpress/data';
import { registerPlugin } from '@wordpress/plugins';
import { applyFilters } from '@wordpress/hooks';
import { find } from 'lodash';
import { find, isEqual } from 'lodash';
import debugFactory from 'debug';

/**
Expand All @@ -13,6 +13,7 @@ import debugFactory from 'debug';
import tracksRecordEvent from './tracking/track-record-event';
import delegateEventTracking from './tracking/delegate-event-tracking';
import { trackGlobalStylesTabSelected } from './tracking/wpcom-block-editor-global-styles-tab-selected';
import { buildGlobalStylesContentEvents } from './utils';

// Debugger.
const debug = debugFactory( 'wpcom-block-editor:tracking' );
Expand Down Expand Up @@ -391,6 +392,54 @@ const trackListViewToggle = ( isOpen ) => {
} );
};

/**
* Tracks editEntityRecord for global styles updates.
*
* @param {string} kind Kind of the edited entity record.
* @param {string} type Name of the edited entity record.
* @param {number} id Record ID of the edited entity record.
* @param {object} updates The edits made to the record.
*/
const trackEditEntityRecord = ( kind, type, id, updates ) => {
if ( kind === 'postType' && type === 'wp_global_styles' ) {
const editedEntity = select( 'core' ).getEditedEntityRecord( kind, type, id );
const entityContent = JSON.parse( editedEntity?.content );
const updatedContent = JSON.parse( updates?.content );

// Sometimes a second update is triggered corresponding to no changes since the last update.
// Therefore we must check if there is a change to avoid debouncing a valid update to a changeless update.
if ( ! isEqual( updatedContent, entityContent ) ) {
buildGlobalStylesContentEvents(
updatedContent,
entityContent,
'wpcom_block_editor_global_styles_update'
);
}
}
};

/**
* Tracks saveEditedEntityRecord for saving global styles updates.
*
* @param {string} kind Kind of the edited entity record.
* @param {string} type Name of the edited entity record.
* @param {number} id Record ID of the edited entity record.
*/
const trackSaveEditedEntityRecord = ( kind, type, id ) => {
if ( kind === 'postType' && type === 'wp_global_styles' ) {
const savedEntity = select( 'core' ).getEntityRecord( kind, type, id );
const editedEntity = select( 'core' ).getEditedEntityRecord( kind, type, id );
const entityContent = JSON.parse( savedEntity?.content?.raw );
const updatedContent = JSON.parse( editedEntity?.content );

buildGlobalStylesContentEvents(
updatedContent,
entityContent,
'wpcom_block_editor_global_styles_save'
);
}
};

/**
* Tracker can be
* - string - which means it is an event name and should be tracked as such automatically
Expand All @@ -413,6 +462,8 @@ const REDUX_TRACKING = {
core: {
undo: 'wpcom_block_editor_undo_performed',
redo: 'wpcom_block_editor_redo_performed',
editEntityRecord: trackEditEntityRecord,
saveEditedEntityRecord: trackSaveEditedEntityRecord,
},
'core/block-editor': {
moveBlocksUp: getBlocksTracker( 'wpcom_block_moved_up' ),
Expand Down
149 changes: 149 additions & 0 deletions apps/wpcom-block-editor/src/wpcom/features/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/**
* External Dependencies
*/
import { isEqual, some, debounce } from 'lodash';

/**
* Internal dependencies
*/
import tracksRecordEvent from './tracking/track-record-event';

/**
* Determines the type of the block editor.
*
Expand All @@ -22,3 +32,142 @@ export const getEditorType = () => {

return undefined;
};

/**
* Compares two objects, returning values in newObject that do not correspond
* to values in oldObject.
*
* @param {object|Array} newObject The object that has had an update.
* @param {object|Array} oldObject The original object to reference.
* @param {Array} keyMap Used in recursion. A list of keys mapping to the changed item.
*
* @returns {Array[object]} Array of objects containing a keyMap array and value for the changed items.
*/
const compareObjects = ( newObject, oldObject, keyMap = [] ) => {
if ( isEqual( newObject, oldObject ) ) {
return [];
}

const changedItems = [];
for ( const key of Object.keys( newObject ) ) {
// If an array, key/value association may not be maintained.
// So we must check against the entire collection instead of by key.
if ( Array.isArray( newObject ) ) {
if ( ! some( oldObject, ( item ) => isEqual( item, newObject[ key ] ) ) ) {
changedItems.push( { keyMap: [ ...keyMap ], value: newObject[ key ] || 'reset' } );
}
} else if ( ! isEqual( newObject[ key ], oldObject?.[ key ] ) ) {
if ( typeof newObject[ key ] === 'object' && newObject[ key ] !== null ) {
changedItems.push(
...compareObjects( newObject[ key ], oldObject?.[ key ], [ ...keyMap, key ] )
);
} else {
changedItems.push( { keyMap: [ ...keyMap, key ], value: newObject[ key ] || 'reset' } );
}
}
}

return changedItems;
};

/**
* Compares two objects by running compareObjects in both directions.
* This returns items in newContent that are different from those found in oldContent.
* Additionally, items found in oldContent that are not in newContent are added to the
* change list with their value as 'reset'.
*
* @param {object} newContent The object that has had an update.
* @param {object} oldContent The original object to reference.
*
* @returns {Array[object]} Array of objects containing a keyMap array and value for the changed items.
*/
const findUpdates = ( newContent, oldContent ) => {
const newItems = compareObjects( newContent, oldContent );

const removedItems = compareObjects( oldContent, newContent ).filter(
( update ) => ! some( newItems, ( { keyMap } ) => isEqual( update.keyMap, keyMap ) )
);
removedItems.forEach( ( item ) => {
if ( item.value?.color ) {
// So we don't override information about which color palette item was reset.
item.value.color = 'reset';
} else if ( typeof item.value === 'object' && item.value !== null ) {
// A safety - in case there happen to be any other objects in the future
// that slip by our mapping process, add an 'is_reset' prop to the object
// so the data about what was reset is not lost/overwritten.
item.value.is_reset = true;
} else {
item.value = 'reset';
}
} );

return [ ...newItems, ...removedItems ];
};

/**
* Builds tracks event props for a change in global styles.
*
* @param {Array[string]} keyMap A list of keys mapping to the changed item in the global styles content object.
* @param {*} value New value of the updated item.
*
* @returns {object} An object containing the event properties for a global styles change.
*/
const buildGlobalStylesEventProps = ( keyMap, value ) => {
let blockName;
let elementType;
let changeType;
let propertyChanged;
let fieldValue = value;
let paletteSlug;

if ( keyMap[ 1 ] === 'blocks' ) {
blockName = keyMap[ 2 ];
if ( keyMap[ 3 ] === 'elements' ) {
elementType = keyMap[ 4 ];
changeType = keyMap[ 5 ];
propertyChanged = keyMap[ 6 ];
} else {
changeType = keyMap[ 3 ];
propertyChanged = keyMap[ 4 ];
}
} else if ( keyMap[ 1 ] === 'elements' ) {
elementType = keyMap[ 2 ];
changeType = keyMap[ 3 ];
propertyChanged = keyMap[ 4 ];
} else {
changeType = keyMap[ 1 ];
propertyChanged = keyMap[ 2 ];
}

if ( propertyChanged === 'palette' ) {
fieldValue = value.color || 'reset';
paletteSlug = value.slug;
}

return {
block_type: blockName,
element_type: elementType,
section: changeType,
field: propertyChanged,
field_value:
typeof fieldValue === 'object' && fieldValue !== null
? JSON.stringify( fieldValue )
: fieldValue,
palette_slug: paletteSlug,
};
};

/**
* Builds and sends tracks events for global styles changes.
*
* @param {Object} updated The updated global styles content object.
* @param {Object} original The original global styles content object.
* @param {string} eventName Name of the tracks event to send.
*/
export const buildGlobalStylesContentEvents = debounce( ( updated, original, eventName ) => {
// Debouncing is necessary to avoid spamming tracks events with updates when sliding inputs
// such as a color picker are in use.
return findUpdates( updated, original )?.forEach( ( { keyMap, value } ) => {
tracksRecordEvent( eventName, buildGlobalStylesEventProps( keyMap, value ) );
} );
}, 100 );
Loading

0 comments on commit 4edfb08

Please sign in to comment.