A PHP developer’s
adventures in React

building WordPress plugin
admin interfaces

Jonathan Brinley
WordCamp Miami
March 26, 2017

WordCamp Miami 2017

Jonathan Brinley

Modern Tribe
WordPress

vs.

webpack
WordPress

+

webpack

Hot Module Replacement

server.js

var webpack = require('webpack');
var WebpackDevServer = require('webpack-dev-server');
var config = require('./webpack.config');

new WebpackDevServer(webpack(config), {
	publicPath: config.output.publicPath,
	hot: true,
	historyApiFallback: true
}).listen(3000, 'localhost', function (err, result) {
	if (err) {
		return console.log(err);
	}

	console.log('Listening at http://localhost:3000/');
});

Hot Module Replacement

wp_register_script(
  'wcmia-plugin-ui',
  plugins_url( 'ui/dist/master.js', __FILE__ ),
  $dependencies,
  $version,
  true
);

Hot Module Replacement

$base_path = 'ui/dist/master.js';
$script_path = plugins_url( $base_path, __FILE__ );
if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
  $script_path = apply_filters( 'wcmia_js_dev_path', $script_path, $base_path );
}
wp_register_script( 'wcmia-plugin-ui', $script_path, $dependencies, $version, true );

mu-plugins/hmr.php

add_filter( 'wcmia_js_dev_path', function( $script_path, $base_path ) {
  return 'http://localhost:3000/' . $base_path;
}, 10, 2 );

Data Persistence

Data Persistence: Ajax

const App = () => {
  const updateDatabase = () => {
    console.log('Updating database');
    api.put(store.getState().manager);
  };

  return (
    <Provider store={store}>
      <MenuManager
        updateDatabase={updateDatabase} />
    </Provider>
  );
};
handleSaveClick() {
 this.props.dayMenuUpdated({
  date: this.props.date.iso,
  content: {
   exceptions: { $set: this.state.exceptions },
   menu: { $set: this.state.content },
  },
 });
 this.setState({ editing: false });
 this.props.updateDatabase();
}


import request from 'superagent';
import * as wpPublish from './dom/wp-publish';

export const put = (managerData = {}) => {
  wpPublish.disable();

  return request
    .post(managerData.api.url)
    .set('Content-Type', 'application/json')
    .set('X-WP-Nonce', managerData.api.nonce)
    .send(JSON.stringify(managerData))
    .end((err) => {
      wpPublish.enable();

      if (err) {
        console.error(`There was an error saving menu data: ${err}`);
      } else {
        console.log('Successfully updated menu manager data in the database');
      }
    });
};
class Menu_Controller extends Menu_Management_REST_Controller {
  private $base = '/menu';

  public function register_routes() {
    register_rest_route( $this->get_namespace(), $this->base . '/(?P<id>\d+)', [
      [
        'methods'             => \WP_REST_Server::READABLE,
        'callback'            => [ $this, 'get_item' ],
        // and other args
      ],
      [
        'methods'             => \WP_REST_Server::EDITABLE,
        'callback'            => [ $this, 'update_item' ],
        // and other args
      ],
    ] );
  }

  public function get_item( \WP_REST_Request $request ) {
    $post_id = $request->get_param( 'id' );
    $calendar = new Calendar_Data( $post_id );
    return rest_ensure_response( $calendar->get_data() );
  }

  public function update_item( \WP_REST_Request $request ) {
    $post_id = $request->get_param( 'id' );
    $updater = new Calendar_Updater( $post_id, $request->get_json_params() );
    $updater->apply_update();
    return $this->get_item( $request );
  }
}

Data Persistence: Ajax

<input type="hidden" />

<input type="hidden" />

runDataHeartbeat() {
  const dataInput = this.dataInput;
  this.heartbeat = setInterval(() => {
    const panels = cloneDeep(this.props.panels);
    const newData = JSON.stringify({ panels });
    dataInput.value = newData;
  }, 1000);
}

Autosave WP Globals

const wp = window.wp || {};
export const wpHeartbeat = wp.heartbeat || null;
export const wpAutosave = wp.autosave || null;

Autosave Hooks

