Skip to content

WordPress Integration

Adam Doe edited this page Dec 4, 2024 · 41 revisions

This guide walks you through adding the COVE editor into your WordPress instance. The plugin includes importing the COVE react components, uses Webpack for bundling, and listens for events to update the page. Follow these steps to set up everything correctly.

Step 1. Setup Project Structure using the files below

cove-plugin/
├── dist/                    # Bundled output files (generated by Webpack)
├── assets/                  
│   └── scripts/             # Directory for your scripts
│       └── listener.js      # Event Listeners
├── src/                     # Source files for development
│   ├── index.html           # HTML template
│   ├── index.js             # Entry point for React and Webpack
│   ├── wrapper.js           # React component (wrapper)
├── package.json             # Dependencies and scripts
├── webpack.config.js        # Webpack configuration
├── your-plugin.php          # Main PHP file for the plugin
Click to expand package.json
    {
        "private": true,
        "scripts": {
            "start": "webpack serve --mode development",
            "build": "npx webpack --mode production"
        },
        "devDependencies": {
            "@babel/core": "^7.6.4",
            "@babel/preset-env": "^7.6.3",
            "@babel/preset-react": "^7.6.3",
            "babel-loader": "^8.0.6",
            "core-js": "^3.8.3",
            "css-loader": "^6.8.1",
            "eslint": "^7.16.0",
            "eslint-config-airbnb-typescript": "12.0.0",
            "eslint-config-react-app": "^6.0.0",
            "eslint-plugin-flowtype": "^5.2.0",
            "eslint-plugin-import": "^2.22.1",
            "eslint-plugin-jsx-a11y": "^6.4.1",
            "eslint-plugin-react": "^7.21.5",
            "eslint-plugin-react-hooks": "^4.2.0",
            "file-loader": "^6.1.0",
            "html-webpack-plugin": "^5.3.1",
            "husky": "^8.0.3",
            "lint-staged": "^13.2.1",
            "mini-svg-data-uri": "^1.2.3",
            "papaparse": "^5.3.0",
            "react": "^18.2.0",
            "react-dom": "^18.2.0",
            "@types/react": "^18.0.26",
            "@types/react-dom": "^18.0.9",
            "sass": "^1.32.8",
            "sass-loader": "^11.0.1",
            "style-loader": "^1.3.0",
            "terser-webpack-plugin": "^5.1.1",
            "url-loader": "^4.1.1",
            "webpack": "^5.94.0",
            "webpack-cli": "^4.6.0",
            "webpack-dev-server": "^5.0.4",
            "whatwg-fetch": "^3.6.2"
        },
        "husky": {
            "hooks": {
                "pre-commit": "lint-staged"
            }
        },
        "lint-staged": {
            "./packages/*/src/**/*.{js,jsx,ts,tsx}": [
                "eslint --config .eslintrc.js"
            ]
        }
    }
Click to expand webpack.config.js
// Required imports
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const svgToMiniDataURI = require('mini-svg-data-uri');

