{% extends "base.html" %} {% load static %} {% load common_extras %} {% comment %} The CRM list (issue #13). Three regions, and the split is what keeps it cheap: a toolbar plus filter builder that is one
, a bulk bar driven by Alpine, and `#contact-rows` — the only thing htmx ever swaps. Every control in that form submits the same GET, so the filter, the search term and the sort are one set of parameters with one canonical spelling. Two forms would mean a search keystroke that silently dropped the filter beside it. Loading a **segment** is the one control that navigates for real rather than swapping. It has to: picking a saved filter changes the rule editor as well as the rows, and the server is the only thing that has the segment's stored document — shipping every segment's rules to the browser so the client could hydrate one would put a workspace's whole filter library in every page load. `hx-push-url` is not used here even though the URL does track the view. The form GETs the rows partial, so pushing *its* URL would give the reader a bookmark to a bare ; `contacts:rows` sets `HX-Push-Url` to the list URL instead, which is the address the reader actually wants. No inline event handlers anywhere. The CSP allows 'unsafe-eval' for Alpine's x-* expressions but NOT 'unsafe-inline', so an `onclick=` attribute is silently inert — a button that looks fine and does nothing. {% endcomment %} {% block title %}Contacts · DemandTrail Messaging{% endblock %} {% block extra_head %} {% comment %} Vendored, not a CDN (deviation 6). This page is the project's first consumer: the date operators in the filter builder need a picker, and styles.css has themed flatpickr since L1-B in anticipation. {% endcomment %} {% endblock %} {% block page_header %} {% endblock %} {% block content %} {% comment %} Selection lives on this wrapper, OUTSIDE #contact-rows: htmx replaces that element wholesale on every search keystroke, and a selection stored inside it would be discarded by the very swap that redraws the checkboxes. `.camel` is load-bearing on the listener. The HTML parser lowercases attribute names, so `@contactsChanged` reaches Alpine as `contactschanged` and would never match htmx's `contactsChanged` event; `.camel` on a kebab-cased name is the documented way back. htmx reads the *value* of hx-trigger, so it keeps its capitals without any of this. {% endcomment %}
{% comment %} Saved views, as chips rather than as a dropdown. A segment is the answer to "who am I looking at", and the redesign puts the two or three a workspace actually uses in front of the reader instead of behind a select they have to open to discover. Real links, not htmx: picking a saved filter changes the rule editor as well as the rows, and the server is the only thing holding the segment's stored document — shipping every segment's rules to the browser so the client could hydrate one would put a workspace's whole filter library in every page load. {% endcomment %} {% if filter_config.segments %} {% endif %} {% comment %} The filter component wraps BOTH forms rather than sitting on the toolbar. Saving a segment is a POST and the toolbar is a GET, so they cannot be one form — and forms cannot nest — but they must serialise the same rules, or a segment could be saved as something other than what the list is showing. {% endcomment %}
{% comment %} The three questions people ask of a contact list most — which channel, which tag, still subscribed — in the one Filter popover every list has (HANDOFF §2.2). Several values per group; they narrow whatever the rules or the saved filter select. See apps/contacts/filters.py. {% endcomment %} {% filter_popover groups=filter_groups onchange="reload()" target="#contact-rows" count=page.paginator.count noun="contact" %} {% comment %} The rule builder is a powerful thing most readers do not want on most visits — it is how a segment gets made, not how a list gets read. It starts closed behind this button, and opens already open when the current view has rules in it, so nobody is shown a filtered list with no visible reason for it. {% endcomment %}
{% filter_chips groups=filter_groups onchange="reload()" %}
{% include "contacts/_filter_bar.html" %}
{% comment %} The three segment forms, out here because a form cannot nest inside the toolbar's and they all POST while it GETs. Each reads the same Alpine `serialised`, so no two of them can describe different rules. Saving a new one asks for its name in a modal. The dialog stays where the hidden form used to be, inside this scope, so `serialised` still reaches it: a opened with showModal() is drawn in the top layer without moving in the DOM. A successful update or delete reloads rather than swapping: both change what the *filter builder* should be showing, and the builder is server-hydrated — a toast alone would leave the page describing a segment that no longer says what the panel shows, or one that is gone. {% endcomment %} {% if can_manage_crm %} {% modal "save-segment" title="Save as a segment" close_on="segmentsChanged" %}
It keeps the filter rules you have set right now.
{% endmodal %} {% if filter_config.segmentId %} {% endif %} {% endif %}
{% if can_edit_contacts %}
{% endif %} {% comment %} Sequences (#22). The endpoint has been here since #13 answering politely, so this is a control being enabled rather than a route arriving. Two submit buttons rather than a mode select, matching the tag form above it: `mode` travels on whichever button was pressed. {% endcomment %} {% if can_manage_crm and enrollable_sequences %}
{% endif %} {% if can_manage_crm %}
{% endif %} {% comment %} Erase, beside Delete rather than instead of it: they are different acts. Admin only, and the typed sentinel is checked in the view — hx-confirm cannot be. See apps/contacts/views.py::bulk_erase. {% endcomment %} {% if can_erase_contacts %}
{% endif %}
{% include "contacts/_rows.html" %}
{% endblock %} {% block alpine_components %} {% comment %} Defined in this block rather than inside x-data because base.html loads Alpine *deferred, after* it — which is exactly the window in which a global function definition has to land for x-data to be able to call it. {% endcomment %} {% include "contacts/_filter_bar_script.html" %} {% endblock %} {% block extra_js %} {% endblock %}