Skip to content

WordPress Integration

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

These docs attempt to share how to create the editor in WordPress.

package.json

Click to expand package.json
    {
        "private": true,
        "scripts": {
            "start": "webpack serve --mode development",
            "build": "npx webpack --mode production; cd ../../../; gulp contrib; cd contrib/widgets/openVizWrapper"
        },
        "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

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

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