module.exports = (env, { mode }) => ({
    // Mode configuration: 'development' or 'production'
    mode,  // Set the mode to either 'development' or 'production'
    
    // Entry point for bundling
    entry: './src/index.js',  // Main entry file for your JavaScript code

    // Source map configuration for debugging
    devtool: mode === 'development' ? 'inline-source-map' : false, // Inline source maps in development for easier debugging
    
    // Performance settings to avoid oversized bundles
    performance: {
        hints: mode === 'development' ? false : 'error',  // Disable performance hints in development, show errors in production
        maxEntrypointSize: 512000,  // Max size for entry points (500 KB)
        maxAssetSize: 512000,  // Max size for assets (500 KB)
    },

    // Plugins section
    plugins: [
        // Used for generating the main HTML file with injected script tags
        new HtmlWebpackPlugin({
            // Custom template parameters for the HTML generation
            templateParameters: (compilation, assets, assetTags, options) => {
                return {
                    compilation,
                    webpackConfig: compilation.options,
                    htmlWebpackPlugin: {
                        tags: assetTags,
                        files: assets,
                        options,
                    },
                    fileName: assets.js[0],  // The first JavaScript file in the assets
                    mode: mode,  // Passes the mode (development or production)
                };
            },
            template: './src/index.html',  // Path to the HTML template file
            inject: false,  // We handle the injection manually in the template
        })
    ],

    // Module resolution configuration
    resolve: {
        alias: {
            // Resolves React to avoid version conflicts when linking a package locally
            react: path.resolve('./node_modules/react'),
        },
    },

    // Stats configuration to control Webpack output
    stats: 'normal',  // Normal logging for Webpack output

    // Output configuration for bundled files
    output: {
        path: path.resolve(__dirname, './dist'),  // Path for the bundled files
        publicPath: mode === 'development' ? '/' : '/TemplatePackage/contrib/widgets/openVizWrapper/dist/',  // Public path for assets (root for dev, specific path for production)
        filename: '[name].js',  // Use entry point name for the output file name
        environment: {
            arrowFunction: false,  // Avoid using arrow functions for IE11 compatibility
            bigIntLiteral: false,  // Disable BigInt literals
            const: false,  // Use `var` instead of `const` for older browser support
            destructuring: false,  // Avoid destructuring assignment for compatibility
            dynamicImport: false,  // Disable dynamic imports
            forOf: false,  // Disable `for...of` loops for IE11 compatibility
            module: false,  // Avoid using ES modules for IE11 support
        },
        clean: true,  // Clean the output directory before each build
    },

    // Development server configuration
    devServer: {
        open: true,  // Opens the default browser when the server starts
        overlay: {
            warnings: false,  // Hide warnings in the overlay
            errors: true,  // Show errors in the overlay
        },
    },

    // Module rules for handling different file types
    module: {
        rules: [
            // Rule for image files (PNG, JPG, GIF)
            {
                test: /\.(png|jp(e*)g|gif)$/,
                use: [
                    {
                        loader: 'url-loader',  // Converts images to base64 URLs
                        options: {
                            name: 'images/[name].[ext]',  // Output image file names
                        },
                    },
                ],
            },

            // Rule for JavaScript files (Babel transpiling)
            {
                exclude: [
                    /node_modules/,  // Exclude node_modules folder
                    // Explicitly exclude certain files due to symlink issues
                    /cdcchart.js/,
                    /cdcmap.js/,
                    /cdceditor.js/,
                    /cdcdashboard.js/,
                    /cdcdatabite.js/,
                    /cdcwafflechart.js/,
                    /cdcmarkupinclude.js/,
                ],
                test: /\.m?js$/,  // Test for JavaScript and ES6+ files
                use: {
                    loader: 'babel-loader',  // Transpiles JavaScript using Babel
                    options: {
                        presets: [
                            [
                                '@babel/preset-env',  // Transpiles modern JS features for older browsers
                                {
                                    useBuiltIns: 'usage',  // Only include polyfills that are used
                                    corejs: '3.8',  // Use core-js version 3.8 for polyfilling
                                    targets: {
                                        browsers: ['IE 11'],  // Target IE11 for compatibility
                                    },
                                },
                            ],
                            '@babel/preset-react',  // Transpiles React JSX code
                        ],
                    },
                },
            },

            // Rule for handling CSS/SCSS files
            {
                test: /\.(sa|sc|c)ss$/i,
                use: [
                    'style-loader',  // Injects styles into the DOM via <style> tags
                    'css-loader',  // Resolves CSS files into JavaScript
                    'sass-loader',  // Compiles Sass to CSS
                ],
            },

            // Rule for handling SVG files
            {
                test: /\.svg$/i,
                use: [
                    {
                        loader: 'url-loader',  // Converts SVGs to data URIs
                        options: {
                            generator: (content) => svgToMiniDataURI(content.toString()),  // Minifies SVGs and converts them to data URIs
                        },
                    },
                ],
            },
        ],
    },

    // Optimization settings for minimizing output
    optimization: {
        minimizer: [
            new TerserPlugin({
                extractComments: false,  // Do not extract comments into separate files
            }),
        ],
    },
});
Click to expand index.js
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React, { StrictMode } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';

