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/interactivityto the page's import map for your view module. Without it theimport { store } from '@wordpress/interactivity'in Step 4 is an unresolvable specifier, the whole module fails to load, and everydata-wp-*directive is inert."apiVersion": 3is required forget_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 )andevnm_after_update_event( $event, $changed_keys, $context )are real actions that fire post-commit, but the$eventpayload is the event row + itssettings, 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, thenevnm_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
- Run
npm run buildin your add-on to generate thebuild/directory. - Activate the add-on plugin.
- Open the Block Editor on any page, then search for "Event Video" and insert it.
- View the page on the frontend and confirm the block renders with your markup.
- Check the browser console for
0Interactivity store errors.
Related
docs/EXTENDING.md§5 - Interactivity store extension surface.docs/EXTENDING.md§6 -evnm_blocksfilter details.- Blocks & Templates - the stable store API.
- Recipes Index