Skip to content

Closes: #15165 - HTMX partial fieldset re-rendering for HTMXSelect forms#22345

Open
bctiemann wants to merge 10 commits into
featurefrom
15165-htmx-partial-fieldset-rerender
Open

Closes: #15165 - HTMX partial fieldset re-rendering for HTMXSelect forms#22345
bctiemann wants to merge 10 commits into
featurefrom
15165-htmx-partial-fieldset-rerender

Conversation

@bctiemann

@bctiemann bctiemann commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Closes: #15165

Summary

Replaces the full #form_fields swap with targeted per-fieldset swaps for all HTMXSelect fields where it is safe to do so, eliminating the visible flash on large forms when a select value changes.

How it works

  • FieldSet gains an optional fieldset_id= parameter that renders as the HTML id on the fieldset <div>.
  • HTMXSelect gains an optional hx_fieldset_id= parameter. When set, the widget emits hx-select and retargets hx-target to the named fieldset instead of #form_fields. hx-include remains #form_fields so all field values are submitted and server-side dependent-field resolution continues to work correctly.
  • No view or server-side template changes are required — the server still returns the full form HTML; HTMX's hx-select extracts only the matching element on the client side.

Forms updated

Form Trigger field Target fieldset
InterfaceForm mode dot1q-switching
VMInterfaceForm mode dot1q-switching
CableForm a_terminations_type / b_terminations_type cable-side-a / cable-side-b (hand-rolled template)
ModuleTypeForm profile profile-attributes (dynamic fieldset)
ScopedForm mixin scope_type scope (applied to ClusterForm, WirelessLANForm, PrefixForm)
VLANGroupForm scope_type scope
ServiceForm parent_object_type service
CircuitTerminationForm termination_type circuit-termination
CircuitGroupAssignmentForm member_type circuit-group-assignment
VirtualCircuitTerminationForm role virtual-circuit-termination
TunnelCreateForm termination1_type / termination2_type tunnel-termination1 / tunnel-termination2
TunnelTerminationForm type tunnel-termination
EventRuleForm action_type event-rule-action

Forms intentionally left on full-form swap

Three forms keep HTMXSelect() without hx_fieldset_id because changing their trigger field adds or removes entire fieldsets dynamically — a partial swap would silently miss those additions. Each is annotated with an inline comment explaining the constraint:

  • CustomFieldFormtype change dynamically adds/removes Validation, Related Object, and Choices fieldsets via self.fieldsets = ... in __init__
  • DataSourceFormtype change conditionally appends a Backend Parameters fieldset
  • VirtualMachineFormvirtual_machine_type populates defaults across both the Virtual Machine and Resources fieldsets

Notes

  • FieldSet.id must be a valid CSS identifier (start with a letter). The inline docstring notes this constraint.
  • The CableForm change targets <div> elements in dcim/htmx/cable_edit.html directly (that form uses a hand-rolled HTMX template rather than the fieldset rendering system).
  • Bulk-edit forms (ScopedBulkEditForm, CircuitTerminationBulkEditForm, VLANGroupBulkEditForm) already use an explicit hx-select targeting #form_fields for a different purpose and are unchanged.

Test plan

  • InterfaceForm: change 802.1Q Mode — only the 802.1Q Switching fieldset updates; no full-form flash
  • CableForm: change A Side type — only A Side updates; B Side is unaffected
  • CableForm: change B Side type independently
  • ModuleTypeForm: switch profile — only Profile & Attributes fieldset re-renders; dynamically added attribute fields appear correctly
  • utilities test suite (314 tests): all pass
  • dcim form tests (30 tests): all pass

🤖 Generated with Claude Code


Addendum — Review feedback (Jun 9)

Two naming improvements made in response to Jeremy's review:

FieldSet: fieldset_idhtml_id

The parameter name was redundant given it lives on a class already called FieldSet. Renamed to html_id throughout — rendering.py, form_helpers templatetag, render_fieldset.html, all call sites in forms, and tests.

HTMXSelect: split hx_target_id / hx_fieldset_id into hx_include_id / hx_target_id

Jeremy asked why hx_target_id couldn't simply be overridden instead of needing a separate hx_fieldset_id. The reason: the original hx_target_id controlled both hx-include (the form data source) and the default hx-target. Overriding it would also scope hx-include to just the fieldset, dropping the rest of the form data and breaking server-side dependent-field resolution.

Fixed by separating the two concerns explicitly:

Parameter Controls Default
hx_include_id hx-include — which container's fields are submitted 'form_fields'
hx_target_id hx-target + hx-select — which element to swap None (falls back to hx_include_id)

Jeremy's instinct is now correct: HTMXSelect(hx_target_id='dot1q-switching') does exactly what was expected — hx-include stays #form_fields while hx-target and hx-select narrow to #dot1q-switching.