import Wrapper from './Wrapper';


const loadViz = () => {
    const vizContainers = Array.from(document.querySelectorAll('.wcms-viz-container'));

    vizContainers.forEach((container) => {
        // Remove existing if there is one so you can start fresh
        unmountComponentAtNode(container);

        // Grab data attributes from the container we're going to be rendering inside of and set defaults.
        let {
            configUrl: relativePath = null,
            host: hostName = null,
            standalone = false,
            language = 'en',
            config = null,
            editor: isEditor = false,
        } = container.dataset;
        let constructedURL = null;
        let sharePath = container.getAttribute('data-sharepath');

        //If we are not in the context of syndication, use the current host, not the data-host attribute value
        if (!document.body.classList.contains('syndicated-content')) {
            hostName = location.host;
        }

        // Transform values to type boolean
        standalone = standalone === 'true';

        // Only allow URL properties if we're running this in standalone mode (widget loader or development environment.)
        if (true === standalone) {
            const params = new URLSearchParams(window.location.search);

            // Set Editor Flag
            if ('true' === params.get('editor')) {
                isEditor = true;
            }

            let queryStringRelativePath = params.get('configUrl');
            let queryStringHostName = params.get('host');
            let queryStringSharePath = params.get('sharePath');

            let queryStringConfigURL = `https://` + queryStringHostName + queryStringRelativePath;

            // Config file load method: URL parameter
            if (queryStringHostName && queryStringRelativePath) {
                const configURLObject = new URL(queryStringConfigURL);

                // We can load URLs this way from either cdc.gov or localhost for local development.
                if (true === configURLObject.hostname.endsWith('cdc.gov') || 'localhost' === configURLObject.hostname) {
                    constructedURL = queryStringConfigURL;
                } else {
                    const errorMsg = new Error(
                        'Invalid JSON file provided to URL query. Must be from cdc.gov or localhost.'
                    );
                    throw errorMsg;
                }
            }
        }

        // If we received a config instead of the URL
        if ('string' === typeof config) {
            config = JSON.parse(config);
        }

        if (null === config && null !== relativePath) {
            constructedURL = `https://` + hostName + relativePath;

            try {
                const configURLObject = new URL(constructedURL);

                configURLObject.protocol = window.location.protocol;
                constructedURL = configURLObject.toString();
            } catch (err) {
                new Error(err);
            }
        }

        if (constructedURL && window.hasOwnProperty('CDC') && standalone) {
            initMetrics(constructedURL);
        }

        render(
            <StrictMode>
                <Wrapper
                    language={language}
                    configURL={constructedURL}
                    config={config}
                    isEditor={isEditor}
                    sharePath={sharePath}
                />
            </StrictMode>,
            container
        );
    });
};

// Assign to CDC object for external use
window.CDC_Load_Viz = loadViz;
// Call on load
if (document.readyState !== 'loading') {
    loadViz();
} else {
    document.addEventListener('DOMContentLoaded', function () {
        loadViz();
    });
}
Click to expand wrapper.s
import React, { useEffect, useState, useCallback, Suspense } from 'react'
import 'whatwg-fetch'


import './styles.scss'

import cdcLogo from './cdc-hhs.svg'

const CdcMap = React.lazy(() => import('@cdc/map'));
const CdcChart = React.lazy(() => import('@cdc/chart'));
const CdcEditor = React.lazy(() => import('@cdc/editor'));
const CdcDashboard = React.lazy(() => import('@cdc/dashboard'));
const CdcDataBite = React.lazy(() => import('@cdc/data-bite'));
const CdcWaffleChart = React.lazy(() => import('@cdc/waffle-chart'));
const CdcMarkupInclude = React.lazy(() => import('@cdc/markup-include'));

const Loading = ({viewport = "lg"}) => {
  return (
        <section className="loading">
            <div className={`la-ball-beat la-dark ${viewport}`}>
                <div />
                <div />
                <div />
            </div>
        </section>
    )
}

