diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..8f9206d --- /dev/null +++ b/.eslintrc @@ -0,0 +1,12 @@ +{ + "extends": "humanmade", + "globals": { + "Altis": "readonly", + "wp": "readonly", + "moment": "readonly" + }, + "rules": { + "no-multi-str": "off", + "no-console": "off" + } +} diff --git a/inc/features/blocks/namespace.php b/inc/features/blocks/namespace.php new file mode 100644 index 0000000..941faf2 --- /dev/null +++ b/inc/features/blocks/namespace.php @@ -0,0 +1,71 @@ + 'altis-experience-blocks', + 'title' => __( 'Experience Blocks', 'altis-experiments' ), + ]; + + return $categories; +} + +/** + * Reads and returns a block.json file to pass shared settings + * between JS and PHP to the register blocks functions. + * + * @param string $name The directory name of the block relative to this file. + * @return array|null The JSON data as an associative array or null on error. + */ +function get_block_settings( string $name ) : ?array { + $json_path = __DIR__ . '/' . $name . '/block.json'; + + // Check name is valid. + if ( ! file_exists( $json_path ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + trigger_error( sprintf( 'Error reading %/block.json: file does not exist.', $name ), E_USER_WARNING ); + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $json = file_get_contents( $json_path ); + + // Decode the settings. + $settings = json_decode( $json, ARRAY_A ); + + // Check JSON is valid. + if ( json_last_error() !== JSON_ERROR_NONE ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + trigger_error( sprintf( 'Error decoding %/block.json: %s', $name, json_last_error_msg() ), E_USER_WARNING ); + return null; + } + + return $settings; +} diff --git a/inc/features/blocks/personalization-variant/block.json b/inc/features/blocks/personalization-variant/block.json new file mode 100644 index 0000000..129708e --- /dev/null +++ b/inc/features/blocks/personalization-variant/block.json @@ -0,0 +1,25 @@ +{ + "name": "altis/personalization-variant", + "settings": { + "category": "altis-experience-blocks", + "icon": "groups", + "parent": [ "altis/personalization" ], + "supports": { + "reusable": false, + "html": false, + "lightBlockWrapper": true, + "inserter": false + }, + "attributes": { + "parentId": { + "type": "string" + }, + "audience": { + "type": "number" + }, + "fallback": { + "type": "boolean" + } + } + } +} diff --git a/inc/features/blocks/personalization-variant/edit.js b/inc/features/blocks/personalization-variant/edit.js new file mode 100644 index 0000000..afd44fa --- /dev/null +++ b/inc/features/blocks/personalization-variant/edit.js @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; + +const { InnerBlocks } = wp.blockEditor; +const { compose } = wp.compose; +const { withSelect, withDispatch } = wp.data; + +const Edit = ( { + hasChildBlocks, + isSelected, + onSelect, +} ) => { + // Select the block parent if a variant is directly selected. + useEffect( () => { + if ( isSelected ) { + onSelect(); + } + }, [ isSelected ] ); + + const props = {}; + if ( ! hasChildBlocks ) { + // If we don't have any child blocks, show large block appender button. + props.renderAppender = () => ; + } + + return ( + + ); +}; + +export default compose( + withSelect( ( select, ownProps ) => { + const { clientId } = ownProps; + const { getBlockOrder } = select( 'core/block-editor' ); + + return { + hasChildBlocks: () => getBlockOrder( clientId ).length > 0, + }; + } ), + withDispatch( ( dispatch, ownProps, registry ) => { + const { clientId } = ownProps; + const { getBlockRootClientId } = registry.select( 'core/block-editor' ); + const { selectBlock } = dispatch( 'core/block-editor' ); + + // Get parent block client ID. + const rootClientId = getBlockRootClientId( clientId ); + + return { + onSelect: () => selectBlock( rootClientId ), + }; + } ), +)( Edit ); diff --git a/inc/features/blocks/personalization-variant/index.js b/inc/features/blocks/personalization-variant/index.js new file mode 100644 index 0000000..238bad2 --- /dev/null +++ b/inc/features/blocks/personalization-variant/index.js @@ -0,0 +1,18 @@ +import edit from './edit'; +import save from './save'; + +import blockData from './block.json'; + +const { registerBlockType } = wp.blocks; +const { __ } = wp.i18n; + +const settings = { + title: __( 'Personalized Content Variant', 'altis-experiments' ), + description: __( 'Personalized content block items', 'altis-experiments' ), + edit, + save, + ...blockData.settings, +}; + +// Register block. +registerBlockType( blockData.name, settings ); diff --git a/inc/features/blocks/personalization-variant/register.php b/inc/features/blocks/personalization-variant/register.php new file mode 100644 index 0000000..2448efd --- /dev/null +++ b/inc/features/blocks/personalization-variant/register.php @@ -0,0 +1,95 @@ + $block_data['settings']['attributes'], + 'render_callback' => __NAMESPACE__ . '\\render_block', + ] ); +} + +/** + * Enqueues the block assets. + */ +function enqueue_assets() { + wp_enqueue_script( + 'altis-experiments-features-blocks-personalization-variant', + Utils\get_asset_url( 'features/blocks/personalization-variant.js' ), + [], + null + ); + + wp_add_inline_script( + 'altis-experiments-features-blocks-personalization-variant', + sprintf( + 'window.Altis = window.Altis || {};' . + 'window.Altis.Analytics = window.Altis.Analytics || {};' . + 'window.Altis.Analytics.Experiments = window.Altis.Analytics.Experiments || {};' . + 'window.Altis.Analytics.Experiments.BuildURL = %s;', + wp_json_encode( plugins_url( 'build', Experiments\ROOT_FILE ) ) + ), + 'before' + ); +} + +/** + * Render callback for the personalization variant block. + * + * Because this block only saves on the JS side, + * the content string represents only the wrapped inner block markup. + * + * @param array $attributes The block's attributes object. + * @param string $innerContent The block's saved content. + * @return string The final rendered block markup, as an HTML string. + */ +function render_block( array $attributes, ?string $inner_content = '' ) : string { + $parent_id = $attributes['parentId'] ?? false; + $audience = $attributes['audience'] ?? 0; + $fallback = $attributes['fallback'] ?? false; + + if ( ! $parent_id ) { + trigger_error( 'Personalization block variant has no parent ID set.', E_USER_WARNING ); + return ''; + } + + // If this is the fallback variant output the template with different attributes + // for easier and more specific targeting by document.querySelector(). + if ( $fallback ) { + return sprintf( + '', + esc_attr( $parent_id ), + $inner_content + ); + } + + return sprintf( + '', + esc_attr( $audience ), + esc_attr( $parent_id ), + $inner_content + ); +} diff --git a/inc/features/blocks/personalization-variant/save.js b/inc/features/blocks/personalization-variant/save.js new file mode 100644 index 0000000..015a746 --- /dev/null +++ b/inc/features/blocks/personalization-variant/save.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const { InnerBlocks } = wp.blockEditor; + +const Save = () => { + return ( + + ); +}; + +export default Save; diff --git a/inc/features/blocks/personalization/block.json b/inc/features/blocks/personalization/block.json new file mode 100644 index 0000000..a0fdd58 --- /dev/null +++ b/inc/features/blocks/personalization/block.json @@ -0,0 +1,17 @@ +{ + "name": "altis/personalization", + "settings": { + "category": "altis-experience-blocks", + "icon": "groups", + "supports": { + "alignWide": true, + "html": false, + "align": true + }, + "attributes": { + "clientId": { + "type": "string" + } + } + } +} diff --git a/inc/features/blocks/personalization/components/variant-panel.js b/inc/features/blocks/personalization/components/variant-panel.js new file mode 100644 index 0000000..f1b97c1 --- /dev/null +++ b/inc/features/blocks/personalization/components/variant-panel.js @@ -0,0 +1,40 @@ +import React from 'react'; +import VariantTitle from './variant-title'; + +const { AudiencePicker } = Altis.Analytics.components; + +const { PanelBody } = wp.components; +const { useDispatch } = wp.data; +const { __ } = wp.i18n; + +const VariantPanel = ( { variant } ) => { + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + if ( variant.attributes.fallback ) { + return ( + +

+ { __( 'This variant will be shown as a fallback if no audiences are matched. You can leave the content empty if you do not wish to show anything.', 'altis-experiments' ) } +

+
+ ); + } + + return ( + }> + updateBlockAttributes( variant.clientId, { audience: audience.id } ) } + onClearSelection={ () => updateBlockAttributes( variant.clientId, { audience: null } ) } + /> + { ! variant.attributes.audience && ( +

+ { __( 'You must select an audience for this variant.', 'altis-experiments' ) } +

+ ) } +
+ ); +}; + +export default VariantPanel; diff --git a/inc/features/blocks/personalization/components/variant-title.js b/inc/features/blocks/personalization/components/variant-title.js new file mode 100644 index 0000000..2be2af3 --- /dev/null +++ b/inc/features/blocks/personalization/components/variant-title.js @@ -0,0 +1,45 @@ +const { useSelect } = wp.data; +const { __ } = wp.i18n; + +// Component for fetching and displaying the variant title string. +const VariantTitle = ( { variant } ) => { + const audience = useSelect( select => { + return select( 'audience' ).getPost( variant.attributes.audience ); + }, [ variant.attributes.audience ] ); + + const isLoading = useSelect( select => select( 'audience' ).getIsLoading(), [] ); + + if ( variant.attributes.fallback ) { + return __( 'Fallback', 'altis-experiments' ); + } + + if ( ! variant.attributes.audience ) { + return __( 'Select audience', 'altis-experiments' ); + } + + const status = ( audience && audience.status ) || 'draft'; + const title = audience && audience.title && audience.title.rendered; + + // Audience is valid and has a title. + if ( status !== 'trash' && title ) { + return audience.title.rendered; + } + + // Audience has been deleted. + if ( status === 'trash' ) { + return __( '(deleted)', 'altis-experiments' ); + } + + // Check if audience reponse is a REST API error. + if ( audience && audience.error && audience.error.message ) { + return audience.error.message; + } + + if ( isLoading ) { + return __( 'Loading...', 'altis-experiments' ); + } + + return ''; +}; + +export default VariantTitle; diff --git a/inc/features/blocks/personalization/components/variant-toolbar.js b/inc/features/blocks/personalization/components/variant-toolbar.js new file mode 100644 index 0000000..57180a7 --- /dev/null +++ b/inc/features/blocks/personalization/components/variant-toolbar.js @@ -0,0 +1,35 @@ +import React from 'react'; + +const { IconButton } = wp.components; +const { __ } = wp.i18n; + +const VariantToolbar = props => { + const { + canRemove, + isFallback, + onCopy, + onRemove, + } = props; + + return ( +
+ + { __( 'Copy', 'altis-experiments' ) } + + { ! isFallback && ( + + ) } +
+ ); +}; + +export default VariantToolbar; diff --git a/inc/features/blocks/personalization/data/edit.js b/inc/features/blocks/personalization/data/edit.js new file mode 100644 index 0000000..ea0d82b --- /dev/null +++ b/inc/features/blocks/personalization/data/edit.js @@ -0,0 +1,148 @@ +const { createBlock, cloneBlock } = wp.blocks; +const { compose } = wp.compose; +const { withSelect, withDispatch } = wp.data; + +/** + * Creates a new fallback variant block within the provided experience block clientId. + * + * @param {String} parentId The parent experience block client ID. + * @return The new fallback variant block. + */ +const createFallbackBlock = parentId => { + return createBlock( 'altis/personalization-variant', { + parentId, + audience: null, + fallback: true, + } ); +}; + +/** + * Returns an upgraded React Component with data store connectors. + * + * @param {React.Component} Component + * @return React.Component + */ +const withData = Component => compose( + withSelect( ( select, ownProps ) => { + const { clientId, attributes } = ownProps; + const { getBlocks } = select( 'core/block-editor' ); + + const parentClientId = attributes.clientId || clientId; + const innerBlocks = getBlocks( clientId ); + + // Ensure at least one variant is present as a fallback. + // Note TEMPLATE does not seem to have the desired effect every time. + if ( innerBlocks.length === 0 ) { + const fallbackBlock = createFallbackBlock( parentClientId ); + innerBlocks.push( fallbackBlock ); + } else { + // Add a flag to check if we have a fallback explicitly set. + const hasFallback = innerBlocks.find( block => block.attributes.fallback ); + if ( ! hasFallback ) { + const fallbackBlock = createFallbackBlock( parentClientId ); + innerBlocks.push( fallbackBlock ); + } + } + + return { + variants: innerBlocks, + }; + } ), + withDispatch( ( dispatch, ownProps, registry ) => { + return { + onAddVariant() { + const { clientId, attributes } = ownProps; + const { replaceInnerBlocks, selectBlock } = dispatch( 'core/block-editor' ); + const { getBlocks } = registry.select( 'core/block-editor' ); + + const newVariant = createBlock( 'altis/personalization-variant', { + parentId: attributes.clientId, + audience: null, + } ); + + // Prepend the new variant. + const innerBlocks = [ + newVariant, + ...getBlocks( clientId ), + ]; + + // Update the inner blocks. + replaceInnerBlocks( clientId, innerBlocks ); + + // Focus defaults to the newly added inner block so keep it on the parent. + selectBlock( clientId ); + + // Return new client ID to enable selection. + return newVariant.clientId; + }, + onCopyVariant( variantClientId ) { + const { replaceInnerBlocks } = dispatch( 'core/block-editor' ); + const { + getBlock, + getBlocks, + getBlockRootClientId, + } = registry.select( 'core/block-editor' ); + + // Clone the the block but override the audience. + const fromVariant = getBlock( variantClientId ); + const newVariant = cloneBlock( fromVariant, { + audience: null, + fallback: false, + } ); + + const experienceBlockClientId = getBlockRootClientId( variantClientId ); + const variantBlocks = getBlocks( experienceBlockClientId ); + const fromVariantIndex = variantBlocks.findIndex( variant => variant.clientId === variantClientId ); + + // If we've copied the fallback then add it just before the fallback variant. + // Otherwise add it just after the source block. + const fromIndex = fromVariant.attributes.fallback ? fromVariantIndex : fromVariantIndex + 1; + + const nextBlocks = [ + ...variantBlocks.slice( 0, fromIndex ), + newVariant, + ...variantBlocks.slice( fromIndex ), + ]; + + replaceInnerBlocks( experienceBlockClientId, nextBlocks ); + + return newVariant.clientId; + }, + onRemoveVariant( variantClientId ) { + const { clientId, attributes } = ownProps; + const { replaceInnerBlocks } = dispatch( 'core/block-editor' ); + const { getBlocks } = registry.select( 'core/block-editor' ); + + // Prevent removal of the fallback variant. + if ( attributes.fallback ) { + return; + } + + // Remove inner block by clientId. + const innerBlocks = getBlocks( clientId ).filter( block => block.clientId !== variantClientId ); + + // Update the inner blocks. + replaceInnerBlocks( clientId, innerBlocks ); + }, + onSetClientId() { + const { attributes, setAttributes } = ownProps; + if ( ! attributes.clientId ) { + setAttributes( { clientId: attributes.clientId } ); + } + }, + onSetVariantParents() { + const { attributes, variants } = ownProps; + const { updateBlockAttributes } = dispatch( 'core/block-editor' ); + variants.forEach( variant => { + if ( ! variant.attributes.parentId || variant.attributes.parentId !== attributes.clientId ) { + updateBlockAttributes( variant.clientId, { + parentId: attributes.clientId, + } ); + } + } ); + }, + }; + } ), +)( Component ); + +export default withData; diff --git a/inc/features/blocks/personalization/edit.css b/inc/features/blocks/personalization/edit.css new file mode 100644 index 0000000..af74827 --- /dev/null +++ b/inc/features/blocks/personalization/edit.css @@ -0,0 +1,94 @@ +/** + * Experience block editor CSS. + */ + +.altis-experience-block-header { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 16px; + background: repeating-linear-gradient( + 315deg, + rgba(0, 0, 0, .05), + rgba(0, 0, 0, .05) 10px, + rgba(255, 255, 255, .05) 10px, + rgba(255, 255, 255, .05) 20px + ); + padding: 14px; + margin: 0 -14px -28px; + position: relative; + top: -14px; + display: flex; +} + +.altis-experience-block-header__title { + flex: 1; + font-weight: bold; +} + +.altis-experience-block-header__toolbar { + flex: 0; + border: 0; + padding: 0; + margin: -4px 0; + display: flex; +} + +.altis-experience-block-header__toolbar .components-button { + margin-left: 3px; + color: inherit; +} + +.block-editor-block-contextual-toolbar[data-type="altis/personalization"] .block-editor-block-toolbar__slot { + flex-shrink: 0; +} + +.altis-variants-toolbar { + flex-shrink: 1; +} + +.altis-variants-toolbar .altis-variants-toolbar__tabs { + max-width: 400px; + overflow-x: auto; + margin-left: 0; +} + +.altis-variants-toolbar .altis-variant-button { + width: auto; + flex-shrink: 0; + padding: 0 11px; +} + +.altis-variants-toolbar .altis-variant-button.is-active, +.altis-variants-toolbar .altis-variant-button.is-pressed, +.altis-variants-toolbar .altis-variant-button.is-active:hover, +.altis-variants-toolbar .altis-variant-button.is-pressed:hover, +.altis-variants-toolbar .altis-variant-button.is-active:focus, +.altis-variants-toolbar .altis-variant-button.is-pressed:focus, +.altis-variants-toolbar .altis-variant-button:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):not(.is-primary):not(.is-tertiary):not(.is-link).is-active, +.altis-variants-toolbar .altis-variant-button:not(:disabled):not([aria-disabled="true"]):not(.is-secondary):not(.is-primary):not(.is-tertiary):not(.is-link).is-pressed, +.altis-variants-toolbar .altis-variant-button:not(:disabled):not([aria-disabled="true"]):not(.is-default).is-active, +.altis-variants-toolbar .altis-variant-button:not(:disabled):not([aria-disabled="true"]):not(.is-default).is-pressed { + outline: none; + color: #fff; + box-shadow: none; + margin: 3px; + height: 30px; + padding: 0 8px; + background: #555d66; +} + +.altis-variants-toolbar .altis-add-variant-button svg { + padding: 6px 5px 4px 5px; +} + +.audience-picker-control .audience-picker-control__value { + margin-top: 6px; + margin-bottom: 4px; +} + +.wp-block[data-type="altis/personalization-variant"] { + max-width: none; +} + +.wp-block[data-type="altis/personalization-variant"] .wp-block { + max-width: none; +} diff --git a/inc/features/blocks/personalization/edit.js b/inc/features/blocks/personalization/edit.js new file mode 100644 index 0000000..e9a4b79 --- /dev/null +++ b/inc/features/blocks/personalization/edit.js @@ -0,0 +1,147 @@ +import React, { Fragment, useEffect, useState } from 'react'; +import VariantTitle from './components/variant-title'; +import VariantPanel from './components/variant-panel'; +import VariantToolbar from './components/variant-toolbar'; + +import withData from './data/edit'; + +const { + BlockControls, + InnerBlocks, + InspectorControls, +} = wp.blockEditor; +const { + Button, + Toolbar, +} = wp.components; +const { __ } = wp.i18n; + +/** + * Only variants can be direct descendents so that we can generate + * usable markup. + */ +const ALLOWED_BLOCKS = [ 'altis/personalization-variant' ]; + +/** + * Start with a default template of one variant. + */ +const TEMPLATE = [ + [ 'altis/personalization-variant' ], +]; + +// Audience picker input. +const Edit = ( { + attributes, + className, + clientId, + isSelected, + onAddVariant, + onCopyVariant, + onRemoveVariant, + onSetClientId, + onSetVariantParents, + variants, +} ) => { + // Track currently selected variant. + const defaultVariantClientId = ( variants.length > 0 && variants[ 0 ].clientId ) || null; + const [ activeVariant, setVariant ] = useState( defaultVariantClientId ); + + // Track the active variant index to show in the title. + const activeVariantIndex = variants.findIndex( variant => variant.clientId === activeVariant ); + + // Set clientId attribute if not set. + useEffect( () => { + onSetClientId(); + }, [] ); + + // Ensure variant parentId is correct. + useEffect( () => { + onSetVariantParents(); + }, [ attributes.clientId ] ); + + // Controls that appear before the variant selector buttons. + const variantsToolbarControls = [ + { + icon: 'plus', + title: __( 'Add a variant', 'altis-experiments' ), + className: 'altis-add-variant-button', + onClick: () => setVariant( onAddVariant() ), + }, + ]; + + // When a variant is removed select the preceeding one along unless it's the first in the list. + const onRemove = () => { + if ( activeVariantIndex === 0 ) { + setVariant( variants[ activeVariantIndex + 1 ].clientId ); + } else { + setVariant( variants[ activeVariantIndex - 1 ].clientId ); + } + onRemoveVariant( activeVariant ); + }; + + return ( + + + +
+ { variants.map( variant => ( + + ) ) } +
+
+
+ + { variants.map( variant => ( + + ) ) } + + + + `; + + // Update the component content. + this.setContent(); + + // Attach a listener to update the content when audiences are changed. + window.Altis.Analytics.on( 'updateAudiences', this.setContent ); + } + + setContent = () => { + const audiences = window.Altis.Analytics.getAudiences() || []; + + // Track the audience for recording an event later. + let audience = 0; + + // Find a matching template. + for ( let index = 0; index < audiences.length; index++ ) { + // Find the first matching audience template. + const template = document.querySelector( `template[data-audience="${ audiences[ index ] }"][data-parent-id="${ this.clientId }"]` ); + if ( ! template ) { + continue; + } + + // We have a matching template, update audience and fallback value. + audience = audiences[ index ]; + + // Populate experience block content. + const experience = template.content.cloneNode( true ); + this.innerHTML = ''; + this.appendChild( experience ); + break; + } + + // Set fallback content if needed. + if ( ! audience ) { + const template = document.querySelector( `template[data-fallback][data-parent-id="${ this.clientId }"]` ); + if ( ! template ) { + return; + } + const experience = template.content.cloneNode( true ); + this.innerHTML = ''; + this.appendChild( experience ); + } + + // Log an event for tracking views and audience. + window.Altis.Analytics.record( 'experienceView', { + attributes: { + type: 'personalization', + clientId: this.clientId, + audience: audience, + }, + } ); + } + +} // Expose ABTest methods. window.Altis.Analytics.Experiments = Object.assign( {}, window.Altis.Analytics.Experiments || {}, { registerGoal: Test.registerGoal, } ); + +// Define custom elements when analytics has loaded. +window.Altis.Analytics.onReady( () => { + window.customElements.define( 'ab-test', ABTest ); + window.customElements.define( 'personalization-block', PersonalizationBlock ); +} ); + +// Fire a ready event once userland API has been exported. +const readyEvent = new CustomEvent( 'altis.experiments.ready' ); +window.dispatchEvent( readyEvent ); diff --git a/src/features/titles/components/duration.js b/src/features/titles/components/duration.js index 598a1fc..822dc36 100644 --- a/src/features/titles/components/duration.js +++ b/src/features/titles/components/duration.js @@ -16,7 +16,7 @@ const Duration = props => { return function cleanup() { clearInterval( timer ); - } + }; } ); if ( duration <= 0 ) { @@ -25,7 +25,7 @@ const Duration = props => { return ( { getDurationString( duration ) } - ) + ); }; export default Duration; diff --git a/src/features/titles/components/field-date-range.js b/src/features/titles/components/field-date-range.js index 9ffb804..382a4c9 100644 --- a/src/features/titles/components/field-date-range.js +++ b/src/features/titles/components/field-date-range.js @@ -1,4 +1,3 @@ -/* global wp */ import React from 'react'; import { Notice } from '.'; @@ -29,7 +28,7 @@ const DateRange = props => { currentTime={ startDate.toISOString() } onChange={ time => { const newDate = new Date( time ); - onChangeStart( newDate < endDate ? newDate.getTime() : endTime - ( 24 * 60 * 60 * 1000 ) ) + onChangeStart( newDate < endDate ? newDate.getTime() : endTime - ( 24 * 60 * 60 * 1000 ) ); } } /> diff --git a/src/features/titles/components/field-title-text.js b/src/features/titles/components/field-title-text.js index b3d0ee7..6247ac0 100644 --- a/src/features/titles/components/field-title-text.js +++ b/src/features/titles/components/field-title-text.js @@ -1,4 +1,3 @@ -/* global wp */ import React, { Fragment } from 'react'; import styled from 'styled-components'; import { getLetter } from '../utils'; @@ -59,7 +58,7 @@ const removeTitle = ( titles, index ) => { const newTitles = [ ...titles ]; newTitles.splice( index, 1 ); return newTitles; -} +}; const TitleTextField = props => { const { @@ -132,7 +131,7 @@ const TitleTextField = props => { ) } - ) + ); } ) } { isEditable && allTitles.length < 26 && ( { const { value, onChange } = props; diff --git a/src/features/titles/components/index.js b/src/features/titles/components/index.js index 9bcfa1c..a87618c 100644 --- a/src/features/titles/components/index.js +++ b/src/features/titles/components/index.js @@ -1,4 +1,3 @@ -/* global wp */ import React from 'react'; import styled from 'styled-components'; export { default as Duration } from './duration'; diff --git a/src/features/titles/components/panel-row.js b/src/features/titles/components/panel-row.js index bb27663..1b840e0 100644 --- a/src/features/titles/components/panel-row.js +++ b/src/features/titles/components/panel-row.js @@ -1,4 +1,3 @@ -/* global wp */ import React from 'react'; import styled from 'styled-components'; diff --git a/src/features/titles/components/plugin-icon.js b/src/features/titles/components/plugin-icon.js index 0d99363..0890d5a 100644 --- a/src/features/titles/components/plugin-icon.js +++ b/src/features/titles/components/plugin-icon.js @@ -1,4 +1,3 @@ -/* global wp */ import React from 'react'; import withTestData from '../data/with-test-data'; import styled from 'styled-components'; diff --git a/src/features/titles/data/with-test-data.js b/src/features/titles/data/with-test-data.js index ede87e9..4b685c6 100644 --- a/src/features/titles/data/with-test-data.js +++ b/src/features/titles/data/with-test-data.js @@ -1,4 +1,3 @@ -/* global wp */ import deepmerge from 'deepmerge'; import { DEFAULT_TEST } from './shapes'; @@ -32,7 +31,7 @@ const dispatchHandler = ( dispatch, props ) => { } setState( { isSaving: false } ); - } + }; const updateTest = async ( test = {}, titles = false, save = false ) => { const data = { @@ -103,7 +102,7 @@ const withTestData = compose( titles: select( 'core/editor' ).getEditedPostAttribute( 'ab_test_titles' ) || [], }; } ), - withDispatch( dispatchHandler ) + withDispatch( dispatchHandler ), ); export default withTestData; diff --git a/src/features/titles/index.js b/src/features/titles/index.js index b83410f..21d33f5 100644 --- a/src/features/titles/index.js +++ b/src/features/titles/index.js @@ -1,4 +1,3 @@ -/* global wp */ import Plugin from './plugin'; import { PluginIcon } from './components'; diff --git a/src/features/titles/plugin.js b/src/features/titles/plugin.js index 9a3de35..44aa627 100644 --- a/src/features/titles/plugin.js +++ b/src/features/titles/plugin.js @@ -1,4 +1,3 @@ -/* global wp */ import React, { Fragment } from 'react'; import { Panel } from './components'; import { DEFAULT_TEST } from './data/shapes'; diff --git a/src/features/titles/results.js b/src/features/titles/results.js index 4a750f5..bbb0b0d 100644 --- a/src/features/titles/results.js +++ b/src/features/titles/results.js @@ -1,4 +1,3 @@ -/* global wp */ import React, { Fragment } from 'react'; import withTestData from './data/with-test-data'; import { @@ -84,7 +83,7 @@ export const Results = props => {
) } - ) + ); } ) } @@ -114,7 +113,7 @@ export const ResultsWithData = compose( } ); }, }; - } ) + } ), )( Results ); export default ResultsWithData; diff --git a/src/features/titles/settings.js b/src/features/titles/settings.js index 0f286d5..d7d32a0 100644 --- a/src/features/titles/settings.js +++ b/src/features/titles/settings.js @@ -1,4 +1,3 @@ -/* global wp */ import React, { Fragment } from 'react'; import { Button, diff --git a/src/features/titles/utils/index.js b/src/features/titles/utils/index.js index 6b6d38d..72072fc 100644 --- a/src/features/titles/utils/index.js +++ b/src/features/titles/utils/index.js @@ -1,5 +1,3 @@ -/* global moment */ - export const getLetter = index => ( 'abcdefghijklmnopqrstuvwxyz'.toUpperCase() )[ Math.max( 0, Math.min( index, 26 ) ) ]; export const getDateString = date => moment( date ).format( 'MMMM D, YYYY — HH:mm' ); diff --git a/webpack.config.js b/webpack.config.js index c225edd..913c0e8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,20 +3,27 @@ const mode = process.env.NODE_ENV || 'production'; const BundleAnalyzerPlugin = require( 'webpack-bundle-analyzer' ) .BundleAnalyzerPlugin; const EnvironmentPlugin = require( 'webpack' ).EnvironmentPlugin; +const DynamicPublicPathPlugin = require( 'dynamic-public-path-webpack-plugin' ); +const SriPlugin = require( 'webpack-subresource-integrity' ); +const ManifestPlugin = require( 'webpack-manifest-plugin' ); +const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ); const sharedConfig = { mode: mode, entry: { 'features/titles': path.resolve( __dirname, 'src/features/titles/index.js' ), + 'features/blocks/personalization': path.resolve( __dirname, 'inc/features/blocks/personalization/index.js' ), + 'features/blocks/personalization-variant': path.resolve( __dirname, 'inc/features/blocks/personalization-variant/index.js' ), experiments: path.resolve( __dirname, 'src/experiments.js' ), }, output: { path: path.resolve( __dirname, 'build' ), - filename: '[name].js', - chunkFilename: '[name].chunk.js', - publicPath: '.', + filename: '[name].[hash:8].js', + chunkFilename: 'chunk.[id].[chunkhash:8].js', + publicPath: '/', libraryTarget: 'this', - jsonpFunction: 'AltisABTestsJSONP', + jsonpFunction: 'AltisExperimentsJSONP', + crossOriginLoading: 'anonymous', }, module: { rules: [ @@ -47,11 +54,11 @@ const sharedConfig = { new EnvironmentPlugin( { SC_ATTR: 'altis-experiments', } ), + new ManifestPlugin( { + writeToFileEmit: true, + } ), + new CleanWebpackPlugin(), ], - devtool: - mode === 'production' - ? 'cheap-module-source-map' - : 'cheap-module-eval-source-map', externals: { 'Altis': 'Altis', 'wp': 'wp', @@ -61,6 +68,19 @@ const sharedConfig = { }, }; +if ( mode === 'production' ) { + sharedConfig.plugins.push( new DynamicPublicPathPlugin( { + externalGlobal: 'window.Altis.Analytics.Experiments.BuildURL', + chunkName: 'experiments', + } ) ); + sharedConfig.plugins.push( new SriPlugin( { + hashFuncNames: [ 'sha384' ], + enabled: true, + } ) ); +} else { + sharedConfig.devtool = 'cheap-module-eval-source-map'; +} + if ( process.env.ANALYSE_BUNDLE ) { // Add bundle analyser. sharedConfig.plugins.push( new BundleAnalyzerPlugin() );