Eventonomy

Recipe: Add a Custom Block

Goal: Register a Gutenberg block that inherits the shared eventonomy Interactivity store and reads the canonical REST envelope, without duplicating store or API logic.

Seams Used

Seam Type Purpose
evnm_blocks Filter Register the block with Eventonomy's BlockRegistrar.
evnm_get_view_data() Function Read server-side view data (event, config) for block render.php.
store('eventonomy', …) JS Merge additional state or actions into the shared store.

Step 1: Register the Block

In your plugin's PHP bootstrap (or in a Provider; see Recipe: Extend with a Provider):

add_filter( 'evnm_blocks', function ( array $blocks ): array {
    $blocks['my-addon/event-video'] = [
        'path' => __DIR__ . '/build/event-video',  // directory with block.json
    ];
    return $blocks;
} );

BlockRegistrar picks this up automatically. The only key it reads is path, which must point at a directory containing a valid block.json. (There are no uses_store / view_data keys: the shared store is inherited by any block that sets the eventonomy interactivity namespace in its markup, and view data is read on demand in render.php, not "hydrated" by the registrar.)

Step 2: Build the Block Files

Minimal directory layout:

build/event-video/
├── block.json
├── index.js        (editor script)
├── view.js         (frontend, imported by the block)
└── style.css

block.json (minimum required fields):

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "my-addon/event-video",
  "title": "Event Video",
  "category": "eventonomy",
  "supports": {
    "interactivity": true,
    "html": false
  },
  "editorScript": "file:./index.js",
  "viewScriptModule": "file:./view.js",
  "style": "file:./style.css"
}

Two of those are load-bearing, and leaving either out produces a block that looks registered but does not work:

  • "supports": { "interactivity": true } is what makes WordPress add @wordpress/interactivity to the page's import map for your view module. Without it the import { store } from '@wordpress/interactivity' in Step 4 is an unresolvable specifier, the whole module fails to load, and every data-wp-* directive is inert.
  • "apiVersion": 3 is required for get_block_wrapper_attributes() in Step 3 to return anything useful.

All eighteen blocks Eventonomy and Pro ship declare both.

There is deliberately no "render" key. BlockRegistrar looks for a render.php beside block.json and installs its own render callback - the one that resolves child-theme overrides, injects the .evnm-scope class and applies evnm_block_output. A "render" key would be ignored, because the registrar's render_callback wins.

Step 3: Read View Data in render.php

<?php
// build/event-video/render.php

defined( 'ABSPATH' ) || exit;

// evnm_get_view_data( 'event', $id ) returns [ 'event' => <row>, 'occurrences' => [...] ]
// (an empty array if the event isn't publicly viewable). The event id is at
// $view_data['event']['id'] - NOT $view_data['id'].
$view_data = function_exists( 'evnm_get_view_data' )
    ? evnm_get_view_data( 'event', get_the_ID() )
    : [];

$event_id  = (int) ( $view_data['event']['id'] ?? 0 );
$vimeo_url = evnm_get_meta( 'event', $event_id, 'vimeo_url', '' );
?>
<div
    <?php
    // get_block_wrapper_attributes() returns a PRE-ESCAPED attribute string. Do not run
    // it through wp_kses_data()/esc_attr() - that mangles it. Every shipped block echoes
    // it raw with a phpcs:ignore, and so should yours.
    echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped by core.
    ?>
    data-wp-interactive="eventonomy"
    data-wp-context="<?php echo esc_attr( wp_json_encode( [ 'vimeoUrl' => $vimeo_url ] ) ); ?>"
>
    <div class="my-addon-video" data-wp-bind--hidden="!context.vimeoUrl">
        <iframe
            data-wp-bind--src="context.vimeoUrl"
            allow="autoplay; fullscreen"
            loading="lazy"
        ></iframe>
    </div>
</div>

Step 4: Merge into the Shared Store

// build/event-video/view.js
import { store, getContext } from '@wordpress/interactivity';

store( 'eventonomy', {
    state: {
        // Derived state: a getter that reads from this block's context.
        get hasVideo() {
            return !! getContext().vimeoUrl;
        },
    },
    actions: {
        *loadVideo() {
            const ctx = getContext();
            ctx.loading = true;
            // ... fetch from a custom REST endpoint if needed.
            ctx.loading = false;
        },
    },
} );

Calling store('eventonomy', …) merges your additions; it does not replace the existing store.

Step 5: Storing a Custom Event Field (Honest Limits)

Your block reads vimeo_url event meta, so something has to write it. There is no evnm_event_form_fields hook (it does not exist), and no seam that injects a field into the event editor and auto-persists it as event meta. The event-editor block only submits the event's own columns and its typed settings, so the persist actions below never receive an arbitrary add-on field on their own.

What is real:

  • evnm_after_create_event ( $event, $context ) and evnm_after_update_event ( $event, $changed_keys, $context ) are real actions that fire post-commit, but the $event payload is the event row + its settings, not your custom key. They are the right place to persist a value you obtained elsewhere (your own admin screen, REST call, or import).
  • evnm_event_editor_fields (filter) is the real seam to render extra UI inside the frontend event editor. It only prints markup, though; persisting whatever the user types is your responsibility (e.g. your own REST route or a hidden field your JS reads, then evnm_update_meta).

The simplest reliable pattern is to write the meta yourself the moment you have the value, then let the block read it:

// Wherever your add-on obtains the value (your settings screen, a REST route,
// an importer, a webhook), persist it with the public Meta API:
evnm_update_meta( 'event', $event_id, 'vimeo_url', esc_url_raw( $vimeo_url ) );

The block in Step 3 then reads it with evnm_get_meta( 'event', $event_id, 'vimeo_url', '' ); no editor hook required.

Verify It Worked

  1. Run npm run build in your add-on to generate the build/ directory.
  2. Activate the add-on plugin.
  3. Open the Block Editor on any page, then search for "Event Video" and insert it.
  4. View the page on the frontend and confirm the block renders with your markup.
  5. Check the browser console for 0 Interactivity store errors.

Related

  • docs/EXTENDING.md §5 - Interactivity store extension surface.
  • docs/EXTENDING.md §6 - evnm_blocks filter details.
  • Blocks & Templates - the stable store API.
  • Recipes Index