A small WordPress plugin that registers two Gutenberg blocks — a dynamic Posts Grid and a companion Posts Filter — that stay in sync anywhere on the page, and seeds a complete demo environment automatically on activation.
Built for the WordPress Web Development Team Lead technical assessment.
- What it does
- Quick start
- Architecture overview
- The key decision: inter-block communication
- How the filtering logic works (OR / AND)
- Demo content seeding
- Project structure
- Trade-offs & known limitations
- Coding standards
| Requirement | Where it's implemented |
|---|---|
| Posts Grid — dynamic, not hardcoded | src/posts-grid/render.php + includes/class-posts-query.php |
| Title, featured image, excerpt | shape_post() in class-posts-query.php |
| Inspector Controls: columns (2/3/4) & posts per page | src/posts-grid/edit.js |
| Pagination as an inner block of the grid | src/posts-grid-pagination/ (restricted via parent in block.json) |
| Posts Filter — category + tag, multi-select | src/posts-filter/render.php |
| OR within a filter, AND across filters | Posts_Query::get_posts() (tax_query) |
| Blocks sync without nesting | Shared Interactivity API store, src/store.js |
| Seed ≥10 posts, ≥3 categories, multiple tags, featured images, demo page | includes/class-seeder.php |
| Unique prefix on slug / post type / taxonomies | wmpb_ everywhere (class-content-model.php) |
| Admin settings page (columns, fields shown, brand colour, card style, image ratio, excerpt length) | includes/class-settings.php |
| Generated gradient featured images (no network on activation) | Seeder::create_featured_image() |
| Card chips + meta (category chip, date · reading time, tag row) | Posts_Query::shape_post() |
| Result count, Clear-filters, loading bar | src/posts-grid/render.php + src/store.js |
Requires Docker and Node.js 18+.
# 1. Install build tooling
npm install
# 2. Compile the blocks (creates the /build folder)
npm run build
# 3. Boot a throwaway WordPress with this plugin already active
npm run env:startThen open http://localhost:8888/wp-admin (user admin, password password).
On activation the plugin creates everything, so visit the page titled
“WM Posts Blocks Demo” on the front end and try the filters + pagination.
To stop / reset:
npm run env:stop # stop containers
npm run env:clean # wipe the environment entirelynpm install
npm run build # the /build folder is required at runtimeThen copy the whole wm-posts-blocks folder into wp-content/plugins/ (or zip it
and upload via Plugins → Add New → Upload) and click Activate.
Important: the plugin runs from the compiled
/builddirectory, sonpm run buildmust be run before activating. If you received a.zip, the build is already included.
npm start # rebuild on save (watch mode) ┌─────────────────────────────────────────┐
│ wmpb/posts (shared store) │
Posts Filter ──────▶│ state.selectedCategories / selectedTags │◀────── Pagination
(checkboxes) │ state.posts / page / totalPages │ (prev / next)
└───────────────────┬───────────────────────┘
│ actions.refresh()
▼
GET /wp-json/wmpb/v1/posts?... (REST_Controller)
│
▼
Posts_Query::get_posts() (one WP_Query, OR/AND)
│
▼
state.posts updated ─▶ data-wp-each re-renders the grid
PHP side (small, single-responsibility classes under includes/):
Content_Model— registers thewmpb_postCPT and thewmpb_category/wmpb_tagtaxonomies.Posts_Query— the only place posts are queried and shaped. Used by both the server render and the REST endpoint, so the filtering rules can't drift.REST_Controller— exposesGET /wmpb/v1/posts, delegates toPosts_Query.Blocks— registers the three compiled blocks from/build.Seeder— activation seeding (terms, posts, images, demo page); idempotent.Plugin— wires every class to its WordPress hooks (the only “glue” file).
JS side (src/): three blocks, each with an editor component (edit.js),
a server render (render.php) and a tiny front-end module (view.js) that
imports the single shared store in src/store.js.
“The filter and grid blocks must stay in sync without being nested inside each other. Choose the approach you think is most appropriate.”
I chose the WordPress Interactivity API with a single shared store
(namespace wmpb/posts).
Every interactive element in both blocks declares
data-wp-interactive="wmpb/posts". The Interactivity API resolves directives by
namespace, not by DOM position, so all of them read and write the same
global store no matter where they sit on the page. That is precisely the problem
the brief describes — independent blocks that must share state — and it is the
exact scenario the Interactivity API was designed for since WordPress 6.5.
Concretely:
- The filter writes
state.selectedCategories/state.selectedTags. - That triggers
actions.refresh(), which fetches the matching posts. - The result is written to
state.posts, and the grid'sdata-wp-eachtemplate re-renders automatically. - The pagination inner block writes
state.pageand calls the samerefresh().
There is no custom event bus, no nesting, and no manual DOM querying — the reactive store is the single source of truth.
| Approach | Why not |
|---|---|
Custom DOM events (document.dispatchEvent) |
Works, but it's an ad-hoc pub/sub I'd have to invent, document and guard against typos. The Interactivity API gives the same decoupling with reactive state for free. |
@wordpress/data (Redux-like) store |
Great in the editor, but it's not the idiomatic tool for front-end interactivity and would mean shipping a heavier runtime to render server-side content. |
| Nesting the grid inside the filter | Explicitly disallowed by the brief, and it couples two concerns that should be placeable independently. |
The grid uses one rendering path: a data-wp-each template (in
render.php) that renders one card per item in state.posts. Crucially, the
first page of posts is seeded into the store server-side with
wp_interactivity_state(), so the data ships inline in the initial HTML and the
grid populates the moment the runtime hydrates — no extra fetch, no loading
flash for the first view. The same template then handles every filter and
pagination change.
I deliberately do not also print the cards as static <article> HTML:
data-wp-each appends its rendered output rather than reconciling against
existing sibling nodes, so doing both renders every card twice. (I verified this
in a real browser — see "Trade-offs" below.)
Trade-off: because the cards are rendered client-side, a JavaScript-disabled
client sees the post data only in the inline JSON, not as visible cards. A fully
no-JS / crawler-friendly version would render server HTML and swap it with the
Interactivity Router region pattern (data-wp-router-region) that the core
Query Loop block uses for enhanced pagination — a sensible next step, but heavier
than this brief needs.
Settings → WM Posts Blocks is a single control centre (class-settings.php,
built on the WordPress Settings API with a sanitiser for every field):
- Layout — default columns per row, posts per page.
- Content — which fields show (image / excerpt / meta) and excerpt length.
- Style — brand accent colour, card style (elevated / bordered / minimal), image aspect ratio.
The model is intentionally easy to explain:
- Per-instance layout (columns, posts-per-page) lives on the grid block, where
editors expect it. Each grid can also choose “Global default” (stored as
0) to follow the site setting — so a team gets one consistent look while any page can still override. - Site-wide presentation & content lives in the settings page.
render.phpreads it into CSS custom properties (--wmpb-accent,--wmpb-ratio) and a card style class, and toggles which fields the card template outputs. One change re-themes every grid on the site with no code edits.
The rule — OR within a filter type, AND across filter types — maps cleanly onto
a single WP_Query tax_query (includes/class-posts-query.php):
$tax_query = array( 'relation' => 'AND' ); // AND across types
if ( $categories ) {
$tax_query[] = array(
'taxonomy' => 'wmpb_category',
'terms' => $categories,
'operator' => 'IN', // OR within categories
);
}
if ( $tags ) {
$tax_query[] = array(
'taxonomy' => 'wmpb_tag',
'terms' => $tags,
'operator' => 'IN', // OR within tags
);
}operator => 'IN'→ “match any of these terms” = OR within a type.relation => 'AND'→ both clauses must pass = AND across types.
So selecting Engineering, Design + Tutorial returns posts that are (Engineering OR Design) AND Tutorial. Doing it in the database (rather than filtering in PHP/JS) keeps pagination counts correct.
On activation (includes/class-seeder.php), and only once (guarded by the
wmpb_seeded option so re-activation never duplicates):
-
4 categories and 6 tags are created.
-
12 posts are inserted with deliberately overlapping category/tag assignments, so filter combinations produce meaningful, non-empty results.
-
Each post gets a diagonal-gradient featured image generated locally with PHP's GD extension (drawn small, upscaled smoothly, with soft overlay circles) — no external HTTP request, which would be fragile on locked-down hosts. Body length varies per post so the cards show a range of reading times.
-
A demo page is created with both blocks already placed:
<!-- wp:wmpb/posts-filter /--> <!-- wp:wmpb/posts-grid {"columns":3,"perPage":6} --> <!-- wp:wmpb/posts-grid-pagination /--> <!-- /wp:wmpb/posts-grid -->
Deleting the plugin from wp-admin runs uninstall.php, which removes every
post, attachment, term, the demo page and the options — leaving the site clean.
wm-posts-blocks/
├── wm-posts-blocks.php # Plugin header, constants, lifecycle hooks
├── uninstall.php # Full teardown on delete
├── includes/
│ ├── class-plugin.php # Hook wiring
│ ├── class-content-model.php # CPT + taxonomies (wmpb_ prefix)
│ ├── class-settings.php # Admin settings page (Settings API)
│ ├── class-posts-query.php # Shared query + OR/AND logic + card shaping
│ ├── class-rest-controller.php# GET /wmpb/v1/posts
│ ├── class-blocks.php # register_block_type() x3
│ └── class-seeder.php # Activation seeding
├── src/
│ ├── store.js # The shared Interactivity store (the “brain”)
│ ├── posts-grid/ # Block 1 (block.json, edit, render.php, view, scss)
│ ├── posts-grid-pagination/ # Inner block (parent: posts-grid)
│ └── posts-filter/ # Block 2
├── build/ # Compiled output (generated by npm run build)
├── .wp-env.json # One-command local WordPress
└── package.json
- One filter + one grid per page. The shared store is global to the
wmpb/postsnamespace, which is the simplest correct model for the brief. Supporting multiple independent grid/filter pairs on a single page would mean scoping state by an instance ID — a deliberate next step, not needed here. - The store is duplicated into each block's view bundle. It lives once in
source (
src/store.js) but@wordpress/scriptsinlines it into each view module. Re-declaring the same namespace just merges, so this is correct; sharing a single runtime module would shave a few KB at the cost of a more complex build. - Generated placeholder images are solid-colour JPEGs (intentionally, to
avoid network calls on activation). Swapping in real imagery is a one-line
change in
Seeder::create_featured_image(). - No automated tests. For a plugin this size I prioritised clear structure
and a strong manual demo; the
Posts_Queryclass is deliberately pure and isolated so it would be the natural first unit-test target.
- PHP follows the WordPress Coding Standards (tabs, Yoda-free readable
conditionals, full DocBlocks, escaping on all output, sanitising on all
input). Lint with
composer/phpcsif desired. - JS/CSS follow
@wordpress/scriptsdefaults:npm run lint:js,npm run lint:css,npm run format. - Modern PHP namespaces (
WMPB\) are used instead of long prefixed function names — a deliberate choice for a small, class-based plugin.