Frontend refinement
Use inter-feature template hooks to add tag UI to the notes feature — without touching a single file in the base feature.
Table of contents
- Prerequisites
- How inter-feature hooks work
- Step 1 – Make notes routes refinement-aware
- Step 2 – Add hook slots to notes templates
- Step 3 – Register hooks in notes_tags
- Step 4 – Create the hook templates
- Step 5 – Restart and verify
- Step 6 – Inspect the composition
- Step 7 – Test reversibility
- Hook naming convention
- What you learned
- Wrapping up
Prerequisites
You have completed Tutorial 6 – Extend with refinement. You are inside the CLI container with my_first_app selected and splent_feature_notes_tags added as an editable feature.
(my_first_app) /workspace$
How inter-feature hooks work
In Tutorial 3, you used product-level hooks (layout.sidebar.authenticated.items) to inject content into the product layout. But hooks are not limited to the product — any feature can declare hook slots in its templates, and any other feature can fill them.
The pattern:
- Base feature adds
get_template_hooks("notes.create.form_extra")in its template — an empty slot by default. - Refinement feature calls
register_template_hook("notes.create.form_extra", my_func)in itshooks.py. - At render time, Flask calls the registered function and injects the HTML into the slot.
The base feature is completely aseptic — it has no knowledge of tags, badges, or filters. It just offers extension points. The refinement feature fills them.
Step 1 – Make notes routes refinement-aware
Before adding hooks, we need to update the notes routes so they work with the service locator. This ensures that when notes_tags overrides NotesService, the routes use the refined version.
Edit splent_feature_notes/src/splent_io/splent_feature_notes/routes.py:
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from splent_framework.services.service_locator import service_proxy
from splent_io.splent_feature_notes import notes_bp
notes_service = service_proxy("NotesService")
FORM_BASE_FIELDS = {"title", "content"}
@notes_bp.route("/notes/", methods=["GET"])
@login_required
def index():
notes = notes_service.get_by_user(current_user.id)
return render_template("notes/index.html", notes=notes)
@notes_bp.route("/notes/create", methods=["GET", "POST"])
@login_required
def create():
if request.method == "POST":
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
if not title:
flash("Title is required.", "danger")
return render_template("notes/create.html")
# Collect extra fields injected by hook-based form extensions
extra = {k: v for k, v in request.form.items() if k not in FORM_BASE_FIELDS}
notes_service.create(
user_id=current_user.id,
title=title,
content=content,
**extra,
)
flash("Note created.", "success")
return redirect(url_for("notes.index"))
return render_template("notes/create.html")
@notes_bp.route("/notes/<int:note_id>/delete", methods=["POST"])
@login_required
def delete(note_id):
note = notes_service.get_or_404(note_id)
if note.user_id != current_user.id:
flash("Access denied.", "danger")
return redirect(url_for("notes.index"))
notes_service.delete(note_id)
flash("Note deleted.", "success")
return redirect(url_for("notes.index"))
Two changes from the original:
-
Service proxy —
service_proxy("NotesService")replaces the directNotesService()import. It looks and works the same way (notes_service.get_by_user(...)) but resolves the service at request time instead of at module load. This means whennotes_tagsoverrides the service withNotesServiceWithTags, the routes automatically use the refined version. -
Extra form fields — The
createroute collects any form field not inFORM_BASE_FIELDSand passes it as**extrato the service. This way, when a hook injects atagsinput into the form, the value flows through toNotesServiceWithTags.create()— which knows how to normalize it — without the base route having to know about tags.
Why not import
NotesServicedirectly? Because Python creates the instance when the module loads — beforenotes_tagshas registered its override. The proxy delays resolution until request time, when all features are loaded. Same pattern Flask uses withcurrent_app.
Step 2 – Add hook slots to notes templates
The base splent_feature_notes needs to declare where other features can inject content. Edit splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/index.html and add hook slots:
notes.index.before_list— above the notes list (for filter bars, search, etc.)notes.index.note_extra— inside each note card (for badges, metadata, etc.)
{% for hook in get_template_hooks("notes.index.before_list") %}
{{ hook() | safe }}
{% endfor %}
And inside the note loop:
{% for hook in get_template_hooks("notes.index.note_extra") %}
{{ hook(note) | safe }}
{% endfor %}
The
note_extrahook passesnoteas an argument to the callback. This lets refiners access the note object to render context-specific content (like tag badges for that note).
Similarly, edit notes/create.html and add a hook slot for extra form fields:
notes.create.form_extra— before the submit button (for additional inputs)
{% for hook in get_template_hooks("notes.create.form_extra") %}
{{ hook() | safe }}
{% endfor %}
The base feature’s templates are still completely generic — they have no idea what will be injected. If no feature registers for these hooks, the slots render nothing.
Step 3 – Register hooks in notes_tags
Before Step 2, the notes feature’s hook slots were not yet in its contract. Now that the templates declare them, update the contract so the wizard can see them:
splent feature:contract splent_feature_notes --write
Option A: Use the refinement wizard
If you haven’t configured refinement yet (or want to reconfigure it), the wizard detects the new hook slots automatically:
splent feature:refine splent_feature_notes_tags
The wizard separates hooks into two categories:
Step 2: What do you want to do?
Hook slots (fill with register_template_hook)
1. notes.create.form_extra
2. notes.index.before_list
3. notes.index.note_extra
Select hook slots to fill (space-separated numbers, or 0 for none): 1 2 3
fill: notes.create.form_extra
fill: notes.index.before_list
fill: notes.index.note_extra
The wizard scaffolds hooks.py with stub functions and register_template_hook calls. You then fill in the actual implementation (see below).
Option B: Write hooks.py manually
Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/hooks.py directly:
from splent_framework.hooks.template_hooks import register_template_hook
from flask import render_template
def tags_filter_bar():
"""Inject a tag filter bar above the notes list."""
from flask_login import current_user
from flask import current_app
from splent_framework.services.service_locator import get_service_class
try:
svc_cls = get_service_class(current_app, "NotesService")
svc = svc_cls()
all_tags = svc.get_all_tags(current_user.id)
except Exception:
all_tags = []
if not all_tags:
return ""
return render_template("hooks/tag_filter_bar.html", all_tags=all_tags)
def note_tag_badges(note):
"""Inject tag badges after a note's content."""
tags = []
if hasattr(note, "get_tags_list"):
tags = note.get_tags_list()
if not tags:
return ""
return render_template("hooks/note_tag_badges.html", tags=tags)
def tags_form_field():
"""Inject a tags input field in the note creation form."""
return render_template("hooks/tags_form_field.html")
register_template_hook("notes.index.before_list", tags_filter_bar)
register_template_hook("notes.index.note_extra", note_tag_badges)
register_template_hook("notes.create.form_extra", tags_form_field)
Either way
Whether you used the wizard or wrote it by hand, you end up with three hooks, three slots — all additive, all reversible. If you used the wizard, replace the stub functions with the implementations shown in Option B.
Step 4 – Create the hook templates
Create splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/templates/hooks/tag_filter_bar.html:
<div class="mb-3">
<strong>Filter by tag:</strong>
{% for tag in all_tags %}
<a href="{{ url_for('notes_tags.by_tag', tag=tag) }}"
class="badge bg-secondary text-decoration-none">{{ tag }}</a>
{% endfor %}
</div>
Create templates/hooks/note_tag_badges.html:
<div class="mt-1">
{% for tag in tags %}
<span class="badge bg-info text-dark">{{ tag }}</span>
{% endfor %}
</div>
Create templates/hooks/tags_form_field.html:
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<input type="text" class="form-control" id="tags" name="tags"
placeholder="e.g. work, important, personal">
<small class="form-text text-muted">Separate tags with commas.</small>
</div>
Step 5 – Restart and verify
splent product:restart
Open the browser (use splent product:port for the URL) and log in with user1@example.com / 1234.
- Navigate to
/notes/create— you should see a Tags field in the form - Create a note with tags
work, important - On the notes list, you should see tag badges on the note
- If you have notes with tags, a filter bar appears above the list
All of this was injected by notes_tags into notes templates — without modifying a single file in the base feature.
Step 6 – Inspect the composition
splent feature:xray --validate
The xray shows the hook registrations:
splent_feature_notes (refined by splent_feature_notes_tags)
service: NotesService ← overridden by splent_feature_notes_tags
model: Notes ← extended by splent_feature_notes_tags
hook: notes.index.before_list ← splent_feature_notes_tags
hook: notes.index.note_extra ← splent_feature_notes_tags
hook: notes.create.form_extra ← splent_feature_notes_tags
Use --full to see all extensible points, including those with no active connections.
Step 7 – Test reversibility
Remove the refinement feature:
splent db:rollback splent_feature_notes_tags
splent feature:remove splent_feature_notes_tags
splent product:restart
Open the browser — the notes feature works exactly as before. No tags field, no badges, no filter bar. The hook slots in the templates render nothing because no feature is registered for them. The **extra in the create route passes nothing because there are no extra form fields.
Add it back:
splent feature:add splent-io/splent_feature_notes_tags
splent db:upgrade
splent product:restart
Tags are back. Fully reversible.
Hook naming convention
Use dot-separated names that identify the feature, the view, and the insertion point:
| Hook name | Where it renders |
|---|---|
notes.index.before_list |
Above the notes list |
notes.index.note_extra |
Inside each note card |
notes.create.form_extra |
Extra fields in the create form |
auth.login.form_footer |
Below the login form |
layout.sidebar.authenticated.items |
Product sidebar items |
Product-level hooks start with layout.. Feature-level hooks start with the feature’s short name.
What you learned
| Concept | What it means |
|---|---|
| Service locator in routes | Routes use get_service_class() so refiners can override behavior without modifying base routes |
| Extra form fields | Base routes pass **extra form data to the service, letting hook-injected inputs flow through automatically |
| Inter-feature hooks | Features declare hook slots in their templates; other features fill them via register_template_hook() |
| Hook arguments | Hooks can receive arguments (like note) for context-specific rendering |
| Additive hooks | register_template_hook() adds alongside existing content |
| Replacement hooks | replace_template_hook() swaps out all existing content for a slot |
| Aseptic templates | Base features have no knowledge of what gets injected — they just offer extension points |
| Full reversibility | Remove the refiner and the base feature works unchanged |
Wrapping up
Over seven tutorials you have gone from an empty workspace to a fully realized product:
- Created a product from an SPL catalog with interactive feature selection.
- Explored the composition with xray, doctor, and UVL queries.
- Built a feature from scratch — model, service, routes, hooks, and UVL integration.
- Tested it at every level — unit, integration, and functional.
- Released and deployed — published to PyPI, pinned a version, built a production image.
- Extended with refinement — added tags to notes via model mixin and service override.
- Customized the frontend — used inter-feature hooks to inject UI into the base feature’s templates without modifying them.
Every change was modular, reversible, and formally validated. That is what SPLENT is for.