Skip to content

WordPress Integration

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

This guide walks you through integrating the necessary files for your custom JavaScript plugin. 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.

Project setup

your-plugin/
├── dist/                  # Bundled output files (generated by Webpack)
├── src/                   # Source files for development
│   ├── wrapper.js         # React components
│   ├── index.js           # Entry point for Webpack
├── package.json           # Dependencies and scripts
├── webpack.config.js      # Webpack configuration

Install Dependencies

npm install

package.json

This setup configures aproject that uses React, Webpack for bundling, Babel for transpiling, and ESLint for enforcing code quality. It also integrates with Husky and lint-staged to run code quality checks before commits. It uses Sass for styling, and it manages assets like images or fonts using loaders. The build process is set up for both development and production environments.

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"
            ]
        }
    }

webpack.config.js

This Webpack configuration is set up for a React application with development and production optimizations. It uses Babel for JavaScript and JSX transpilation, handles assets like images and SVGs, processes styles with Sass, and minifies the JavaScript output in production using Terser. It also generates HTML files using HtmlWebpackPlugin and has a custom public path for deployment. The configuration is designed to ensure compatibility with older browsers (e.g., IE11) and provides a smooth development experience with Webpack’s dev server.

Click to expand webpack.config.js
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,
    entry: './src/index.js',
    devtool: mode === 'development' ? 'inline-source-map' : false,
    performance: {
        hints: mode === 'development' ? false : 'error',
        maxEntrypointSize: 512000,
        maxAssetSize: 512000,
    },
    plugins: [
        // Used for widget loader
        new HtmlWebpackPlugin({
            templateParameters: (compilation, assets, assetTags, options) => {
                return {
                    compilation,
                    webpackConfig: compilation.options,
                    htmlWebpackPlugin: {
                        tags: assetTags,
                        files: assets,
                        options,
                    },
                    fileName: assets.js[0],
                    mode: mode,
                };
            },
            template: './src/index.html',
            inject: false, // Don't auto inject, we custom inject with the template
        }),
        // SSI used inside WCMS for maps
        new HtmlWebpackPlugin({
            filename: 'ssi.html',
            template: './src/ssi.html',
            publicPath: '/TemplatePackage/contrib/widgets/openVizWrapper/dist/',
            inject: 'body',
        }),
    ],
    resolve: {
        alias: {
            react: path.resolve('./node_modules/react'), // https://github.com/facebook/react/issues/13991 - Needed or else you won't be able to link a package locally
        },
    },
    stats: 'normal',
    output: {
        path: path.resolve(__dirname, './dist'),
        publicPath: mode === 'development' ? '/' : '/TemplatePackage/contrib/widgets/openVizWrapper/dist/', // Needed for IE11
        filename: '[name].js',
        environment: {
            arrowFunction: false,
            bigIntLiteral: false,
            const: false,
            destructuring: false,
            dynamicImport: false,
            forOf: false,
            module: false,
        },
        clean: true,
    },
    devServer: {
        open: true,
        overlay: {
            warnings: false,
            errors: true,
        },
    },
    module: {
        rules: [
            {
                test: /\.(png|jp(e*)g|gif)$/,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            name: 'images/[name].[ext]',
                        },
                    },
                ],
            },
            {
                exclude: [
                    /node_modules/,
                    // Due to symlinking, we have to explicitly exclude these files or Babel will try to parse them.
                    /cdcchart.js/,
                    /cdcmap.js/,
                    /cdceditor.js/,
                    /cdcdashboard.js/,
                    /cdcdatabite.js/,
                    /cdcwafflechart.js/,
                    /cdcmarkupinclude.js/,
                ],
                test: /\.m?js$/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            [
                                '@babel/preset-env',
                                {
                                    useBuiltIns: 'usage',
                                    corejs: '3.8',
                                    targets: {
                                        browsers: ['IE 11'],
                                    },
                                },
                            ],
                            '@babel/preset-react',
                        ],
                    },
                },
            },
            {
                test: /\.(sa|sc|c)ss$/i,
                use: [
                    // Creates `style` nodes from JS strings
                    'style-loader',
                    // Translates CSS into CommonJS
                    'css-loader',
                    // Compiles Sass to CSS
                    'sass-loader',
                ],
            },
            {
                test: /\.svg$/i,
                use: [
                    {
                        loader: 'url-loader',
                        options: {
                            generator: (content) => svgToMiniDataURI(content.toString()),
                        },
                    },
                ],
            },
        ],
    },
    optimization: {
        minimizer: [
            new TerserPlugin({
                extractComments: false,
            }),
        ],
    },
});

index.js

This script dynamically loads a React-based widget inside specified containers on a web page. It allows for flexibility in how the widget is configured by passing parameters via HTML data attributes or URL query parameters. It also ensures compatibility with both development and production environments and integrates with the CDC’s content management system. The configuration is loaded from a URL or directly passed as a JSON object, and the widget is rendered using React.

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();
    	});
    }

wrapper.js

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

Listener

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;


});
Clone this wiki locally