-
Notifications
You must be signed in to change notification settings - Fork 20
WordPress Integration
Adam Doe edited this page Dec 4, 2024
·
41 revisions
This guide walks you through adding the COVE editor into your WordPress instance. The plugin includes importing the COVE react components, uses Webpack for bundling, and listens for events to update the page. Follow these steps to set up everything correctly.
cove-plugin/
├── dist/ # Bundled output files (generated by Webpack)
├── assets/
│ └── scripts/ # Directory for your scripts
│ └── listener.js # Event Listeners
├── src/ # Source files for development
│ ├── index.html # HTML template
│ ├── index.js # Entry point for React and Webpack
│ ├── wrapper.js # React component (wrapper)
├── package.json # Dependencies and scripts
├── webpack.config.js # Webpack configuration
├── your-plugin.php # Main PHP file for the plugin
Click to expand package.json
{
"private": true,
"scripts": {
"start": "webpack serve --mode development",
"build": "npx webpack --mode production"
},
"devDependencies": {
"@babel/core": "^7.6.4",
"@babel/preset-env": "^7.6.3",
"@babel/preset-react": "^7.6.3",
"babel-loader": "^8.0.6",
"core-js": "^3.8.3",
"css-loader": "^6.8.1",
"eslint": "^7.16.0",
"eslint-config-airbnb-typescript": "12.0.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-flowtype": "^5.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.2.0",
"file-loader": "^6.1.0",
"html-webpack-plugin": "^5.3.1",
"husky": "^8.0.3",
"lint-staged": "^13.2.1",
"mini-svg-data-uri": "^1.2.3",
"papaparse": "^5.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"sass": "^1.32.8",
"sass-loader": "^11.0.1",
"style-loader": "^1.3.0",
"terser-webpack-plugin": "^5.1.1",
"url-loader": "^4.1.1",
"webpack": "^5.94.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^5.0.4",
"whatwg-fetch": "^3.6.2"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"./packages/*/src/**/*.{js,jsx,ts,tsx}": [
"eslint --config .eslintrc.js"
]
}
}
Click to expand webpack.config.js
// Required imports
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const svgToMiniDataURI = require('mini-svg-data-uri');
module.exports = (env, { mode }) => ({
// Mode configuration: 'development' or 'production'
mode, // Set the mode to either 'development' or 'production'
// Entry point for bundling
entry: './src/index.js', // Main entry file for your JavaScript code
// Source map configuration for debugging
devtool: mode === 'development' ? 'inline-source-map' : false, // Inline source maps in development for easier debugging
// Performance settings to avoid oversized bundles
performance: {
hints: mode === 'development' ? false : 'error', // Disable performance hints in development, show errors in production
maxEntrypointSize: 512000, // Max size for entry points (500 KB)
maxAssetSize: 512000, // Max size for assets (500 KB)
},
// Plugins section
plugins: [
// Used for generating the main HTML file with injected script tags
new HtmlWebpackPlugin({
// Custom template parameters for the HTML generation
templateParameters: (compilation, assets, assetTags, options) => {
return {
compilation,
webpackConfig: compilation.options,
htmlWebpackPlugin: {
tags: assetTags,
files: assets,
options,
},
fileName: assets.js[0], // The first JavaScript file in the assets
mode: mode, // Passes the mode (development or production)
};
},
template: './src/index.html', // Path to the HTML template file
inject: false, // We handle the injection manually in the template
})
],
// Module resolution configuration
resolve: {
alias: {
// Resolves React to avoid version conflicts when linking a package locally
react: path.resolve('./node_modules/react'),
},
},
// Stats configuration to control Webpack output
stats: 'normal', // Normal logging for Webpack output
// Output configuration for bundled files
output: {
path: path.resolve(__dirname, './dist'), // Path for the bundled files
publicPath: mode === 'development' ? '/' : '/TemplatePackage/contrib/widgets/openVizWrapper/dist/', // Public path for assets (root for dev, specific path for production)
filename: '[name].js', // Use entry point name for the output file name
environment: {
arrowFunction: false, // Avoid using arrow functions for IE11 compatibility
bigIntLiteral: false, // Disable BigInt literals
const: false, // Use `var` instead of `const` for older browser support
destructuring: false, // Avoid destructuring assignment for compatibility
dynamicImport: false, // Disable dynamic imports
forOf: false, // Disable `for...of` loops for IE11 compatibility
module: false, // Avoid using ES modules for IE11 support
},
clean: true, // Clean the output directory before each build
},
// Development server configuration
devServer: {
open: true, // Opens the default browser when the server starts
overlay: {
warnings: false, // Hide warnings in the overlay
errors: true, // Show errors in the overlay
},
},
// Module rules for handling different file types
module: {
rules: [
// Rule for image files (PNG, JPG, GIF)
{
test: /\.(png|jp(e*)g|gif)$/,
use: [
{
loader: 'url-loader', // Converts images to base64 URLs
options: {
name: 'images/[name].[ext]', // Output image file names
},
},
],
},
// Rule for JavaScript files (Babel transpiling)
{
exclude: [
/node_modules/, // Exclude node_modules folder
// Explicitly exclude certain files due to symlink issues
/cdcchart.js/,
/cdcmap.js/,
/cdceditor.js/,
/cdcdashboard.js/,
/cdcdatabite.js/,
/cdcwafflechart.js/,
/cdcmarkupinclude.js/,
],
test: /\.m?js$/, // Test for JavaScript and ES6+ files
use: {
loader: 'babel-loader', // Transpiles JavaScript using Babel
options: {
presets: [
[
'@babel/preset-env', // Transpiles modern JS features for older browsers
{
useBuiltIns: 'usage', // Only include polyfills that are used
corejs: '3.8', // Use core-js version 3.8 for polyfilling
targets: {
browsers: ['IE 11'], // Target IE11 for compatibility
},
},
],
'@babel/preset-react', // Transpiles React JSX code
],
},
},
},
// Rule for handling CSS/SCSS files
{
test: /\.(sa|sc|c)ss$/i,
use: [
'style-loader', // Injects styles into the DOM via <style> tags
'css-loader', // Resolves CSS files into JavaScript
'sass-loader', // Compiles Sass to CSS
],
},
// Rule for handling SVG files
{
test: /\.svg$/i,
use: [
{
loader: 'url-loader', // Converts SVGs to data URIs
options: {
generator: (content) => svgToMiniDataURI(content.toString()), // Minifies SVGs and converts them to data URIs
},
},
],
},
],
},
// Optimization settings for minimizing output
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false, // Do not extract comments into separate files
}),
],
},
});
Click to expand index.js
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React, { StrictMode } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import Wrapper from './Wrapper';
const loadViz = () => {
const vizContainers = Array.from(document.querySelectorAll('.wcms-viz-container'));
vizContainers.forEach((container) => {
// Remove existing if there is one so you can start fresh
unmountComponentAtNode(container);
// Grab data attributes from the container we're going to be rendering inside of and set defaults.
let {
configUrl: relativePath = null,
host: hostName = null,
standalone = false,
language = 'en',
config = null,
editor: isEditor = false,
} = container.dataset;
let constructedURL = null;
let sharePath = container.getAttribute('data-sharepath');
//If we are not in the context of syndication, use the current host, not the data-host attribute value
if (!document.body.classList.contains('syndicated-content')) {
hostName = location.host;
}
// Transform values to type boolean
standalone = standalone === 'true';
// Only allow URL properties if we're running this in standalone mode (widget loader or development environment.)
if (true === standalone) {
const params = new URLSearchParams(window.location.search);
// Set Editor Flag
if ('true' === params.get('editor')) {
isEditor = true;
}
let queryStringRelativePath = params.get('configUrl');
let queryStringHostName = params.get('host');
let queryStringSharePath = params.get('sharePath');
let queryStringConfigURL = `https://` + queryStringHostName + queryStringRelativePath;
// Config file load method: URL parameter
if (queryStringHostName && queryStringRelativePath) {
const configURLObject = new URL(queryStringConfigURL);
// We can load URLs this way from either cdc.gov or localhost for local development.
if (true === configURLObject.hostname.endsWith('cdc.gov') || 'localhost' === configURLObject.hostname) {
constructedURL = queryStringConfigURL;
} else {
const errorMsg = new Error(
'Invalid JSON file provided to URL query. Must be from cdc.gov or localhost.'
);
throw errorMsg;
}
}
}
// If we received a config instead of the URL
if ('string' === typeof config) {
config = JSON.parse(config);
}
if (null === config && null !== relativePath) {
constructedURL = `https://` + hostName + relativePath;
try {
const configURLObject = new URL(constructedURL);
configURLObject.protocol = window.location.protocol;
constructedURL = configURLObject.toString();
} catch (err) {
new Error(err);
}
}
if (constructedURL && window.hasOwnProperty('CDC') && standalone) {
initMetrics(constructedURL);
}
render(
<StrictMode>
<Wrapper
language={language}
configURL={constructedURL}
config={config}
isEditor={isEditor}
sharePath={sharePath}
/>
</StrictMode>,
container
);
});
};
// Assign to CDC object for external use
window.CDC_Load_Viz = loadViz;
// Call on load
if (document.readyState !== 'loading') {
loadViz();
} else {
document.addEventListener('DOMContentLoaded', function () {
loadViz();
});
}
Click to expand wrapper.s
import React, { useEffect, useState, useCallback, Suspense } from 'react'
import 'whatwg-fetch'
import './styles.scss'
import cdcLogo from './cdc-hhs.svg'
const CdcMap = React.lazy(() => import('@cdc/map'));
const CdcChart = React.lazy(() => import('@cdc/chart'));
const CdcEditor = React.lazy(() => import('@cdc/editor'));
const CdcDashboard = React.lazy(() => import('@cdc/dashboard'));
const CdcDataBite = React.lazy(() => import('@cdc/data-bite'));
const CdcWaffleChart = React.lazy(() => import('@cdc/waffle-chart'));
const CdcMarkupInclude = React.lazy(() => import('@cdc/markup-include'));
const Loading = ({viewport = "lg"}) => {
return (
<section className="loading">
<div className={`la-ball-beat la-dark ${viewport}`}>
<div />
<div />
<div />
</div>
</section>
)
}
const Wrapper = ({configURL, language, config: configObj, isEditor, hostname, sharePath}) => {
const [ config, setConfig ] = useState(configObj)
const [ type, setType ] = useState(null)
const metricsCall = useCallback((type, url) => {
const s = window.s || {}
if(true === s.hasOwnProperty('tl')) {
let newObj = {...s}
newObj.pageURL = window.location.href
newObj.linkTrackVars = "pageURL";
newObj.linkURL = url // URL We are navigating to
s.tl( true, type, null, newObj )
}
})
const iframeCheck = () => {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
const navigationHandler = useCallback((urlString = '') => {
// Abort if value is blank
if(0 === urlString.length) {
throw Error("Blank string passed as URL. Navigation aborted.");
}
// Make sure this isn't loading through an iFrame.
const inIframe = iframeCheck();
// Determine if link is a relative hash link
const isHashLink = urlString.startsWith('#');
// Smooth scrolling for hash links on the same page as the map
if(true === isHashLink && false === inIframe) {
let hashName = urlString.substr(1);
let scrollSection = window.document.querySelector(`*[id="${hashName}"]`) || window.document.querySelector(`*[name="${hashName}"]`)
if(scrollSection) {
scrollSection.scrollIntoView({
behavior: 'smooth'
})
return true;
} else {
throw Error("Internal hash link detected but unable to find element on page. Navigation aborted.");
}
}
// Metrics Call
const extension = urlString.substring( urlString.lastIndexOf( '.' ) + 1 )
const s = window.s || {}
let metricsParam = 'e';
if ( s.hasOwnProperty('linkDownloadFileTypes') && s.linkDownloadFileTypes.includes(extension) ) {
metricsParam = 'd'; // Different parameter for downloads
}
let urlObj;
// If we're not loading through iframe (ex: widget loader)
if(false === inIframe) {
// Insert proper base for relative URLs
const parentUrlObj = new URL(window.location.href);
// Only insert a dynamic base if this is on a CDC.gov page, regardless of environment.
// This prevents security concerns where a party could embed a CDC visualization on their own site and have the relative URLs go to their own content making it look like its endorsed by the CDC.
let urlBase = parentUrlObj.host.endsWith('cdc.gov') ? parentUrlObj.origin : 'https://www.cdc.gov/';
urlObj = new URL(urlString, urlBase);
} else {
urlObj = new URL(urlString);
}
// Set the string to the newly constructed string.
urlString = urlObj.toString();
// Don't make a metrics call if it's a link to cdc.gov and does not have a download extension (ex. pdf) or if we're inside the editor.
if( false === ( 'e' === metricsParam && urlString.includes('cdc.gov') ) && false === isEditor ) {
metricsCall(metricsParam, urlString);
}
// Open constructed link in new tab/window
window.open(urlString, '_blank');
})
useEffect(() => {
if(null === configURL) {
console.warn('No configuration URL detected.');
return;
}
const grabConfigObj = async () => {
try {
const response = await fetch(configURL);
const data = await response.json();
let tempConfigObj = {language, ...data}
setConfig(tempConfigObj);
setLoading(false);
} catch (err) {
new Error(err)
}
};
grabConfigObj();
}, [configURL]);
useEffect(() => {
if(config && config.hasOwnProperty('type')) {
setType(config.type)
}
}, [config])
// WCMS Admin
if(isEditor && config) {
// This either passes an existing config or starts with a blank editor
return (
<Suspense fallback={<Loading />}>
<CdcEditor config={config} hostname={hostname} sharepath={sharePath} />
</Suspense>)
}
// Standalone mode when you run `npm run start` just so it isn't blank
if(!config && !configURL) {
return (<Suspense fallback={<Loading />}>
<CdcEditor hostname={hostname} sharepath={sharePath} />
</Suspense>)
}
switch (type) {
case 'map':
return (
<Suspense fallback={<Loading />}>
<CdcMap config={config} hostname={hostname} navigationHandler={navigationHandler} logo={cdcLogo} />
</Suspense>
)
case 'chart':
return (
<Suspense fallback={<Loading />}>
<CdcChart config={config} hostname={hostname} />
</Suspense>
)
case 'dashboard':
return (
<Suspense fallback={<Loading />}>
<CdcDashboard config={config} hostname={hostname} />
</Suspense>
)
case 'data-bite':
return (
<Suspense fallback={<Loading />}>
<CdcDataBite config={config} hostname={hostname} />
</Suspense>
)
case 'waffle-chart':
return (
<Suspense fallback={<Loading />}>
<CdcWaffleChart config={config} hostname={hostname} />
</Suspense>
)
case 'markup-include':
return (
<Suspense fallback={<Loading />}>
<CdcMarkupInclude config={config} hostname={hostname} />
</Suspense>
)
default:
return <Loading />
}
}
export default Wrapper
Click to expand listener.js
jQuery( document ).ready( function( $ ) {
var $temporaryInput = $(".cdc-viz-editor-hidden-temp-input");
var $persistedInput = $(".cdc-viz-editor-hidden-input");
var $vizContainer = $(".wcms-viz-container");
// Store the data in the temporary input every time the viz sends an event from it's editor.
window.addEventListener('updateVizConfig', function(e) {
$temporaryInput.val(e.detail);
updateDetailsOnScreen( e.detail );
}, false)
function updateDetailsOnScreen( config ) {
//Get config object
var configObject;
if ( undefined !== config ) {
configObject = JSON.parse( config );
} else if ( undefined !== vizVariables.config && vizVariables.config.length > 0 ) {
configObject = JSON.parse( vizVariables.config );
} else { //New viz
return false;
}
const getPropHtml = (pathArr, title, defaultVal = 'Not Set', alwaysShow = true) => {
let val = getConfigProp( pathArr )
if ( ( undefined === val ) && alwaysShow ) {
val = defaultVal;
}
return undefined !== val ? getHtml( title, val) : '';
}
const friendlies = {
'us': 'U.S.',
'world': 'World',
'chart': 'Chart',
'equalnumber': 'Equal Number',
'equalinterval': 'Equal Interval',
'category': 'Categorical',
'data': 'Data',
'navigation': 'Navigation',
'map': 'Map'
}
const getHtml = (title,val) => {
if ( friendlies.hasOwnProperty( val ) ) {
val = friendlies[val];
}
return '<div>' + title + ':</div><div>' + val + '</div>';
}
const getConfigProp = (pathArr) => {
return pathArr.reduce((configObject, key) =>
(configObject && configObject[key] !== 'undefined') ? configObject[key] : undefined, configObject);
}
let summaryInfo = '';
const vizType = configObject.type;
if ( 'chart' === vizType ) { //Handle Charts
summaryInfo += getPropHtml( ['title'], 'Title', 'Not Set' );
summaryInfo += getPropHtml( ['type'], 'Type', 'Not Set' );
summaryInfo += getPropHtml( ['visualizationType'], 'Sub Type', 'Not Set' );
if ( configObject.hasOwnProperty('series') && Array.isArray( configObject.series ) && configObject.series.length ) {
let dataSeries = configObject.series.map(a => a.dataKey);
summaryInfo += getHtml( 'Data Series', dataSeries.join(', ') );
}
summaryInfo += getHtml('Number of Rows', configObject.data.length);
} else if ( 'map' === vizType ) { //Handle Maps
summaryInfo += getPropHtml( ['general', 'title'], 'Title', 'Not Set' );
summaryInfo += getPropHtml( ['type'], 'Type', 'Not Set' );
summaryInfo += getPropHtml( ['general', 'type'], 'Sub Type', 'Not Set' );
summaryInfo += getPropHtml( ['general', 'geoType'], 'Geo Type', 'Not Set' );
var displayAsHex = getConfigProp( ['general', 'displayAsHex'] );
if ( displayAsHex ) {
summaryInfo += getHtml('Is Hex Tile', 'Yes');
}
summaryInfo += getHtml('Number of Rows', configObject.data.length);
summaryInfo += getPropHtml( ['legend', 'type'], 'Classification Type', 'Not Set' );
summaryInfo += getPropHtml( ['legend', 'numberOfItems'], 'Number of Classes (Legend Items)', 'Not Set', true );
}
$('.viz-details').html( summaryInfo );
}
function saveVizData() {
// Apply the temporary value
window.SAVEVIZ = true
$persistedInput.val( $temporaryInput.val() );
$vizContainer.attr('data-config', $persistedInput.val());
$('body').css('overflow', 'auto')
}
function cancelVizEdit() {
// This is so this function can work every time the modal closes - ESC button or clicking outside the modal.
if(window.SAVEVIZ) {
return;
}
// Blank out temporary value
$temporaryInput.val('');
$('body').css('overflow', 'auto');
var dataVal = $persistedInput.val()
if(dataVal) {
// Revert to persisted configuration behind the scenes
$vizContainer.attr('data-config', dataVal);
window.CDC_Load_Viz();
}
}
var vizModal = CDC_Modal.create({
title: 'Visualization Editor',
// data-viz uses the older modal center method 'modal-transform'
classNames: ['cdc-cove-editor modal-transform'],
closed: true,
fullbody: true,
fullscreen: true,
onOpen: function() {
$('body').css('overflow', 'hidden'); // Disable scrolling
window.SAVEVIZ = false;
},
onClose: cancelVizEdit,
message: $vizContainer,
buttons: [
{
text: 'OK',
click: saveVizData,
className: 'button-primary'
},
{
text: 'Cancel',
className: 'button-secondary'
}
]
})
$( '.open-viz-editor-anchor').on( 'click', function( e ) {
e.preventDefault();
vizModal.open();
} );
updateDetailsOnScreen();
return false;
});
Click to view index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<title></title>
<style type="text/css">
body {
margin: 0;
}
.wcms-viz-container {
min-height: 100vh;
}
.cdc-viz-inner-container {
min-height: 100vh;
}
</style>
</head>
<body>
<div class="wcms-viz-container" data-standalone="true"></div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<% if (mode === "development") { %>
<script src="<%= fileName %>"></script>
<% } %>
</body>
</html>
npm install
npm run start
<?php
/**
* Plugin Name: COVE Editor Plugin
* Plugin URI:
* Description: A custom plugin that integrates Cove's React Editor with WordPress.
* Version: 1.0.0
* Author: COVE
* Author URI: https://github.com/CDCgov/cdc-open-viz/
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Enqueue scripts and styles
function your_plugin_enqueue_assets() {
// Register the bundled JavaScript file generated by Webpack
wp_enqueue_script(
'cove-plugin-js',
plugins_url( 'dist/main.js', __FILE__ ),
array(),
null,
true // Load in footer
);
wp_enqueue_script(
'cove-listener-js',
plugins_url( 'assets/scripts/listener.js', __FILE__ ),
array(),
null,
true // Load in footer
);
}
// Hook to enqueue assets
add_action( 'wp_enqueue_scripts', 'your_plugin_enqueue_assets' );
You'll want to create a new content type that has a div for <div class="wcms-viz-container"></div>
on the edit screen. This will be where React renders the editor. You'll also want two hidden inputs on the page with the class names .cdc-viz-editor-hidden-temp-input
and .cdc-viz-editor-hidden-input
to save the JSON from the editor into.
Save the data from the hidden input to your database.