const Wrapper = ({configURL, language, config: configObj, isEditor, hostname, sharePath}) => {
    const [ config, setConfig ] = useState(configObj)

    const [ type, setType ] = useState(null)

    const metricsCall = useCallback((type, url) => {
        const s = window.s || {}

        if(true === s.hasOwnProperty('tl')) {
            let newObj = {...s}


            newObj.pageURL = window.location.href
            newObj.linkTrackVars = "pageURL";
            newObj.linkURL = url // URL We are navigating to

            s.tl( true, type, null, newObj )
        }
    })

    const iframeCheck = () => {
        try {
            return window.self !== window.top;
        } catch (e) {
            return true;
        }
    }

    const navigationHandler = useCallback((urlString = '') => {
        // Abort if value is blank
        if(0 === urlString.length) {
            throw Error("Blank string passed as URL. Navigation aborted.");
        }

        // Make sure this isn't loading through an iFrame.
        const inIframe = iframeCheck();

        // Determine if link is a relative hash link
        const isHashLink = urlString.startsWith('#');

        // Smooth scrolling for hash links on the same page as the map
        if(true === isHashLink && false === inIframe) {
            let hashName = urlString.substr(1);
            let scrollSection = window.document.querySelector(`*[id="${hashName}"]`) || window.document.querySelector(`*[name="${hashName}"]`)

            if(scrollSection) {
                scrollSection.scrollIntoView({
                    behavior: 'smooth'
                })

                return true;
            } else {
                throw Error("Internal hash link detected but unable to find element on page. Navigation aborted.");
            }
        }

        // Metrics Call
        const extension = urlString.substring( urlString.lastIndexOf( '.' ) + 1 )

        const s = window.s || {}

        let metricsParam = 'e';

        if ( s.hasOwnProperty('linkDownloadFileTypes') && s.linkDownloadFileTypes.includes(extension) ) {
            metricsParam = 'd'; // Different parameter for downloads
        }

        let urlObj;

        // If we're not loading through iframe (ex: widget loader)
        if(false === inIframe) {
            // Insert proper base for relative URLs
            const parentUrlObj = new URL(window.location.href);

            // Only insert a dynamic base if this is on a CDC.gov page, regardless of environment.
            // This prevents security concerns where a party could embed a CDC visualization on their own site and have the relative URLs go to their own content making it look like its endorsed by the CDC.
            let urlBase = parentUrlObj.host.endsWith('cdc.gov') ? parentUrlObj.origin : 'https://www.cdc.gov/';

            urlObj = new URL(urlString, urlBase);
        } else {
            urlObj = new URL(urlString);
        }

        // Set the string to the newly constructed string.
        urlString = urlObj.toString();

        // Don't make a metrics call if it's a link to cdc.gov and does not have a download extension (ex. pdf) or if we're inside the editor.
        if( false === ( 'e' === metricsParam && urlString.includes('cdc.gov') ) && false === isEditor ) {
            metricsCall(metricsParam, urlString);
        }

        // Open constructed link in new tab/window
        window.open(urlString, '_blank');
    })

    useEffect(() => {
        if(null === configURL) {
            console.warn('No configuration URL detected.');
            return;
        }

        const grabConfigObj = async () => {

            try {
                const response = await fetch(configURL);

                const data = await response.json();

                let tempConfigObj = {language, ...data}

                setConfig(tempConfigObj);
                setLoading(false);

            } catch (err) {
                new Error(err)
            }
        };
        grabConfigObj();
    }, [configURL]);

    useEffect(() => {
        if(config && config.hasOwnProperty('type')) {
            setType(config.type)
        }
    }, [config])

    // WCMS Admin
    if(isEditor && config) {
        // This either passes an existing config or starts with a blank editor
        return (
            <Suspense fallback={<Loading />}>
                <CdcEditor config={config} hostname={hostname} sharepath={sharePath} />
            </Suspense>)
    }

    // Standalone mode when you run `npm run start` just so it isn't blank
    if(!config && !configURL) {
        return (<Suspense fallback={<Loading />}>
            <CdcEditor hostname={hostname} sharepath={sharePath} />
               </Suspense>)
    }

    switch (type) {
        case 'map':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcMap config={config} hostname={hostname} navigationHandler={navigationHandler} logo={cdcLogo} />
                </Suspense>
            )
        case 'chart':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcChart config={config} hostname={hostname} />
                </Suspense>
            )
        case 'dashboard':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcDashboard config={config} hostname={hostname} />
                </Suspense>
            )
        case 'data-bite':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcDataBite config={config} hostname={hostname} />
                </Suspense>
            )
        case 'waffle-chart':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcWaffleChart config={config} hostname={hostname} />
                </Suspense>
            )
        case 'markup-include':
            return (
                <Suspense fallback={<Loading />}>
                    <CdcMarkupInclude config={config} hostname={hostname} />
                </Suspense>
            )
        default:
            return <Loading />
    }
}