const bindEvents = () => {
  $(document).on(`before-autosave.${settings.namespace}`, (e, postdata) => autosaveDrafts(e, postdata));
  $(document).on(`after-autosave.${settings.namespace}`, (e, data) => handleAutosaveSuccess(e, data));
};

const autosaveDrafts = (e, postdata) => {
  postdata.post_content_filtered = MODULAR_CONTENT.autosave;
};

Trigger Autosave

export const triggerAutosave = () => {
  if (!wpAutosave) {
    return;
  }
  const timestamp = new Date().getTime();
  MODULAR_CONTENT.needs_save = false;
  titleText = title.value;
  title.value = `${titleText}${timestamp}`;
  triggeredSave = true;
  wpAutosave.server.triggerSave();
  title.value = titleText;
};

wp_localize_script()

public function enqueue_resources() {
  if ( $this->is_menu_post_admin() ) {
    wp_enqueue_script( 'menu-manager-admin-ui', $this->script_url(), [ ], tribe_get_version(), true );
    wp_localize_script( 'menu-manager-admin-ui', 'TribeMenuManager', $this->get_script_data() );
    wp_localize_script( 'menu-manager-admin-ui', 'TribeMenuManageri18n', $this->get_i18n_data() );
  }
}

private function get_script_data() {
  $data = new Calendar_Data( $GLOBALS[ 'post' ]->ID );
  $data = $data->get_data();
  $data[ 'api' ] = [
    'url'   => Menu_Controller::get_url( $GLOBALS[ 'post' ]->ID ),
    'nonce' => wp_create_nonce( 'wp_rest' ),
  ];
  return $data;
}
<script>
  var ExampleData = <?php echo json_encode( $meta_box_data ); ?>;
  <?php do_action( 'example_metabox_js_init' ); ?>
</script>

Media WP Globals

wp_register_script(
  'example-admin-ui',
  $script_url,
  [ 'wp-util', 'media-upload', 'media-views' ],
  $version,
  true
);

const wp = window.wp || {};
export const wpMedia = wp.media || null;

Media Frames

const frame = wpMedia({
  multiple: false,
  library: {
    type: 'image',
  },
});

frame.on('select', () => {
  const attachment = frame.state().get('selection').first().toJSON();
  // do something with the attachment object
});

frame.open();

Image Galleries

selectImages() {
  const ids = _.map(this.state.gallery, attachment => attachment.id);
  // Set frame object:
  this.frame = wpMedia({
    frame: 'post',
    state: 'gallery-edit',
    title: 'Gallery',
    editing: true,
    multiple: true,
    selection: this.buildSelection(ids),
  });

  this.frame.open();
  // but wait, there's more!
}

Image Galleries

buildSelection(attachmentIds) {
  const shortcode = new WPShortcode({
    tag: 'gallery',
    attrs: { ids: attachmentIds.join(',') },
    type: 'single',
  });
  const attachments = wpMedia.gallery.attachments(shortcode);
  const selection = new wpMedia.model.Selection(attachments.models, {
    multiple: true, props: attachments.props.toJSON(),
  });
  selection.gallery = attachments.gallery;
  selection.more().done(() => {
    selection.props.set({ query: false });
    selection.unmirror();
    selection.props.unset('orderby');
  });

  return selection;
}

Image Galleries

selectImages() {
  // the aforementioned code to open the frame, then...

  const GallerySidebarHider = {};
  _.extend(GallerySidebarHider, panelBackbone.Events);
  GallerySidebarHider.hideGallerySidebar = this.hideGallerySidebar;
  GallerySidebarHider.listenTo(
    this.frame.state('gallery-edit'),
    'activate',
    GallerySidebarHider.hideGallerySidebar
  );
  GallerySidebarHider.hideGallerySidebar();

  this.frame.on('toolbar:render:gallery-edit', this.overrideGalleryInsert);
  this.overrideGalleryInsert();
}

Image Galleries

hideGallerySidebar() {
  if (this.frame) {
    // Hide Gallery Settings in sidebar
    this.frame.content.get('view').sidebar.unset('gallery');
  }
}

Image Galleries

