-
Notifications
You must be signed in to change notification settings - Fork 26
WordPress Integration
This guide walks you through integrating the necessary files for your custom JavaScript plugin. The plugin includes React components, uses Webpack for bundling, and listens for events to update the page. Follow these steps to set up everything correctly.
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; 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"
]
}
}
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,
}),
],
},
});
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();
});
}
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;
});