export default Wrapper
Click to expand listener.js
jQuery( document ).ready( function( $ ) {
    var $temporaryInput = $(".cdc-viz-editor-hidden-temp-input");
    var $persistedInput = $(".cdc-viz-editor-hidden-input");
    var $vizContainer = $(".wcms-viz-container");

    // Store the data in the temporary input every time the viz sends an event from it's editor.
    window.addEventListener('updateVizConfig', function(e) {
        $temporaryInput.val(e.detail);
        updateDetailsOnScreen( e.detail );
    }, false)

    function updateDetailsOnScreen( config ) {

      //Get config object
      var configObject;
      if ( undefined !== config ) {
        configObject = JSON.parse( config );
      } else if ( undefined !== vizVariables.config && vizVariables.config.length > 0 ) {
        configObject = JSON.parse( vizVariables.config );
      } else { //New viz
        return false;
      }

      const getPropHtml = (pathArr, title, defaultVal = 'Not Set', alwaysShow = true) => {
        let val = getConfigProp( pathArr )

        if ( ( undefined === val ) && alwaysShow ) {
          val = defaultVal;
        }

        return undefined !== val ? getHtml( title, val) : '';
      }

      const friendlies = {
        'us': 'U.S.',
        'world': 'World',
        'chart': 'Chart',
        'equalnumber': 'Equal Number',
        'equalinterval': 'Equal Interval',
        'category': 'Categorical',
        'data': 'Data',
        'navigation': 'Navigation',
        'map': 'Map'
      }

      const getHtml = (title,val) => {
        if ( friendlies.hasOwnProperty( val ) ) {
          val = friendlies[val];
        }
        return '<div>' + title + ':</div><div>' + val + '</div>';
      }

      const getConfigProp = (pathArr) => {
        return pathArr.reduce((configObject, key) =>
          (configObject && configObject[key] !== 'undefined') ? configObject[key] : undefined, configObject);
      }

      let summaryInfo = '';
      const vizType = configObject.type;

      if ( 'chart' === vizType ) { //Handle Charts
        summaryInfo += getPropHtml( ['title'],  'Title', 'Not Set' );
        summaryInfo += getPropHtml( ['type'],  'Type', 'Not Set' );
        summaryInfo += getPropHtml( ['visualizationType'],  'Sub Type', 'Not Set' );

        if ( configObject.hasOwnProperty('series') && Array.isArray( configObject.series ) && configObject.series.length ) {
          let dataSeries = configObject.series.map(a => a.dataKey);
          summaryInfo += getHtml( 'Data Series', dataSeries.join(', ') );
        }

        summaryInfo += getHtml('Number of Rows', configObject.data.length);
      } else if ( 'map' === vizType ) { //Handle Maps
        summaryInfo += getPropHtml( ['general', 'title'],  'Title', 'Not Set' );
        summaryInfo += getPropHtml( ['type'],  'Type', 'Not Set' );
        summaryInfo += getPropHtml( ['general', 'type'],  'Sub Type', 'Not Set' );
        summaryInfo += getPropHtml( ['general', 'geoType'],  'Geo Type', 'Not Set' );

        var displayAsHex = getConfigProp( ['general', 'displayAsHex'] );
        if ( displayAsHex ) {
          summaryInfo += getHtml('Is Hex Tile', 'Yes');
        }

        summaryInfo += getHtml('Number of Rows', configObject.data.length);
        summaryInfo += getPropHtml( ['legend', 'type'],  'Classification Type', 'Not Set' );
        summaryInfo += getPropHtml( ['legend', 'numberOfItems'],  'Number of Classes (Legend Items)', 'Not Set', true );
      }

      $('.viz-details').html( summaryInfo );
    }

    function saveVizData() {
        // Apply the temporary value
        window.SAVEVIZ = true
        $persistedInput.val( $temporaryInput.val() );
        $vizContainer.attr('data-config', $persistedInput.val());
        $('body').css('overflow', 'auto')
    }

    function cancelVizEdit() {
        // This is so this function can work every time the modal closes - ESC button or clicking outside the modal.
        if(window.SAVEVIZ) {
            return;
        }
        // Blank out temporary value
        $temporaryInput.val('');
        $('body').css('overflow', 'auto');

        var dataVal = $persistedInput.val()

        if(dataVal) {
          // Revert to persisted configuration behind the scenes
          $vizContainer.attr('data-config', dataVal);

          window.CDC_Load_Viz();
        }
    }

    var vizModal = CDC_Modal.create({
        title: 'Visualization Editor',
		// data-viz uses the older modal center method 'modal-transform'
        classNames: ['cdc-cove-editor modal-transform'],
        closed: true,
	      fullbody: true,
        fullscreen: true,
        onOpen: function() {
            $('body').css('overflow', 'hidden'); // Disable scrolling
            window.SAVEVIZ = false;
        },
        onClose: cancelVizEdit,
        message: $vizContainer,
        buttons: [
            {
                text: 'OK',
                click: saveVizData,
                className: 'button-primary'
            },
            {
                text: 'Cancel',
                className: 'button-secondary'
            }
        ]
    })

    $( '.open-viz-editor-anchor').on( 'click', function( e ) {
        e.preventDefault();
        vizModal.open();
    } );

    updateDetailsOnScreen();

    return false;


});
Click to view index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <title></title>
    <style type="text/css">
      body {
        margin: 0;
      }
      .wcms-viz-container {
        min-height: 100vh;    
      }
      .cdc-viz-inner-container {
        min-height: 100vh;
      }
    </style>
  </head>
  <body>
    <div class="wcms-viz-container" data-standalone="true"></div>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <% if (mode === "development") { %>
      <script src="<%= fileName %>"></script>
    <% } %>
  </body>