this.frame.on('toolbar:render:gallery-edit', this.overrideGalleryInsert);
this.overrideGalleryInsert();

overrideGalleryInsert() {
  this.frame.toolbar.get('view').set({
    insert: {
      style: 'primary',
      text: 'Save Gallery', // Change the "Insert Gallery" text
      click: this.handleFrameInsertClick, // Set our handler
    },
  });
}

Image Galleries

handleFrameInsertClick() {
  const models = this.frame.state().get('library');
  const gallery = models.map((attachment) => {
    const att = attachment.toJSON();
    return {
      id: att.id,
      thumbnail: att.sizes.thumbnail.url,
    };
  });
  this.setState({ gallery });
  this.frame.close();
  this.frame = null;
}

npm require react-quill

wp_editor() - Settings

ob_start();
wp_editor(
  '',
  $editor_id,
  $settings
);
ob_get_clean();
return $editor_id;

wp_editor() - Media Buttons

wp_print_styles('editor-buttons');

ob_start();
do_action( 'media_buttons', '%EDITOR_ID%' );
$html = ob_get_clean();

wp_editor() - Media Buttons

const getMediaButtons = () => {
  return props.buttons ? (
    <div
      id={`wp-${props.fid}-media-buttons`}
      className="wp-media-buttons"
      dangerouslySetInnerHTML={{ __html: mediaButtonsHTML.replace('%EDITOR_ID%', props.fid) }}
    />
  ) : null;
};

wp_editor() - Render

<div className="wp-editor-tabs">
  <button
    type="button"
    id={`${props.fid}-tmce`}
    className="wp-switch-editor switch-tmce"
    data-wp-editor-id={props.fid}
  >
    {props.strings['tab.visual']}
  </button>
  <button
    type="button"
    id={`${props.fid}-html`}
    className="wp-switch-editor switch-html"
    data-wp-editor-id={props.fid}
  >
    {props.strings['tab.text']}
  </button>
</div>

wp_editor() - Render

<div
  data-settings_id={props.fid}
  id={`wp-${props.fid}-editor-container`}
  className="wp-editor-container"
>
  <div
    data-settings_id={props.fid}
    id={`qt_${props.fid}_toolbar`}
    className="quicktags-toolbar"
  />
  <textarea
    className={`wysiwyg-${props.fid} wp-editor-area`}
    rows="15"
    cols="40"
    value={props.data}
    name={props.name}
    id={props.fid}
    onChange={props.onUpdate}
  />
</div>

wp_editor() - Initialize TinyMCE

componentDidMount() {
  // delay for smooth animations
  _.delay(() => {
    this.initTinyMCE();
  }, 100);
}

wp_editor() - TinyMCE Globals

export const tinyMCE = window.tinyMCE || null;
export const tinyMCEPreInit = window.tinyMCEPreInit || null;
export const switchEditors = window.switchEditors || null;
export const quicktags = window.quicktags || null;
export const QTags = window.QTags || null;

wp_editor() - Initialize TinyMCE

let settings = tinyMCEPreInit.mceInit[options.editor_settings];
const qtSettings = {
  id: options.fid,
  buttons: tinyMCEPreInit.qtInit[options.editor_settings].buttons,
};
settings.selector = `#${options.fid}`;
settings = tinyMCE.extend({}, tinyMCEPreInit.ref, settings);

tinyMCEPreInit.mceInit[options.fid] = settings;
tinyMCEPreInit.qtInit[options.fid] = qtSettings;

wp_editor() - Initialize TinyMCE

quicktags(tinyMCEPreInit.qtInit[options.fid]);
QTags._buttonsInit();

if (!window.wpActiveEditor) {
  window.wpActiveEditor = options.fid;
}

options.editor.addEventListener('click', () => {
  window.wpActiveEditor = options.fid;
});

if (options.editor.classList.contains('tmce-active')) {
  _.delay(() => {
    switchEditors.go(options.fid, 'tmce');
  }, 100);
}

A PHP developer’s
adventures in React

building WordPress plugin
admin interfaces

Jonathan Brinley
WordCamp Miami
March 26, 2017