Phase 2: convert primitives to nodes via a whitelist element factory

Generic leaf builders (Div, Span, Td, Tr, Th, Ul, Li, Strong, Label,
Template, P) are now generated from one _html_element factory over the
single Element class — the tag name is data, not a per-tag body. Only
elements that add classes/behaviour (Button, Pill, Checkbox, Radio,
Input, A, SearchField, H1, Modal, AddForm, tables) stay hand-written.
All primitives now return Node objects; string-built widgets (Icon,
SimpleTable, YearPicker) return Safe, and YearPicker declares its
datepicker media. Raw concatenation (_popover_html, Popover slot) uses
Fragment.

Node.__str__/__html__ now return a SafeString: a node's rendered output
is safe HTML by construction, so str(node) stays safe when fed back into
a child list or template (matching the old SafeText behaviour and
preventing double-escaping).

Consumers adapted: the form widgets (SearchSelectWidget,
PrimitiveCheckboxWidget) return render(component) so Django gets a safe
string; the session form's manual field markup joins via str(row).
Component tests render nodes to HTML before asserting.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 07:16:59 +00:00
parent f673f3ac80
commit 4031657bb5
6 changed files with 217 additions and 205 deletions
+6
View File
@@ -73,6 +73,7 @@ from common.components.primitives import (
TableTd,
Td,
Template,
Th,
Tr,
Ul,
YearPicker,
@@ -131,6 +132,11 @@ __all__ = [
"Span",
"StaticScript",
"Label",
"Li",
"Td",
"Th",
"Tr",
"Ul",
"TableHeader",
"TableRow",
"TableTd",
+114 -165
View File
@@ -1,4 +1,11 @@
"""Generic HTML primitives (no domain knowledge)."""
"""Generic HTML primitives (no domain knowledge).
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
per tag: they are generated from a whitelist via :func:`_html_element`, each a
thin builder over the single :class:`Element` node class. Only elements that add
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
"""
from django.middleware.csrf import get_token
from django.templatetags.static import static
@@ -6,7 +13,16 @@ from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
from common.components.core import (
Element,
Fragment,
HTMLAttribute,
HTMLTag,
Media,
Node,
Safe,
randomid,
)
from common.icons import get_icon
from common.utils import truncate
@@ -27,18 +43,46 @@ _SIZE_CLASSES = {
}
# ── Generic leaf elements ────────────────────────────────────────────────────
# A whitelist of plain tags, each turned into a builder over `Element`. The
# tag name is data, not a separate class/function body. Add a tag = one line.
def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
def element(
attributes: list[HTMLAttribute] | None = None,
children: "list[HTMLTag] | HTMLTag | Node | None" = None,
) -> Element:
return Element(tag_name, attributes, children)
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
Div = _html_element("div")
P = _html_element("p")
Ul = _html_element("ul")
Li = _html_element("li")
Strong = _html_element("strong")
Span = _html_element("span")
Label = _html_element("label")
Template = _html_element("template")
Td = _html_element("td")
Tr = _html_element("tr")
Th = _html_element("th")
def _popover_html(
id: str,
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
slot: str = "",
) -> SafeText:
"""Generate popover HTML using Component(tag_name=...).
Single source of truth for popover HTML structure.
Used by Popover() and the python_popover template tag bridge.
"""
slot: "Node | str" = "",
) -> Node:
"""Generate popover HTML. Single source of truth for popover structure."""
display_content = wrapped_content if wrapped_content else slot
span = Span(
@@ -79,7 +123,7 @@ def _popover_html(
],
)
return mark_safe(span + "\n" + div)
return Fragment(span, div, separator="\n")
def Popover(
@@ -89,14 +133,14 @@ def Popover(
children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] | None = None,
id: str = "",
) -> str:
) -> Node:
children = children or []
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
if not id:
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
slot = mark_safe("\n".join(children))
slot = Fragment(*children, separator="\n") if children else ""
return _popover_html(
id=id,
popover_content=popover_content,
@@ -113,7 +157,7 @@ def PopoverTruncated(
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
) -> "Node | str":
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
@@ -143,7 +187,7 @@ def A(
children: list[HTMLTag] | HTMLTag | None = None,
url_name: str | None = None,
href: str | None = None,
) -> SafeText:
) -> Element:
"""
Returns an anchor <a> tag.
@@ -161,8 +205,8 @@ def A(
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
return Element(
"a", attributes=attributes + additional_attributes, children=children
)
@@ -179,7 +223,7 @@ def Button(
title: str = "",
onclick: str = "",
name: str = "",
) -> SafeText:
) -> Element:
attributes = attributes or []
children = children or []
@@ -224,8 +268,8 @@ def Button(
button_attrs.append(("name", name))
button_attrs.extend(other_attrs)
return Component(
tag_name="button",
return Element(
"button",
attributes=button_attrs,
children=children,
)
@@ -267,7 +311,7 @@ def _button_group_button(
title: str = "",
hx_get: str = "",
hx_target: str = "",
) -> SafeText:
) -> Element:
"""Generate a single button-group button (inner <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
@@ -284,8 +328,8 @@ def _button_group_button(
)
)
button = Component(
tag_name="button",
button = Element(
"button",
attributes=[
("type", "button"),
("title", title),
@@ -294,10 +338,10 @@ def _button_group_button(
children=[slot],
)
return Component(tag_name="a", attributes=a_attrs, children=[button])
return Element("a", attributes=a_attrs, children=[button])
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
"""Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
@@ -305,7 +349,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
for conditional buttons (e.g., end-session only when session is active).
"""
buttons = buttons or []
children: list[SafeText] = []
children: list[Node] = []
for btn in buttons:
if not btn or not btn.get("slot"):
continue
@@ -326,79 +370,14 @@ def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
)
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def P(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="p", attributes=attributes, children=children)
def Ul(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="ul", attributes=attributes, children=children)
def Li(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="li", attributes=attributes, children=children)
def Strong(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="strong", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
) -> Element:
attributes = attributes or []
children = children or []
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Span(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="span", attributes=attributes, children=children)
def Label(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="label", attributes=attributes, children=children)
return Element("input", attributes=attributes + [("type", type)], children=children)
def Checkbox(
@@ -407,7 +386,7 @@ def Checkbox(
checked: bool = False,
value: str = "1",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
) -> Node:
"""A filter-agnostic Checkbox component."""
attributes = attributes or []
input_attrs = [
@@ -439,7 +418,7 @@ def Radio(
checked: bool = False,
value: str = "",
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
) -> Node:
"""A filter-agnostic Radio component."""
attributes = attributes or []
input_attrs = [
@@ -465,16 +444,6 @@ def Radio(
)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class
@@ -494,7 +463,7 @@ def Pill(
extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
) -> Node:
"""A small label pill, optionally removable (× button).
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
@@ -520,8 +489,8 @@ def Pill(
children: list[HTMLTag] = [label_child]
if removable:
children.append(
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
@@ -560,11 +529,16 @@ def StaticScript(filename: str) -> SafeText:
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
# on the YearPicker node so Page() loads it wherever a YearPicker appears.
_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
def YearPicker(
year: int | None = None,
available_years: tuple[int, ...] = (),
url_template: str = "",
) -> SafeText:
) -> Node:
"""A Flowbite-datepicker year picker.
`year` is the selected year, or ``None`` for the all-time view (the empty
@@ -573,8 +547,8 @@ def YearPicker(
placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names).
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
via ``render_page(scripts=...)``.
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
node, so ``Page()`` loads it automatically.
"""
label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else ""
@@ -585,7 +559,8 @@ def YearPicker(
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
)
years_csv = ",".join(str(y) for y in available_years)
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
return Safe(
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
@keydown.escape.window="pickerOpen = false">
<button type="button"
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
@@ -638,7 +613,9 @@ document.addEventListener('DOMContentLoaded', () => {{
picker.update();
}}
}});
</script>""")
</script>""",
media=_YEAR_PICKER_MEDIA,
)
def AddForm(
@@ -648,7 +625,7 @@ def AddForm(
fields: SafeText | str | None = None,
additional_row: SafeText | str = "",
submit_class: str = "mt-3",
) -> SafeText:
) -> Node:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
@@ -660,8 +637,8 @@ def AddForm(
field_markup = fields if fields is not None else mark_safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Component(
tag_name="form",
inner_form = Element(
"form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[
CsrfInput(request),
@@ -689,10 +666,10 @@ def SearchField(
search_string: str = "",
id: str = "search_string",
placeholder: str = "Search",
) -> SafeText:
) -> Element:
"""Generate a search form with icon, input field, and submit button."""
return Component(
tag_name="form",
return Element(
"form",
attributes=[("class", "max-w-md")],
children=[
Label(
@@ -730,8 +707,8 @@ def SearchField(
("required", ""),
],
),
Component(
tag_name="button",
Element(
"button",
attributes=[
("type", "submit"),
(
@@ -754,11 +731,11 @@ def SearchField(
def H1(
children: list[HTMLTag] | HTMLTag | None = None,
badge: str = "",
) -> SafeText:
) -> Element:
"""Heading with optional badge count."""
children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
badge_html = ""
badge_html: Node | str = ""
if badge:
heading_class = "flex items-center " + heading_class
@@ -773,8 +750,8 @@ def H1(
children=[badge],
)
return Component(
tag_name="h1",
return Element(
"h1",
attributes=[("class", heading_class)],
children=(children if isinstance(children, list) else [children])
+ ([badge_html] if badge_html else []),
@@ -784,10 +761,10 @@ def H1(
def Modal(
modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
) -> Node:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
outer = Div(
return Div(
attributes=[
("id", modal_id),
(
@@ -809,39 +786,11 @@ def Modal(
),
],
)
return mark_safe(str(outer))
def Td(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="td", attributes=attributes, children=children)
def Tr(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="tr", attributes=attributes, children=children)
def Th(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="th", attributes=attributes, children=children)
def TableTd(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
) -> Element:
"""Styled table cell."""
children = children or []
return Td(
@@ -850,7 +799,7 @@ def TableTd(
)
def TableRow(data: dict | list | None = None) -> SafeText:
def TableRow(data: dict | list | None = None) -> Element:
"""Generate a <tr> from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
@@ -885,7 +834,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"]))
cell_elements: list[SafeText] = []
cell_elements: list[Node] = []
for i, cell in enumerate(cells):
if i == 0:
cell_elements.append(
@@ -910,17 +859,17 @@ def TableRow(data: dict | list | None = None) -> SafeText:
def Icon(
name: str,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
return mark_safe(get_icon(name))
) -> Node:
return Safe(get_icon(name))
def TableHeader(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
) -> Element:
"""Table caption."""
children = children or []
return Component(
tag_name="caption",
return Element(
"caption",
attributes=[
(
"class",
@@ -1011,7 +960,7 @@ def SimpleTable(
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
) -> Node:
"""Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or []
rows = rows or []
@@ -1030,7 +979,7 @@ def SimpleTable(
if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
return mark_safe(
return Safe(
'<div class="shadow-md" hx-boost="false">'
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
@@ -1050,7 +999,7 @@ def paginated_table_content(
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
) -> Node:
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and
+34 -21
View File
@@ -6,6 +6,7 @@ from common.components import (
DEFAULT_PREFETCH,
SearchSelect,
SearchSelectOption,
render,
searchselect_selected,
)
from common.components.primitives import Checkbox
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
attributes = [
(k, str(v))
for k, v in final_attrs.items()
if k not in ("type", "name", "value", "checked")
]
# Django uses boolean values differently for checkboxes, we omit value if empty
return str(Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes
))
# render() returns a safe string (Django widgets must not be autoescaped).
return render(
Checkbox(
name=name,
label=None,
checked=checked,
value=str(value) if value else "1",
attributes=attributes,
)
)
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
@@ -130,19 +140,22 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect(
name=name,
selected=selected,
options=None,
search_url=self.search_url,
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
# Django widgets must return a safe string; the component is a node.
return render(
SearchSelect(
name=name,
selected=selected,
options=None,
search_url=self.search_url,
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
)
def value_from_datadict(self, data, files, name):
+1 -1
View File
@@ -236,7 +236,7 @@ def _session_fields(form) -> SafeText:
)
)
rows.append(Div(children=children))
return mark_safe("\n".join(rows))
return mark_safe("\n".join(str(row) for row in rows))
@login_required
+38 -2
View File
@@ -5,10 +5,36 @@ import django
from django.utils.safestring import SafeText, mark_safe
from common import components
from common import components as _components
from common.components.core import Node
from games.models import Platform, Game, Purchase, Session
class _RenderingComponents:
"""Test accessor that renders lazy component nodes to safe HTML strings.
Component builders now return ``Node`` objects (the lazy tree). These tests
assert on rendered HTML, so we render any node a capitalized builder returns
to a ``SafeText`` string. Internals (``_render_element``) and the legacy
string-returning ``Component()`` are untouched (non-node results pass
through), so cache/escaping tests keep working unchanged.
"""
def __getattr__(self, name):
attr = getattr(_components, name)
if not (callable(attr) and name[:1].isupper()):
return attr
def rendered(*args, **kwargs):
result = attr(*args, **kwargs)
return str(result) if isinstance(result, Node) else result
return rendered
components = _RenderingComponents()
class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent."""
@@ -822,7 +848,16 @@ class SimpleTableRenderingTest(unittest.TestCase):
from django.test import SimpleTestCase
from common.components.primitives import Checkbox, Radio
from common.components.primitives import Checkbox as _Checkbox, Radio as _Radio
# Checkbox/Radio are lazy nodes; render to safe HTML for the assertions below.
def Checkbox(*args, **kwargs):
return str(_Checkbox(*args, **kwargs))
def Radio(*args, **kwargs):
return str(_Radio(*args, **kwargs))
class ComponentPrimitivesTest(SimpleTestCase):
@@ -867,6 +902,7 @@ class PrimitiveWidgetsTest(SimpleTestCase):
def test_primitive_checkbox_widget_renders_headless(self):
from games.forms import PrimitiveCheckboxWidget
widget = PrimitiveCheckboxWidget()
html = widget.render(name="agree", value=True)
self.assertNotIn("<label", html)
+23 -15
View File
@@ -7,14 +7,30 @@ import django.test
from django.utils.safestring import SafeText
from common.components import (
FilterSelect,
Pill,
SearchSelect,
searchselect_selected,
)
from common.components import FilterSelect as _FilterSelect
from common.components import Pill as _Pill
from common.components import SearchSelect as _SearchSelect
from games.models import Game, Platform
# These components are now lazy nodes; the tests below assert on rendered HTML.
# Render at the call site so existing string assertions (assertIn / .count /
# .index / .split) keep working, and ``isinstance(..., SafeText)`` confirms the
# rendered output is safe markup.
def SearchSelect(*args, **kwargs):
return str(_SearchSelect(*args, **kwargs))
def FilterSelect(*args, **kwargs):
return str(_FilterSelect(*args, **kwargs))
def Pill(*args, **kwargs):
return str(_Pill(*args, **kwargs))
class PillTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(Pill("hi"), SafeText)
@@ -201,9 +217,7 @@ class FilterSelectComponentTest(unittest.TestCase):
# Both the modifier pill and the value pill render.
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
self.assertIn(
'data-search-select-type="include"', html
) # value pill present
self.assertIn('data-search-select-type="include"', html) # value pill present
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -250,15 +264,9 @@ class FilterSelectComponentTest(unittest.TestCase):
("INCLUDES_ONLY", "(Only)"),
],
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ALL"', html
)
self.assertIn(
'data-search-select-modifier-option="INCLUDES_ONLY"', html
)
self.assertIn(
'data-search-select-modifier-option="NOT_NULL"', html
)
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
# No legacy match-mode <select>.
self.assertNotIn("data-search-select-match", html)