All call sites, tests, and the ScopedForm runtime guard updated accordingly. The inline comments referencing the old hx_fieldset_id name updated to hx_target_id.

@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bctiemann's task in 4m 13s —— View job


PR Review: feat(ui): HTMX partial fieldset re-rendering for HTMXSelect forms

Todo List

  • Gather context and understand the PR
  • Read changed files
  • Analyze code quality and correctness
  • Check test coverage
  • Post review feedback

Overview

This PR replaces full #form_fields swaps with targeted per-fieldset outerHTML swaps for HTMXSelect fields, eliminating the visible flash on large forms when a select value changes. The mechanism is clean:

  • FieldSet gains an optional fieldset_id= parameter rendered as an HTML id on the <div>
  • HTMXSelect gains hx_fieldset_id=; when set it emits hx-target, hx-select, and hx-swap=outerHTML; hx-include still covers #form_fields so all field values travel to the server
  • No server-side view or template logic changes needed — the server continues returning the full form; HTMX extracts only the matching element client-side

The interaction with the pre-existing hx-disinherit="hx-select hx-swap" on #form_fields (in templates/generic/object_edit.html:65) is correctly handled, since the widget now sets these attributes explicitly on the <select> element rather than relying on inheritance.


Strengths

Elegant design. No server-side changes needed. The change is entirely additive and backward-compatible — existing FieldSets and HTMXSelect instances without the new parameters are completely unaffected.

Good documentation of exceptions. The inline comments on CustomFieldForm, DataSourceForm, and VirtualMachineForm are exactly the right approach — explaining why they cannot use partial swaps rather than just leaving them as is.

Consistent application. All HTMXSelect widgets in the codebase have been audited and appropriately updated or annotated. The PR description table is a useful reference.

Test coverage. FieldSetTestCase and HTMXSelectTestCase in utilities/tests/test_forms.py cover the new widget attributes correctly.


Issues and Suggestions

1. Implicit contract for ScopedForm consumers (mild concern)

ScopedForm.scope_type at dcim/forms/mixins.py:31 hardwires hx_fieldset_id='scope', relying on every consumer to declare FieldSet(..., fieldset_id='scope'). Currently all three consumers (ClusterForm, WirelessLANForm, PrefixForm) do this correctly. But the only enforcement is a code comment. A future consumer that omits fieldset_id='scope' will silently get broken HTMX behavior — the swap targets a non-existent #scope element and does nothing (no error, no warning).

Suggestion: Promote the constraint to the ScopedForm class docstring, or add a __init_subclass__ check in debug mode.

# In ScopedForm or its __init__
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    if settings.DEBUG:
        has_scope_fieldset = any(
            getattr(fs, 'fieldset_id', None) == 'scope'
            for fs in getattr(self, 'fieldsets', [])
        )
        if not has_scope_fieldset:
            import warnings
            warnings.warn(
                f"{self.__class__.__name__} uses ScopedForm but declares no "
                "FieldSet with fieldset_id='scope'; HTMX partial swap will fail silently.",
                stacklevel=2,
            )

Fix this →


2. No validation of fieldset_id as a valid CSS identifier (minor)

The docstring for FieldSet documents the constraint ("Must be a valid CSS identifier: start with a letter…"), but passing fieldset_id='123-bad' renders id="123-bad" in the HTML and hx-target="#123-bad" on the widget, both of which silently fail in a CSS selector context. A DEBUG-mode re.match check in FieldSet.__init__() would surface this early during development.

import re

def __init__(self, *items, name=None, fieldset_id=None):
    if fieldset_id is not None and settings.DEBUG:
        if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', fieldset_id):
            raise ValueError(f"fieldset_id {fieldset_id!r} is not a valid CSS identifier")
    self.items = items
    self.name = name
    self.fieldset_id = fieldset_id

Fix this →


3. hx_target_id naming overlap in HTMXSelect (nit, pre-existing)

In utilities/forms/widgets/select.py:64, hx_target_id is used as both the hx-include container and the fallback hx-target when hx_fieldset_id is absent. The dual role of the same parameter makes the initialiser slightly harder to read. This is pre-existing behaviour; not a blocker for this PR.


4. ServiceForm single-fieldset observation (not an issue, just noting)

ServiceForm has a single fieldset_id='service' that contains all form fields (ipam/forms/model_forms.py:808–814). Swapping #service vs the full #form_fields is technically a smaller swap only if #form_fields contains outer chrome (e.g., custom fields, comments) outside the fieldset. The correctness is not affected, but the UX gain is minimal if all fields live in one fieldset. Still a valid use of the pattern.


Test Coverage