</html>

Step 2. Run npm install

npm install

Step 3. Test running webpack and debug any issues

npm run start

Step 4. Update enqueue scripts for WP

<?php
/**
 * Plugin Name: COVE Editor Plugin
 * Plugin URI: 
 * Description: A custom plugin that integrates Cove's React Editor with WordPress.
 * Version: 1.0.0
 * Author: COVE
 * Author URI: https://github.com/CDCgov/cdc-open-viz/
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// Enqueue scripts and styles
function your_plugin_enqueue_assets() {
    // Register the bundled JavaScript file generated by Webpack
    wp_enqueue_script(
        'cove-plugin-js', 
        plugins_url( 'dist/main.js', __FILE__ ), 
        array(), 
        null, 
        true // Load in footer
    );

    wp_enqueue_script(
        'cove-listener-js', 
        plugins_url( 'assets/scripts/listener.js', __FILE__ ), 
        array(), 
        null, 
        true // Load in footer
    );
}

// Hook to enqueue assets
add_action( 'wp_enqueue_scripts', 'your_plugin_enqueue_assets' );

Step 5. Create a new content type

You'll want to create a new content type that has a div for <div class="wcms-viz-container"></div> on the edit screen. This will be where React renders the editor. You'll also want two hidden inputs on the page with the class names .cdc-viz-editor-hidden-temp-input and .cdc-viz-editor-hidden-input to save the JSON from the editor into.

Step 6. Handle Saving the Data

Save the data from the hidden input to your database.