Frontend refinement

Add a tag UI to the notes feature with template hooks between features. The base feature only gains generic extension points, and everything about tags lives in the refinement.


Table of contents

  1. Prerequisites
  2. How inter-feature hooks work
  3. Step 1. Make notes routes refinement-aware
  4. Step 2. Add hook slots to notes templates
  5. Step 3. Register hooks in notes_tags
    1. Option A. Use the refinement wizard
    2. Option B. Write hooks.py manually
    3. Either way
  6. Step 4. Create the hook templates
  7. Step 5. Restart and verify
  8. Step 6. Inspect the composition
  9. Step 7. Test reversibility
  10. Hook naming convention
  11. What you learned
  12. 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$

Steps 1 and 2 edit splent_feature_notes itself, so the product must use the editable copy at /workspace/splent_feature_notes.

If you published splent_feature_notes in Tutorial 5 and still have v1.0.0 attached, the product uses a read-only snapshot in .splent_cache/. Switch back to your editable copy first.

splent feature:detach splent-io/splent_feature_notes v1.0.0
splent feature:add splent-io/splent_feature_notes

How inter-feature hooks work

In Tutorial 3 you filled a slot of the product layout (layout.sidebar.authenticated.items). Any feature can also declare slots in its own templates, and any other feature can fill them.

  1. The base feature loops over get_template_hooks("notes.create.form_extra") in its template, an empty slot by default.
  2. The refinement feature calls register_template_hook("notes.create.form_extra", my_func) in its hooks.py.
  3. At render time, the registered function’s HTML goes into the slot.
The base templates declare slots and the refiner fills them splent_feature_notes splent_feature_notes_tags notes/index.html notes.index.before_list notes.index.note_extra notes/create.html notes.create.form_extra hooks.py tags_filter_bar() note_tag_badges(note) tags_form_field() register_template_hook
The base templates declare empty slots. The refiner's hooks.py fills them, and without the refiner they render nothing.

The base feature knows nothing about tags, badges or filters. It only offers the slots.


Step 1. Make notes routes refinement-aware

First switch the notes routes to the service locator, so they use the refined NotesService when notes_tags overrides it.

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")

# Fields the route handles itself. csrf_token is the form's CSRF field.
FORM_BASE_FIELDS = {"title", "content", "csrf_token"}


@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 things changed.

  1. Service proxy. service_proxy("NotesService") replaces NotesService(). It is used the same way (notes_service.get_by_user(...)) but resolves the service at request time, so the routes get NotesServiceWithTags once notes_tags overrides it. An instance created at module load would come before the override is registered. Flask’s current_app works the same way.
  2. Extra form fields. The create route passes every form field not in FORM_BASE_FIELDS to the service as **extra. When a hook adds a tags input to the form, its value reaches NotesServiceWithTags.create(), which normalizes it, without the base route knowing about tags. csrf_token is in that set because every form carries it, and passed to the model it would fail with TypeError: 'csrf_token' is an invalid keyword argument for Notes.

Step 2. Add hook slots to notes templates

The base feature declares where other features can add content. Edit splent_feature_notes/src/splent_io/splent_feature_notes/templates/notes/index.html and add two slots, notes.index.before_list above the list (for a filter bar or a search) and notes.index.note_extra inside each note card (for badges or metadata).

{% for hook in get_template_hooks("notes.index.before_list") %}
    {{ hook() | safe }}
{% endfor %}

Put it right above {% if notes %}. Then add this inside the note loop, right after the <p> that shows note.content.

{% for hook in get_template_hooks("notes.index.note_extra") %}
    {{ hook(note) | safe }}
{% endfor %}

The note_extra slot passes note to the function, so a refiner can render content for that note, such as its tag badges.

In notes/create.html, add notes.create.form_extra for extra inputs.

{% for hook in get_template_hooks("notes.create.form_extra") %}
    {{ hook() | safe }}
{% endfor %}

Put it inside the <form>, right above the Cancel link.

If no feature fills a slot, it renders nothing.


Step 3. Register hooks in notes_tags

Update the notes contract so the wizard sees the new slots.

splent feature:contract splent_feature_notes --write
  Changes detected:
      + extensible.hooks: notes.create.form_extra
      + extensible.hooks: notes.index.before_list
      + extensible.hooks: notes.index.note_extra

  ✅ Contract written to pyproject.toml.

Option A. Use the refinement wizard

Run the wizard again.

splent feature:refine splent_feature_notes_tags