The unit tests at utilities/tests/test_forms.py:631–659 are correct and cover:

  • FieldSet.fieldset_id defaults to None
  • FieldSet.fieldset_id is stored
  • HTMXSelect() (no hx_fieldset_id) targets #form_fields, no hx-select/hx-swap
  • HTMXSelect(hx_fieldset_id=...) sets hx-target, hx-select, hx-swap=outerHTML
  • hx-include remains #form_fields regardless of hx_fieldset_id

The tests are purely attribute-level (not end-to-end render tests), which is appropriate given that no template logic changed.


Summary

The implementation is clean, minimal, and correct. The two items worth acting on before merge are:

  1. The implicit ScopedForm consumer contract — a comment upgrade or a light DEBUG-mode assertion would prevent future silent breakage
  2. The fieldset_id CSS identifier constraint — enforcing it even in a simple DEBUG-mode check would prevent confusing failures

Neither is a blocker, but both would improve maintainability. All existing consumers are correctly wired. The code is ready to merge pending those considerations.

@bctiemann

Copy link
Copy Markdown
Contributor Author

Addressed the two actionable items.

@bctiemann bctiemann requested review from a team and jeremystretch and removed request for a team June 1, 2026 19:59
@bctiemann bctiemann changed the title feat(ui): HTMX partial fieldset re-rendering for HTMXSelect forms Closes: #15165 - HTMX partial fieldset re-rendering for HTMXSelect forms Jun 1, 2026
@bctiemann bctiemann changed the base branch from main to feature June 1, 2026 20:07
bctiemann and others added 6 commits June 1, 2026 16:11
Adds an optional `id` parameter to `FieldSet` and a matching
`hx_fieldset_id` parameter to `HTMXSelect`. When both are set, the
widget emits `hx-select` and retargets `hx-target` to the named
fieldset div rather than `#form_fields`, eliminating the full-form
flash on large forms.

The server always returns the complete form HTML; `hx-select` extracts
only the target element on the client side, so no view or template
changes are required. `hx-include` remains `#form_fields` so all field
values are submitted and dependent-field resolution works correctly.

Wires up `InterfaceForm` as the initial case: the 802.1Q Mode field now
refreshes only the 802.1Q Switching fieldset. Closes #15165 partially —
CableForm and ModuleTypeForm are candidates for a follow-up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ypeForm

Extends the HTMXSelect partial-swap pattern from InterfaceForm to two
more forms:

- CableForm: A Side and B Side termination selects now each target their
  own fieldset div (cable-side-a / cable-side-b) in cable_edit.html,
  so changing one side type no longer re-renders the other side.

- ModuleTypeForm: the Profile selector targets the Profile and Attributes
  fieldset, which holds the dynamically generated attribute fields.
  Partial swap works correctly here because hx-include still sends the
  full form, so the server builds the complete attr_fields list before
  the client extracts just the target fieldset from the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ect forms

Applies the hx_fieldset_id / FieldSet id pattern to all remaining model
forms where partial swap is safe (i.e. the trigger field and all fields
that change in response live within a single fieldset):

- ScopedForm mixin: scope_type targets fieldset id=scope; all three
  consumers (ClusterForm, WirelessLANForm, PrefixForm) annotated
- VLANGroupForm: scope_type / Scope fieldset
- ServiceForm: parent_object_type / Application Service fieldset
- VMInterfaceForm: mode / 802.1Q Switching fieldset (mirrors InterfaceForm)
- CircuitTerminationForm: termination_type / Circuit Termination fieldset
- CircuitGroupAssignmentForm: member_type / Group Assignment fieldset
- VirtualCircuitTerminationForm: role / single fieldset
- TunnelCreateForm: termination1_type and termination2_type each target
  their own First/Second Termination fieldsets independently
- TunnelTerminationForm: type / single fieldset
- EventRuleForm: action_type / Action fieldset

Three forms intentionally left on full-form swap:
- CustomFieldForm: type change adds/removes Validation/Related Object/
  Choices fieldsets dynamically; partial swap would miss those additions
- DataSourceForm: type change adds/removes Backend Parameters fieldset
- VirtualMachineForm: type change populates defaults across both the
  Virtual Machine and Resources fieldsets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CustomFieldForm, DataSourceForm, and VirtualMachineForm each have a
type/kind selector that either adds/removes whole fieldsets dynamically
or populates fields across multiple fieldsets. Partial fieldset swap
would silently miss those changes, so they intentionally stay on the
full hx-target='#form_fields' path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HTMXSelect: add hx-swap=outerHTML when hx_fieldset_id is set, preventing
  progressive div nesting on repeated selections
- FieldSet: rename id parameter to fieldset_id to avoid shadowing builtins;
  update all call sites across circuits, dcim, extras, ipam, virtualization,
  vpn, wireless
