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

  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$

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:

  1. Base feature adds get_template_hooks("notes.create.form_extra") in its template — an empty slot by default.
  2. Refinement feature calls register_template_hook("notes.create.form_extra", my_func) in its hooks.py.
  3. 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:

  1. Service proxyservice_proxy("NotesService") replaces the direct NotesService() 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 when notes_tags overrides the service with NotesServiceWithTags, the routes automatically use the refined version.

  2. Extra form fields — The create route collects any form field not in FORM_BASE_FIELDS and passes it as **extra to the service. This way, when a hook injects a tags input into the form, the value flows through to NotesServiceWithTags.create() — which knows how to normalize it — without the base route having to know about tags.

Why not import NotesService directly? Because Python creates the instance when the module loads — before notes_tags has registered its override. The proxy delays resolution until request time, when all features are loaded. Same pattern Flask uses with current_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_extra hook passes note as 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:

  1. Created a product from an SPL catalog with interactive feature selection.
  2. Explored the composition with xray, doctor, and UVL queries.
  3. Built a feature from scratch — model, service, routes, hooks, and UVL integration.
  4. Tested it at every level — unit, integration, and functional.
  5. Released and deployed — published to PyPI, pinned a version, built a production image.
  6. Extended with refinement — added tags to notes via model mixin and service override.
  7. 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.


Back to top

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