Editorial desk¶
Version v1
You can apply this model as a recipe (Drupal 10.3 or later) to your own Drupal site:
1 2 3 4 5 6 7 8 9 10 11 | |
Purpose¶
An editorial desk, built entirely by one ECA model: three blocks, one endpoint serving three representations, and a region that updates itself when the server says something changed.
It is deliberately one model rather than four. The four parts are not independent demonstrations — the block in part four fetches the endpoint in part three — and keeping them together means every event, condition and action that makes the scenario work is visible on one canvas. Six events, five conditions and thirty actions.
Nothing here needs a custom content type. It runs on a stock Standard site.
What you get¶
Three blocks and one route:
- Editorial Desk — a composite render array: a heading, a dropbutton of
desk shortcuts, an embedded view of the desk list, a collapsible help
panel, an attached library and a
drupalSettingsvalue. - Editorial Desk Activity — a block whose body is a lazy element, so the block stays cacheable while its contents do not.
- Editorial Desk Counter — two HTMX regions, one polling and one purely event-driven.
/eca/desk/{format}— one endpoint answering as JSON, as an HTMX fragment, or as a bare timestamp.
It also ships its own view, eca_desk_list. The desk block embeds its
default display and the endpoint counts its count_1 display; Part 3
explains why those have to be two different displays.
The recipe places all three blocks in the content region of the user profile page, visible to authenticated users only and with no block titles shown. Move them from Structure → Block layout if you want them somewhere else.
The placements are not shipped as configuration. A block.block.*.yml
file carries a theme key and a theme dependency, so a recipe that ships
one places its block in whichever theme its author happened to be running
and in no other: on a site with a different front end the recipe applies
without error and the blocks never appear. This recipe uses core's
placeBlockInDefaultTheme config action instead. The action reads
system.theme on the site that applies the recipe and places each block in
that site's own default theme, so nothing in the recipe names a theme at
all. The region is the plain string content, the one region every Drupal
theme is required to have. (Where different themes need different regions,
region can instead be a map keyed by theme name, with a default_region
naming the fallback for themes the map does not mention.)
The action creates a block only when none of that name exists yet, so re-applying the recipe never moves a block you have since placed somewhere else.
Part 1 — Building a render array, element by element¶
The ECA Block event gives you a render array and a chain of actions that
write into it. Each action here contributes one element: Render markup
for the heading, Render dropbutton for the shortcuts, Render views for
the desk list, Render details for the help panel, plus Add attached
library and Add attached setting.
The embedded view has Ajax paging switched on, so moving between pages of the desk list swaps the table in place instead of reloading the whole page the block sits on.
The Name is the render array key. Every Render action has a Name, and that name is the key its element gets. Two actions sharing a name overwrite each other; an empty name writes into the array root. Give every element its own name and you can rearrange them freely.
Weight decides the visual order, the chain decides execution order.
Those are two different orders and both are worth being deliberate about.
Here the heading is -20, the shortcuts -10, the desk list 0 and the
help panel 20, so the layout is stable no matter how the chain is later
rearranged.
The trap: a denied action stops everything behind it¶
The view embed is the last action in the chain, and this is the part worth reading twice.
The eca_desk_list view requires the access content overview
permission. When a visitor without that permission renders the block, the
Render views action is denied — and ECA does not skip it and carry on.
It stops the chain. Every successor after the denied action is
abandoned as well.
Put that action in the middle of the chain, as the first draft of this
model did, and an anonymous visitor loses the help panel, the attached
library and the drupalSettings value too — none of which are
permission-sensitive at all. At ECA's default log level nothing is written
to the log. The block simply renders smaller. It looks like a theme
problem.
With the permission-gated action last, an unprivileged visitor gets everything except the view, which is the intended degradation:
1 2 | |
Rule of thumb: put actions that can be denied at the end of the chain.
Part 2 — Deferring the volatile part with a lazy element¶
The Editorial Desk Activity block is one line on your user page: Desk activity for user 3, resolved at 14:22:07. It names the person reading it and the second it was built, so it is right for you and right now — on a page every other part of which is cached and shared between visitors. That combination is what you want and what you cannot normally have.
A block showing the time, or the current user, cannot be cached — and if it sits on a page, the page cannot be cached either. One volatile element spoils the whole page.
A lazy element is the way out. The block renders a placeholder, the page is cached with the placeholder in it, and the volatile part is resolved separately, after the cache, on every request.
That takes two entry points:
- The ECA Block event builds the Activity block and runs
Render lazy, which places the placeholder and gives it an argument. - The ECA Render: lazy element event fires later, when Drupal resolves that placeholder, and runs the action that produces the real markup.
Nothing in the model connects those two branches. They are joined by a string.
The trap: the name is the only join¶
Render lazy has a Name. The lazy element event has a Name. They must
be the same string — here, desk_activity. That one value is both the
render array key and the identifier the event matches on.
Get it wrong and the model imports cleanly, validates cleanly and renders nothing. No error, no log entry, no broken configuration — just an empty region. No validator can catch it, because from the outside it is two unrelated plugins that happen to hold strings.
If a lazy region is blank, check that pairing before anything else.
Passing data across the boundary¶
The resolved element runs in its own render context. It does not inherit the
tokens that existed when the placeholder was created — that may have been
long ago, for a different user. Render lazy therefore takes an
Argument, here [user:uid], and the resolving branch reads it back as
[argument]. Anything that must cross the boundary goes through there.
Render cacheability then adds the user cache context. Lazy resolution
defers the work; it does not decide who the result is for.
Part 3 — One endpoint, three representations¶
/eca/desk/{format} answers as JSON, as an HTML fragment for HTMX, or as a
bare timestamp — each with its own status code, content type, headers and
cache lifetime.
Access is a separate event, and it is default-deny¶
An ECA endpoint has two events. ECA Endpoint: access decides whether the
route exists for this requester; ECA Endpoint: response builds the
answer. The access event must explicitly allow. Here a scalar condition on
[user:uid] allows authenticated requesters and forbids anonymous ones.
The behaviour to know: a forbidden requester gets Drupal's ordinary 404, not a 403. The endpoint is hidden rather than refused — the right behaviour for an endpoint whose existence you may not want to advertise, but it makes two situations indistinguishable from outside:
- the access event denied the request, and
- there is no access event at all, so nothing ever allowed it.
If a new endpoint 404s and the path is definitely right, check that an access event exists before looking anywhere else.
Default-deny again, this time for the format¶
The response branch mirrors that shape. Before looking at anything it sets
the response to 404 with an explanatory body. Only a branch that
recognises the format upgrades the status to 200 and replaces the body. An
unknown format therefore cannot fall through to an empty 200 —
/eca/desk/bogus returns a real 404 carrying the model's own message.
Building the failure case first, and treating success as the exception, is worth copying.
What each format sets¶
| Format | Status | Content type | Cache-Control | Extra |
|---|---|---|---|---|
json |
200 | application/json |
max-age=60, public, s-maxage=60 |
X-ECA-Desk-Format, X-Robots-Tag |
fragment |
200 | HTML | max-age=0, private |
HX-Trigger: desk-refreshed |
stamp |
200 | HTML | max-age=0, private |
none |
The JSON branch is publicly cacheable for a minute because the number it reports need not be exact to the second. The two HTML branches are private and uncacheable because HTMX fetches them for one user. Cache lifetime is a per-representation decision, not a per-route one.
Counting honestly¶
The awaiting-review count comes from ECA Views: query against the
count_1 display of the shipped eca_desk_list view, and Count turns
the rows into a number.
That display exists for a reason. Point a count at a paged display and you
count the rows on the first page. The default display of this view
carries a mini pager showing 10 items, so counting against it would never
report more than 10. A JSON field called awaiting_review that silently
stops at 10 is worse than no field at all. count_1 is the pagerless
display the recipe ships for exactly this, rather than borrowing one that
pages.
Setting headers from YAML¶
Set response headers takes a YAML map. Switch Use YAML format on and
the value is parsed rather than treated as a literal. Validate YAML
additionally checks it at access time, so a malformed map denies the action
instead of failing halfway through building the response.
Part 4 — Polling, and not polling¶
The Editorial Desk Counter block puts two lines of text on the page. The first reads Desk checked at 14:22:07 and rewrites itself every 30 seconds, so you can leave the page open and watch it stay current. The second starts as not yet refreshed and then shows the same time, but it changes only in the moment the first one does; it is on no clock of its own.
The second line is the interesting one. It moves because the server told the browser that something happened, not because a timer went off — which is how you keep a region current when updates are rare and you do not want every open tab asking for them.
The Counter block renders two regions against the same endpoint on deliberately different schedules.
HTMX poll renders a div that fetches /eca/desk/fragment every 30
seconds and replaces its own contents, showing Loading the desk counter…
until the first response arrives.
HTMX element renders a span whose trigger is not an interval but an
event name: desk-refreshed from:body. It makes no requests at all until
that event fires.
The event comes from the server. The fragment branch sets the
HX-Trigger: desk-refreshed response header, and HTMX turns that header
into a DOM event on arrival. So the poll runs, the response announces
desk-refreshed, and the second region updates as a consequence. One region
polls; the other stays current without polling at all.
That generalises well: anything that should update when something happens rather than every N seconds belongs on this pattern.
The trap: why a third format exists¶
The stamp region fetches /eca/desk/stamp, not /eca/desk/fragment.
If it fetched fragment, that response would carry HX-Trigger:
desk-refreshed too, which would trigger the stamp again, which would fetch
again — an infinite loop running as fast as the network allows, in every
open browser tab. stamp returns the same timestamp without the header.
A region that listens for an event must not fetch a URL that emits it. Any endpoint that both emits an event and can be fetched in response to that event needs a variant that stays quiet.
Both HTMX actions have Htmx only enabled, so the elements render only
where the htmx library is actually active, rather than leaving dead hx-
markup behind.
Trying it¶
The recipe places the three blocks on the user profile page, so log in and
visit /user to see all three. Then, as an authenticated user:
1 2 3 4 | |
In the browser, watch the network tab on a page carrying the Counter block:
one request every 30 seconds to fragment, each followed immediately by a
single request to stamp. Continuous stamp requests mean the loop-breaker
has been undone.
Dependencies¶
- config
- views.view.eca_desk_list
- module
- eca_access
- eca_base
- eca_endpoint
- eca_htmx
- eca_render
- eca_views
- modeler_api
- node
- user
Used plugins¶
Events¶
- ECA Block (Editorial Desk)
- ECA Block (Editorial Desk Activity)
- ECA lazy element (desk_activity)
- ECA Endpoint access (desk)
- ECA Endpoint response (desk)
- ECA Block (Editorial Desk Counter)
Conditions¶
Actions¶
- Add the desk heading
- Add desk shortcut dropbutton
- Embed the editorial desk list view
- Add the collapsible help panel
- Attach the core message library
- Publish the summary endpoint to drupalSettings
- Place the deferred activity placeholder
- Vary the block by user
- Render the deferred activity body
- Allow authenticated requesters
- Forbid anonymous requesters
- Read the requested format
- Default the response to 404
- Default the body to an error notice
- Query items awaiting review
- Count items awaiting review
- Build the JSON summary body
- Set the JSON content type
- Upgrade the JSON response to 200
- Add JSON response headers
- Cache the JSON summary publicly for 60s
- Build the HTMX fragment body
- Upgrade the fragment response to 200
- Mark the fragment private and uncacheable
- Announce desk-refreshed to the client
- Build the bare timestamp body
- Upgrade the timestamp response to 200
- Mark the timestamp private and uncacheable
- Poll the desk fragment every 30 seconds
- Refresh the stamp when desk-refreshed fires
Changelog¶
Initial version