- templatetags: use fieldset.fieldset_id directly instead of getattr fallback
- ScopedForm: add comment documenting the fieldset_id contract for consumers
- Add FieldSetTestCase and HTMXSelectTestCase unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FieldSet.__init__: raise ValueError in DEBUG mode if fieldset_id is not a
  valid CSS identifier (must start with a letter)
- ScopedForm.__init__: emit a warnings.warn in DEBUG mode when a subclass
  does not declare a FieldSet with fieldset_id='scope', preventing silent
  HTMX swap failures for future consumers
- Add override_settings tests covering both the valid/invalid CSS identifier
  paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann bctiemann force-pushed the 15165-htmx-partial-fieldset-rerender branch from 503eaab to a9dcec1 Compare June 1, 2026 20:12
Conflicts resolved:
- circuits/forms/model_forms.py: keep hx_fieldset_id='circuit-termination'
  on CircuitTerminationForm (feature branch had plain HTMXSelect())
- render_fieldset.html: combine both changes on the fieldset div —
  id={{ fieldset_id }} from this branch and
  role/aria-label from feature branch are complementary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread netbox/utilities/forms/rendering.py Outdated
Comment thread netbox/utilities/forms/widgets/select.py Outdated
FieldSet: rename fieldset_id parameter to html_id (less redundant, more
generic — it is an HTML id attribute, not fieldset-specific).

HTMXSelect: separate the two concerns that were conflated in hx_target_id:
- hx_include_id (new name for old hx_target_id): the form container whose
  data is submitted with the HTMX request — always #form_fields so that
  the server receives the full form state for dependent-field resolution.
- hx_target_id (new name for old hx_fieldset_id): the element to swap
  and hx-select from the response — defaults to hx_include_id (full swap)
  or can be set to a specific fieldset for partial swaps.

This makes Jeremy's intuition correct: you can now pass just
hx_target_id='dot1q-switching' to target a specific fieldset without
accidentally scoping hx-include to that fieldset too.

Update all call sites, templates, templatetags, and tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann bctiemann force-pushed the 15165-htmx-partial-fieldset-rerender branch from 2e1f599 to 680555d Compare June 9, 2026 18:19
The file was unintentionally modified by a local pre-commit hook that
regenerated graphiql assets. Our changes are Python/template only and
do not affect static bundles.
@bctiemann bctiemann requested a review from jeremystretch June 9, 2026 18:40
Comment thread netbox/utilities/forms/rendering.py
Comment thread netbox/circuits/forms/model_forms.py Outdated
Comment thread netbox/core/forms/model_forms.py Outdated
Comment thread netbox/extras/forms/model_forms.py Outdated
Comment thread netbox/virtualization/forms/model_forms.py Outdated
Comment thread netbox/circuits/forms/model_forms.py
Comment thread netbox/ipam/forms/model_forms.py
Comment thread netbox/ipam/forms/model_forms.py
…add missing html_ids

- Remove HTMXSelect from VirtualCircuitTerminationForm.role: the form renders
  identically regardless of role selection, so the widget was an errant copy-paste
- Fix three comments that still read hx_fieldset_id (stale name) → hx_target_id
  in core, extras, and virtualization forms
- Add html_id='scope' to PrefixBulkAddForm's Scope fieldset (was missing, unlike
  PrefixForm and PrefixBulkEditForm which already had it)
- Add html_id='service' to ServiceCreateForm's Application Service fieldset
  (was missing, unlike the parent ServiceForm which already had it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@bctiemann bctiemann left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review — addressed in the latest push:

  • Dropped the errant HTMXSelect from VirtualCircuitTerminationForm.role
  • Fixed the three stale hx_fieldset_id comments → hx_target_id
  • Added html_id='scope' to PrefixBulkAddForm's Scope fieldset and html_id='service' to ServiceCreateForm's Application Service fieldset

Two open points worth a word:

Auto-generating slugified IDs for FieldSets: I lean toward keeping them explicit for this PR. Auto-generation is appealing, but it would require updating every hx_target_id value in every form and every HTMX hx-target attribute in templates to use the new fieldset-* prefix — a wider blast radius than seems right for a follow-up. Happy to do it as a separate cleanup if you'd prefer consistency over explicitness.

termination_type required=False: The underlying model field is a nullable FK (null=True, blank=True on CircuitTermination.termination_type), so a circuit termination can legitimately be saved with no termination target at all. The required=False corrects what was a latent mismatch with the model — the form was previously required=True by default even though the DB allows null. The form's clean() already handles the "type selected but no termination" case; the only new behaviour is allowing both to be blank.

@bctiemann bctiemann requested a review from jeremystretch June 11, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTMX Allow updating sub-parts of forms without re-rendering entire form

2 participants