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
// 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
            }),
        ],
    },
});

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