It first asks Refinement already configured (refines: splent_feature_notes) Overwrite? [y/N]. Answer y. It rewrites [tool.splent.refinement] and keeps your code. Give the same answers as in Tutorial 6 (8 for notes, 1 for the Notes model, 1 for NotesService, Enter for templates) until the new question about hook slots.

  Hook slots (fill with register_template_hook)

  Available hook slots to fill:
    1. notes.create.form_extra
    2. notes.index.before_list
    3. notes.index.note_extra
    0. (none)

  Select hook slots to fill (space-separated numbers, or 0 for none) [0]: 1 2 3
    fill: notes.create.form_extra
    fill: notes.index.before_list
    fill: notes.index.note_extra

Press Enter for the hooks to override and for the routes question, and Y to write.

  kept:       models.py (NotesTagsMixin already there)
  kept:       services.py (NotesServiceWithTags already there)
  unchanged:  __init__.py
  scaffolded: hooks.py <- 3 hook slot(s), with empty templates/hooks/*.html
  kept:       migrations/env.py (the framework migrates the columns the mixin injects)
  contract updated.

  notes_tags already in features.

The wizard scaffolds hooks.py with stub functions, their register_template_hook calls and an empty template for each.

Option B. Write hooks.py manually

Edit splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/hooks.py.

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

If you used the wizard, replace the whole hooks.py with the code of Option B. The empty stub templates it created (hooks/notes_*.html) are then unused, and you can delete them.


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 splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/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 splent_feature_notes_tags/src/splent_io/splent_feature_notes_tags/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
  restarting my_first_app (dev)
  done.

Open http://localhost:5591/login and log in with user1@example.com / 1234.

  • /notes/create has a Tags field
  • Create a note with tags work, important
  • The list shows tag badges on the note, and a filter bar above it

All of it comes from notes_tags. The base feature only gained service_proxy in its routes and three slots in its templates.


Step 6. Inspect the composition

splent feature:xray --validate

You replaced hooks.py after the wizard wrote the contract of notes_tags, so it first offers to refresh it. Press Y. The x-ray shows the service override and the model extension, and under splent_feature_notes the three slots, each filled by notes_tags.

  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: layout.sidebar.authenticated.items  ← splent_feature_profile
          hook: notes.create.form_extra  ← splent_feature_notes_tags
          hook: notes.index.before_list  ← splent_feature_notes_tags
          hook: notes.index.note_extra  ← splent_feature_notes_tags

  splent_feature_notes_tags (refines splent_feature_notes)
       service: NotesService → NotesServiceWithTags
         model: Notes + NotesTagsMixin

  splent_feature_public, splent_feature_redis, splent_feature_session_redis — no active refinements

  2 refinement(s) across 1 feature(s).

  ── Validation ──

  ✅ All 10 checks passed.  (4 shared hook(s), 1 unused layout hook(s))

--full shows every extensible point, including those nothing fills.


Step 7. Test reversibility

Remove the refinement feature. As in Tutorial 6, answer No, the default, when the rollback offers to delete the migration file.

splent db:rollback splent_feature_notes_tags
splent feature:remove splent_feature_notes_tags
splent product:restart

In the browser, notes works as before, with no tags field, badges or filter bar. The slots render nothing and **extra passes nothing. Add it back.

splent feature:add splent-io/splent_feature_notes_tags
splent db:upgrade
splent product:restart

The tags UI is back. The code is reversible but the data is not, since the rollback dropped the tags column with the tags you typed. Create a note with tags again to see them.


Hook naming convention

Name a slot with dots, for 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

Slots of the product layout start with layout., and a feature’s slots start with its short name.


What you learned

Concept What it means
Service locator in routes Routes use service_proxy(), resolved per request, so refiners can override behaviour without touching the base routes
Extra form fields Base routes pass **extra form data to the service, so inputs added by hooks reach it
Hooks between features Features declare slots in their templates, and other features fill them with register_template_hook()
Hook arguments Hooks can receive arguments (like note) for context-specific rendering
Additive hooks register_template_hook() adds alongside existing content
Generic templates Base features know nothing of what fills their slots
Full reversibility Remove the refiner and the base feature works unchanged

Wrapping up

From an empty workspace you have

  1. created a product from an SPL, choosing its features interactively,
  2. explored its composition with xray, doctor and UVL queries,
  3. built a feature with a model, a service, routes, hooks and UVL integration,
  4. tested it at the unit, integration and functional levels,
  5. rehearsed a release and deployed, building a production image and creating its first user,
  6. extended it with refinement, adding tags through a model mixin and a service override,
  7. refined the frontend, filling slots the base feature’s templates declare.

Tutorial 8 shows the same kind of feature built by describing it to Claude Code.

Every change was modular, reversible and validated against the model.


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es