wagtail-developer-reference 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {wagtail_developer_reference-0.1.2/wagtail_developer_reference.egg-info → wagtail_developer_reference-0.1.4}/PKG-INFO +9 -6
  2. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/README.md +8 -5
  3. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/pyproject.toml +1 -1
  4. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/setup.py +1 -1
  5. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/migrations/0001_initial.py +1 -2
  6. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/templates/detail.html +63 -6
  7. wagtail_developer_reference-0.1.4/wagtail_developer_reference/templates/index.html +350 -0
  8. wagtail_developer_reference-0.1.4/wagtail_developer_reference/tests/__init__.py +0 -0
  9. wagtail_developer_reference-0.1.4/wagtail_developer_reference/tests/test_auditor.py +100 -0
  10. wagtail_developer_reference-0.1.4/wagtail_developer_reference/wagtail_hooks.py +744 -0
  11. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4/wagtail_developer_reference.egg-info}/PKG-INFO +9 -6
  12. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference.egg-info/SOURCES.txt +3 -1
  13. wagtail_developer_reference-0.1.2/wagtail_developer_reference/templates/index.html +0 -164
  14. wagtail_developer_reference-0.1.2/wagtail_developer_reference/wagtail_hooks.py +0 -334
  15. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/MANIFEST.in +0 -0
  16. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/setup.cfg +0 -0
  17. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/__init__.py +0 -0
  18. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/apps.py +0 -0
  19. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/migrations/__init__.py +0 -0
  20. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference/models.py +0 -0
  21. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference.egg-info/dependency_links.txt +0 -0
  22. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference.egg-info/requires.txt +0 -0
  23. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.4}/wagtail_developer_reference.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wagtail-developer-reference
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A visual auditing tool for Wagtail developers to track Page models and StreamField blocks.
5
5
  Author-email: Four Digits <info@fourdigits.nl>
6
6
  Project-URL: Homepage, https://github.com/fourdigits/wagtail-developer-reference
@@ -32,14 +32,17 @@ Requires-Dist: pytest-django; extra == "dev"
32
32
  In large Wagtail projects, it’s easy to lose track of where specific blocks are used or which templates are being rendered. This tool gives you a "God-view" of your components without needing to dig through the database or shell.
33
33
 
34
34
  ## Features
35
- * **Usage Tracking:** See exactly how many times a Page type or StreamField block is used across the site.
36
- * **Template Discovery:** Instantly see the file path for the template associated with every component.
37
- * **Interactive Audit:** Filter by type or search for specific field names to find where logic is implemented.
38
- * **Zero-Config:** No extra database tables or complex settings. It inspects your models and content on the fly.
35
+ * **Fast registry:** Code definitions are cached per worker; the normal registry response stays small and paginates at 20 rows.
36
+ * **On-demand exact usage:** PAGE and BLOCK counts are calculated only for a usage range, scoped to the exact page model, StreamField and nested definition path.
37
+ * **Scalable StreamField inspection:** Usage scans read raw StreamField JSON, avoiding chooser-object queries while keeping totals current.
38
+ * **Searchable block picker:** An accessible, collapsed picker searches all definitions server-side and returns at most 30 choices at once.
39
+ * **Multi-site aware:** Site root pages are included, nested subsites are excluded from parent-site counts, and an empty site filter aggregates globally.
40
+ * **Usage inspection:** Open an exact definition's paginated page list and copy the visible usage table in one click.
41
+ * **Template discovery:** See the template path associated with every component.
39
42
 
40
43
  ## Installation
41
44
 
42
- Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
45
+ Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4.2 LTS. Wagtail 7.4
43
46
  itself requires Python 3.10+.
44
47
 
45
48
  1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
@@ -6,14 +6,17 @@
6
6
  In large Wagtail projects, it’s easy to lose track of where specific blocks are used or which templates are being rendered. This tool gives you a "God-view" of your components without needing to dig through the database or shell.
7
7
 
8
8
  ## Features
9
- * **Usage Tracking:** See exactly how many times a Page type or StreamField block is used across the site.
10
- * **Template Discovery:** Instantly see the file path for the template associated with every component.
11
- * **Interactive Audit:** Filter by type or search for specific field names to find where logic is implemented.
12
- * **Zero-Config:** No extra database tables or complex settings. It inspects your models and content on the fly.
9
+ * **Fast registry:** Code definitions are cached per worker; the normal registry response stays small and paginates at 20 rows.
10
+ * **On-demand exact usage:** PAGE and BLOCK counts are calculated only for a usage range, scoped to the exact page model, StreamField and nested definition path.
11
+ * **Scalable StreamField inspection:** Usage scans read raw StreamField JSON, avoiding chooser-object queries while keeping totals current.
12
+ * **Searchable block picker:** An accessible, collapsed picker searches all definitions server-side and returns at most 30 choices at once.
13
+ * **Multi-site aware:** Site root pages are included, nested subsites are excluded from parent-site counts, and an empty site filter aggregates globally.
14
+ * **Usage inspection:** Open an exact definition's paginated page list and copy the visible usage table in one click.
15
+ * **Template discovery:** See the template path associated with every component.
13
16
 
14
17
  ## Installation
15
18
 
16
- Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
19
+ Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4.2 LTS. Wagtail 7.4
17
20
  itself requires Python 3.10+.
18
21
 
19
22
  1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wagtail-developer-reference"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "A visual auditing tool for Wagtail developers to track Page models and StreamField blocks."
5
5
  readme = "README.md"
6
6
  authors = [{name = "Four Digits", email = "info@fourdigits.nl" }]
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="wagtail-developer-reference",
5
- version="0.1.2",
5
+ version="0.1.4",
6
6
  packages=find_packages(),
7
7
  include_package_data=True,
8
8
  install_requires=[
@@ -1,7 +1,6 @@
1
1
  # Generated manually for wagtail-developer-reference.
2
2
 
3
- from django.db import migrations
4
- from django.db import models
3
+ from django.db import migrations, models
5
4
 
6
5
 
7
6
  class Migration(migrations.Migration):
@@ -98,6 +98,25 @@
98
98
 
99
99
  .empty-state p { font-size: 1.2rem; }
100
100
 
101
+ .registry-controls {
102
+ display: flex;
103
+ gap: 1rem;
104
+ margin-bottom: 1.5rem;
105
+ align-items: end;
106
+ }
107
+
108
+ .registry-controls {
109
+ background: #333; border-left: 0.3125rem solid #dcbd3d; padding: 1rem;
110
+ }
111
+ .registry-controls div:first-of-type { flex: 1; }
112
+ .registry-controls label { color: white; display: block; font-weight: bold; margin-bottom: 0.5rem; }
113
+ .registry-controls input {
114
+ background: #222; border: 0.0625rem solid #dcbd3d; box-sizing: border-box;
115
+ color: white; min-height: 2.5rem; padding: 0.6rem; width: 100%;
116
+ }
117
+ .usage-table-actions { display: flex; justify-content: flex-end; margin-top: 1rem; }
118
+ .copy-table-button { border-radius: 0; }
119
+
101
120
  [data-wagtail-theme="dark"] .listing tbody tr:hover {
102
121
  background-color: #333 !important;
103
122
  }
@@ -110,12 +129,22 @@
110
129
  <div class="breadcrumb">
111
130
  <a href="{% url 'dev_reference' %}">&larr; Back to System Registry</a>
112
131
  </div>
113
- <h1>Locations for: <strong>{{ component_name }}</strong></h1>
114
- <p>Found in {{ pages|length }} location{{ pages|length|pluralize }}</p>
132
+ <h1>Usage: <strong>{{ component_name }}</strong></h1>
133
+ <p>{{ page_obj.paginator.count }} location{{ page_obj.paginator.count|pluralize }} found</p>
115
134
  </div>
116
135
  </header>
117
136
 
118
137
  <div class="nice-padding">
138
+ <form method="get" class="registry-controls" role="search">
139
+ <input type="hidden" name="site" value="{{ site_filter }}">
140
+ <div>
141
+ <label for="usage-search">Search pages</label>
142
+ <input id="usage-search" type="search" name="q" value="{{ search_query }}" placeholder="Filter by page title or slug...">
143
+ </div>
144
+ <div>
145
+ <button type="submit" class="button">Search</button>
146
+ </div>
147
+ </form>
119
148
  <table class="listing" id="usage-table">
120
149
  <thead>
121
150
  <tr>
@@ -130,14 +159,14 @@
130
159
  <tr class="usage-row">
131
160
  <td>
132
161
  <strong class="page-title">{{ page.title }}</strong>
133
- <small class="page-type">Type: {{ page.get_verbose_name }}</small>
162
+ <small class="page-type">Type: {{ page.content_type.name|title }}</small>
134
163
  </td>
135
164
  <td class="td-status" data-sort="{% if page.live %}1{% else %}0{% endif %}">
136
165
  <span class="status-tag status-tag--{% if page.live %}live{% else %}draft{% endif %}">
137
166
  {% if page.live %}Live{% else %}Draft{% endif %}
138
167
  </span>
139
168
  </td>
140
- <td><code class="url-code">{{ page.url|default:"/(no slug)/" }}</code></td>
169
+ <td><code class="url-code">{{ page.url_path|default:"/(no path)/" }}</code></td>
141
170
  <td class="col-actions">
142
171
  <a href="{% url 'wagtailadmin_pages:edit' page.id %}" class="button button-small edit-button" target="_blank">Edit Page</a>
143
172
  </td>
@@ -151,12 +180,38 @@
151
180
  {% endfor %}
152
181
  </tbody>
153
182
  </table>
183
+ <div class="usage-table-actions">
184
+ <button id="copy-usage-table" type="button" class="button button-secondary copy-table-button">Copy displayed table</button>
185
+ </div>
186
+ {% if page_obj.paginator.num_pages > 1 %}
187
+ <nav class="pagination" aria-label="Usage result pages">
188
+ {% if page_obj.has_previous %}
189
+ <a class="button" href="?site={{ site_filter|urlencode }}&amp;q={{ search_query|urlencode }}&amp;page={{ page_obj.previous_page_number }}">Previous</a>
190
+ {% endif %}
191
+ <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
192
+ {% if page_obj.has_next %}
193
+ <a class="button" href="?site={{ site_filter|urlencode }}&amp;q={{ search_query|urlencode }}&amp;page={{ page_obj.next_page_number }}">Next</a>
194
+ {% endif %}
195
+ </nav>
196
+ {% endif %}
154
197
  </div>
155
198
 
156
199
  <script>
157
200
  let currentSortCol = -1;
158
201
  let sortAsc = true;
159
202
 
203
+ function copyUsageTable() {
204
+ const headers = ["Page Title", "Status", "URL Path"];
205
+ const rows = Array.from(document.querySelectorAll("#usage-body .usage-row"));
206
+ const values = rows.map(row => [0, 1, 2].map(index => row.cells[index].innerText.trim().replace(/\s+/g, " ")));
207
+ const text = [headers, ...values].map(row => row.join("\t")).join("\n");
208
+ const button = document.getElementById("copy-usage-table");
209
+ navigator.clipboard.writeText(text).then(() => {
210
+ button.textContent = "Copied";
211
+ window.setTimeout(() => { button.textContent = "Copy displayed table"; }, 1500);
212
+ });
213
+ }
214
+
160
215
  function sortTable(colIdx) {
161
216
  const tbody = document.getElementById("usage-body");
162
217
  const rows = Array.from(tbody.querySelectorAll(".usage-row"));
@@ -173,12 +228,14 @@
173
228
  if (colIdx === 1) {
174
229
  valA = parseInt(a.cells[colIdx].getAttribute('data-sort'));
175
230
  valB = parseInt(b.cells[colIdx].getAttribute('data-sort'));
176
- return sortAsc ? valA - valB : valB - valueA;
231
+ return sortAsc ? valA - valB : valB - valA;
177
232
  }
178
233
  valA = a.cells[colIdx].textContent.trim().toLowerCase();
179
234
  valB = b.cells[colIdx].textContent.trim().toLowerCase();
180
235
  return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
181
236
  }).forEach(row => tbody.appendChild(row));
182
237
  }
238
+
239
+ document.getElementById("copy-usage-table").addEventListener("click", copyUsageTable);
183
240
  </script>
184
- {% endblock %}
241
+ {% endblock %}
@@ -0,0 +1,350 @@
1
+ {% extends "wagtailadmin/base.html" %}
2
+ {% block titletag %}System Registry{% endblock %}
3
+
4
+ {% block extra_css %}
5
+ <style>
6
+ .registry-controls {
7
+ display: flex; gap: 1rem; margin-bottom: 1.5rem;
8
+ background-color: #4a4a4a; padding: 1.25rem; border-left: 5px solid #dcbd3d;
9
+ align-items: end;
10
+ }
11
+ .registry-controls { flex-wrap: wrap; }
12
+ .registry-controls div { flex: 1 1 10rem; }
13
+ .registry-controls .registry-search { flex: 3 1 20rem; }
14
+ .registry-controls .usage-range { flex: 0 1 6rem; }
15
+ .registry-controls .filter-actions {
16
+ align-items: end; display: flex; flex: 0 0 auto; gap: 0.5rem;
17
+ }
18
+ .registry-controls .filter-actions .button { margin: 0; white-space: nowrap; }
19
+ .quick-search {
20
+ background: #333; border-left: 5px solid #dcbd3d; margin-bottom: 1rem;
21
+ }
22
+ .quick-search summary {
23
+ color: white; cursor: pointer; font-weight: bold; padding: 0.9rem 1.25rem;
24
+ }
25
+ .quick-search-filter {
26
+ box-sizing: border-box; margin: 0 1.25rem 0.75rem; max-width: 34rem;
27
+ min-height: 2.5rem; padding: 0.5rem; width: calc(100% - 2.5rem);
28
+ }
29
+ .quick-search-status { color: #ccc; display: block; font-size: 0.85rem; padding: 0 1.25rem 0.75rem; }
30
+ .quick-search-list {
31
+ display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0 1.25rem 1.25rem;
32
+ }
33
+ .quick-search-item {
34
+ background: #222; border: 1px solid #777; color: white; cursor: pointer;
35
+ font: inherit; padding: 0.35rem 0.5rem; text-align: left;
36
+ }
37
+ .quick-search-item:hover, .quick-search-item:focus {
38
+ border-color: #dcbd3d; color: #dcbd3d;
39
+ }
40
+ .quick-search-item .type-tag { margin-right: 0.35rem; }
41
+ .registry-controls label { display: block; font-weight: bold; color: white; margin-bottom: 0.5rem; }
42
+ .registry-controls input, .registry-controls select {
43
+ box-sizing: border-box; height: 2.5rem; width: 100%;
44
+ border: 1px solid #dcbd3d; background: #333; color: white; border-radius: 0;
45
+ }
46
+
47
+ .listing thead tr { border-bottom: 2px solid #dcbd3d; }
48
+ .listing tbody td { vertical-align: top; }
49
+ .col-centered { text-align: center; width: 5rem; }
50
+ .col-site { width: 10rem; }
51
+ .col-type { width: 8rem; white-space: nowrap; }
52
+
53
+ .sortable { cursor: pointer; position: relative; transition: background 0.2s; }
54
+ .sortable:hover { background: #444; }
55
+ .sortable::after {
56
+ content: '↕';
57
+ font-size: 0.8rem;
58
+ margin-left: 5px;
59
+ opacity: 0.3;
60
+ }
61
+
62
+ .type-tag {
63
+ background: #222; color: #dcbd3d; border: 1px solid #dcbd3d;
64
+ padding: 2px 10px; text-transform: uppercase; font-size: 0.7rem;
65
+ display: inline-block; white-space: nowrap;
66
+ cursor: default; pointer-events: none;
67
+ }
68
+ .type-tag--page { background: #153a5b; border-color: #5db6ff; color: #b9e2ff; }
69
+ .type-tag--block { background: #3c2459; border-color: #bf8cff; color: #e2c8ff; }
70
+
71
+ .field-pill {
72
+ background: #222; border: 1px solid #dcbd3d; padding: 2px 6px;
73
+ display: inline-block; font-size: 0.75rem; white-space: nowrap;
74
+ }
75
+ .field-pill--metadata { border-color: #777; background: #333; }
76
+ .field-type { color: #aaa; font-size: 0.8em; }
77
+
78
+ .field-toggle {
79
+ background: #222;
80
+ border: 1px solid #dcbd3d;
81
+ color: #dcbd3d;
82
+ cursor: pointer;
83
+ font-size: 0.75rem;
84
+ padding: 0.3rem 0.5rem;
85
+ text-align: left;
86
+ }
87
+ .field-toggle:hover { background: #333; }
88
+ .field-toggle::before { content: "▸"; margin-right: 0.4rem; }
89
+ .field-toggle[aria-expanded="true"]::before { content: "▾"; }
90
+ .field-cell { position: relative; }
91
+ .field-details {
92
+ align-items: flex-start; background: #222; border: 1px solid #dcbd3d;
93
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.35); display: flex;
94
+ flex-wrap: wrap; gap: 0.25rem; left: 0; margin-top: 0.5rem;
95
+ max-width: 36rem; min-width: 20rem; padding: 0.5rem; position: absolute;
96
+ top: 100%; z-index: 10;
97
+ }
98
+ .field-details[hidden] { display: none; }
99
+
100
+ .count-draft { color: #dcbd3d; font-weight: bold; }
101
+ .count-live { color: #1fb53a; font-weight: bold; }
102
+ .zero-usage { color: #af7f7f; opacity: 0.5; }
103
+
104
+ .usage-link { background: #dcbd3d !important; color: #222 !important; font-weight: bold; border-radius: 0; }
105
+ .template-label { color: #ccc; font-family: monospace; font-size: 0.7rem; }
106
+ .template-copy { margin-left: 0.35rem; padding: 0.15rem 0.35rem; }
107
+ .template-empty { color: #aaa; }
108
+
109
+ [data-wagtail-theme="dark"] .listing tbody tr:hover {
110
+ background-color: #333 !important;
111
+ }
112
+ </style>
113
+ {% endblock %}
114
+
115
+ {% block content %}
116
+ {% include "wagtailadmin/shared/header.html" with title="System Registry" icon="code" %}
117
+ <div class="nice-padding">
118
+ <details class="quick-search">
119
+ <summary>Browse pages and blocks</summary>
120
+ <input id="quick-search-filter" class="quick-search-filter" type="search" placeholder="Search pages and blocks..." autocomplete="off">
121
+ <span id="quick-search-status" class="quick-search-status" aria-live="polite"></span>
122
+ <div class="quick-search-list" aria-label="Quick registry search">
123
+ {% for item in quick_search_items %}
124
+ <button type="button" class="quick-search-item" data-search="{{ item.hierarchy }}" data-type="{{ item.component_type }}" data-definition="{{ item.definition_id }}">
125
+ <span class="type-tag type-tag--{{ item.component_type|lower }}">{{ item.component_type }}</span>{{ item.hierarchy }}
126
+ </button>
127
+ {% endfor %}
128
+ </div>
129
+ </details>
130
+ <form id="registry-filter-form" method="get" class="registry-controls">
131
+ <input type="hidden" name="apply" value="1">
132
+ <input id="definition-filter" type="hidden" name="definition" value="{{ filters.definition }}">
133
+ <div class="registry-search"><label for="reg-search">Search Registry</label><input type="search" id="reg-search" name="q" value="{{ filters.q }}" placeholder="Name, field or site..."></div>
134
+ <div><label for="reg-filter">Component Type</label><select id="reg-filter" name="type"><option value="ALL" {% if filters.type == "ALL" %}selected{% endif %}>All</option><option value="PAGE" {% if filters.type == "PAGE" %}selected{% endif %}>Pages</option><option value="BLOCK" {% if filters.type == "BLOCK" %}selected{% endif %}>Blocks</option></select></div>
135
+ <div><label for="reg-site-filter">Site</label><select id="reg-site-filter" name="site"><option value="">All sites</option>{% for site in sites %}<option value="{{ site.id }}" {% if filters.site == site.id|stringformat:"s" %}selected{% endif %}>{{ site.site_name }}</option>{% endfor %}</select></div>
136
+ <div class="usage-range"><label for="min-usage">Min usage</label><input id="min-usage" type="number" min="0" name="min_usage" value="{{ filters.min_usage }}"></div>
137
+ <div class="usage-range"><label for="max-usage">Max usage</label><input id="max-usage" type="number" min="0" name="max_usage" value="{{ filters.max_usage }}"></div>
138
+ <div class="filter-actions"><button type="submit" class="button">Apply Filters</button><a class="button button-secondary" href="{% url 'dev_reference' %}">Clear</a></div>
139
+ </form>
140
+ {% if filter_error %}<p class="error-message">{{ filter_error }}</p>{% endif %}
141
+ {% if is_applied %}
142
+ <table class="listing" id="registry-table">
143
+ <thead>
144
+ <tr>
145
+ <th class="sortable col-site" onclick="sortTable(0)">Site</th>
146
+ <th class="sortable" onclick="sortTable(1)">Hierarchy</th>
147
+ <th class="sortable col-type" onclick="sortTable(2)">Type</th>
148
+ <th class="sortable" onclick="sortTable(3)">Structure & Metadata</th>
149
+ <th class="sortable col-centered" onclick="sortTable(4)">Draft</th>
150
+ <th class="sortable col-centered" onclick="sortTable(5)">Live</th>
151
+ <th class="sortable col-centered" onclick="sortTable(6)">Total</th>
152
+ <th class="sortable" onclick="sortTable(7)">Template Path</th>
153
+ </tr>
154
+ </thead>
155
+ <tbody id="registry-body">
156
+ {% for item in audit_data %}
157
+ <tr class="registry-row" data-type="{{ item.type|upper }}" data-site="{{ item.site_id }}">
158
+ <td class="col-site">{{ item.site_name }}</td>
159
+ <td><strong>{{ item.hierarchy }}</strong></td>
160
+ <td class="col-type"><span class="type-tag type-tag--{{ item.type|lower }}">{{ item.type }}</span></td>
161
+ <td class="field-cell">
162
+ <button
163
+ type="button"
164
+ class="field-toggle"
165
+ aria-expanded="false"
166
+ aria-controls="field-details-{{ forloop.counter }}"
167
+ >
168
+ S: {{ item.fields.structure|length }} · M: {{ item.fields.metadata|length }}
169
+ </button>
170
+ <div id="field-details-{{ forloop.counter }}" class="field-details" hidden>
171
+ {% for field_info in item.fields.structure %}
172
+ <div class="field-pill">
173
+ <code>{{ field_info.label }} <small class="field-type">({{ field_info.type }})</small></code>
174
+ </div>
175
+ {% endfor %}
176
+ {% for field_info in item.fields.metadata %}
177
+ <div class="field-pill field-pill--metadata">
178
+ <code>{{ field_info.label }} <small class="field-type">({{ field_info.type }})</small></code>
179
+ </div>
180
+ {% endfor %}
181
+ </div>
182
+ </td>
183
+ {% if usage_requested %}
184
+ <td class="col-centered" data-sort="{{ item.usage.draft }}"><span class="{% if item.usage.draft > 0 %}count-draft{% else %}zero-usage{% endif %}">{{ item.usage.draft }}</span></td>
185
+ <td class="col-centered" data-sort="{{ item.usage.live }}"><span class="{% if item.usage.live > 0 %}count-live{% else %}zero-usage{% endif %}">{{ item.usage.live }}</span></td>
186
+ <td class="col-centered" data-sort="{{ item.usage.total }}">{% if item.usage.total > 0 %}<a href="{% url 'dev_definition_usage' item.definition_id %}{% if item.site_id %}?site={{ item.site_id }}{% endif %}" class="button button-small usage-link">{{ item.usage.total }}</a>{% else %}<span class="zero-usage">0</span>{% endif %}</td>
187
+ {% else %}
188
+ <td class="col-centered"><span class="template-empty">—</span></td>
189
+ <td class="col-centered"><span class="template-empty">—</span></td>
190
+ <td class="col-centered"><span class="template-empty">Add min or max</span></td>
191
+ {% endif %}
192
+ <td>
193
+ {% if item.template %}
194
+ <span class="template-label" title="{{ item.template }}">{{ item.template_label }}</span>
195
+ <button type="button" class="template-copy" data-template="{{ item.template }}">Copy</button>
196
+ {% else %}
197
+ <span class="template-empty">—</span>
198
+ {% endif %}
199
+ </td>
200
+ </tr>
201
+ {% endfor %}
202
+ </tbody>
203
+ </table>
204
+ {% if page_obj.paginator.num_pages > 1 %}
205
+ <nav class="pagination" aria-label="Registry result pages">
206
+ {% if page_obj.has_previous %}<a class="button" href="?apply=1&amp;q={{ filters.q|urlencode }}&amp;type={{ filters.type|urlencode }}&amp;site={{ filters.site|urlencode }}&amp;min_usage={{ filters.min_usage|urlencode }}&amp;max_usage={{ filters.max_usage|urlencode }}&amp;definition={{ filters.definition|urlencode }}&amp;page={{ page_obj.previous_page_number }}">Previous</a>{% endif %}
207
+ <span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} ({{ page_obj.paginator.count }} results)</span>
208
+ {% if page_obj.has_next %}<a class="button" href="?apply=1&amp;q={{ filters.q|urlencode }}&amp;type={{ filters.type|urlencode }}&amp;site={{ filters.site|urlencode }}&amp;min_usage={{ filters.min_usage|urlencode }}&amp;max_usage={{ filters.max_usage|urlencode }}&amp;definition={{ filters.definition|urlencode }}&amp;page={{ page_obj.next_page_number }}">Next</a>{% endif %}
209
+ </nav>
210
+ {% endif %}
211
+ {% else %}
212
+ <p class="help-block">Choose filters and select <strong>Apply Filters</strong> to load registry usage.</p>
213
+ {% endif %}
214
+ </div>
215
+
216
+ <script>
217
+ let currentSortColumn = -1;
218
+ let isSortAscending = true;
219
+
220
+ function sortTable(columnIndex) {
221
+ const tableBody = document.getElementById("registry-body");
222
+ const tableRows = Array.from(tableBody.querySelectorAll(".registry-row"));
223
+
224
+ isSortAscending = (currentSortColumn === columnIndex) ? !isSortAscending : true;
225
+ currentSortColumn = columnIndex;
226
+
227
+ tableRows.sort((rowA, rowB) => {
228
+ let cellA = rowA.cells[columnIndex];
229
+ let cellB = rowB.cells[columnIndex];
230
+ let valueA = cellA.getAttribute('data-sort') || cellA.textContent.trim().toLowerCase();
231
+ let valueB = cellB.getAttribute('data-sort') || cellB.textContent.trim().toLowerCase();
232
+
233
+ if (!isNaN(parseFloat(valueA)) && isFinite(valueA) && !isNaN(parseFloat(valueB)) && isFinite(valueB)) {
234
+ return isSortAscending ? valueA - valueB : valueB - valueA;
235
+ }
236
+ return isSortAscending ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
237
+ }).forEach(sortedRow => tableBody.appendChild(sortedRow));
238
+ }
239
+
240
+ function runFilters() {
241
+ const searchTerm = document.getElementById("reg-search").value.toLowerCase();
242
+ const typeFilter = document.getElementById("reg-filter").value;
243
+ const siteFilter = document.getElementById("reg-site-filter").value;
244
+ document.querySelectorAll(".registry-row").forEach(row => {
245
+ const textMatches = row.textContent.toLowerCase().includes(searchTerm);
246
+ const typeMatches = typeFilter === "ALL" || row.dataset.type === typeFilter;
247
+ const siteMatches = siteFilter === "ALL" || row.dataset.site === siteFilter;
248
+ row.style.display = textMatches && typeMatches && siteMatches ? "" : "none";
249
+ });
250
+ }
251
+
252
+ document.querySelectorAll(".field-toggle").forEach(button => {
253
+ button.addEventListener("click", () => {
254
+ const details = document.getElementById(button.getAttribute("aria-controls"));
255
+ const isExpanded = button.getAttribute("aria-expanded") === "true";
256
+ document.querySelectorAll(".field-toggle").forEach(otherButton => {
257
+ otherButton.setAttribute("aria-expanded", "false");
258
+ document.getElementById(otherButton.getAttribute("aria-controls")).hidden = true;
259
+ });
260
+ if (!isExpanded) {
261
+ button.setAttribute("aria-expanded", "true");
262
+ details.hidden = false;
263
+ }
264
+ });
265
+ });
266
+
267
+ document.querySelectorAll(".template-copy").forEach(button => {
268
+ button.addEventListener("click", () => {
269
+ navigator.clipboard.writeText(button.dataset.template).then(() => {
270
+ button.textContent = "Copied";
271
+ window.setTimeout(() => { button.textContent = "Copy"; }, 1500);
272
+ });
273
+ });
274
+ });
275
+
276
+ const quickSearchInput = document.getElementById("quick-search-filter");
277
+ const quickSearchStatus = document.getElementById("quick-search-status");
278
+ const quickSearchList = document.querySelector(".quick-search-list");
279
+ const definitionFilter = document.getElementById("definition-filter");
280
+ let quickSearchTimer;
281
+ let quickSearchController;
282
+
283
+ function selectQuickItem(button) {
284
+ document.getElementById("reg-search").value = button.dataset.search;
285
+ document.getElementById("reg-filter").value = button.dataset.type;
286
+ definitionFilter.value = button.dataset.definition;
287
+ document.getElementById("registry-filter-form").submit();
288
+ }
289
+
290
+ function bindQuickItems() {
291
+ quickSearchList.querySelectorAll(".quick-search-item").forEach(button => {
292
+ button.addEventListener("click", () => selectQuickItem(button));
293
+ });
294
+ }
295
+
296
+ function renderQuickItems(results, total) {
297
+ quickSearchList.replaceChildren();
298
+ results.forEach(item => {
299
+ const button = document.createElement("button");
300
+ button.type = "button";
301
+ button.className = "quick-search-item";
302
+ button.dataset.search = item.name;
303
+ button.dataset.type = item.type;
304
+ button.dataset.definition = item.id;
305
+ const badge = document.createElement("span");
306
+ badge.className = `type-tag type-tag--${item.type.toLowerCase()}`;
307
+ badge.textContent = item.type;
308
+ button.append(badge, document.createTextNode(item.name));
309
+ quickSearchList.appendChild(button);
310
+ });
311
+ quickSearchStatus.textContent = total > results.length
312
+ ? `Showing ${results.length} of ${total} matching components.`
313
+ : `${total} matching component${total === 1 ? "" : "s"}.`;
314
+ bindQuickItems();
315
+ }
316
+
317
+ async function loadQuickItems() {
318
+ if (quickSearchController) quickSearchController.abort();
319
+ quickSearchController = new AbortController();
320
+ const query = quickSearchInput.value.trim();
321
+ const params = new URLSearchParams({q: query, limit: "30"});
322
+ try {
323
+ const response = await fetch(`{% url 'dev_catalog_search' %}?${params}`, {
324
+ headers: {"X-Requested-With": "XMLHttpRequest"},
325
+ signal: quickSearchController.signal,
326
+ });
327
+ if (!response.ok) throw new Error("Catalog search failed");
328
+ const data = await response.json();
329
+ renderQuickItems(data.results, data.total);
330
+ } catch (error) {
331
+ if (error.name !== "AbortError") {
332
+ quickSearchStatus.textContent = "Could not load components.";
333
+ }
334
+ }
335
+ }
336
+
337
+ bindQuickItems();
338
+ quickSearchStatus.textContent = "Showing the first 30 components. Type to search all definitions.";
339
+ quickSearchInput.addEventListener("input", () => {
340
+ window.clearTimeout(quickSearchTimer);
341
+ quickSearchTimer = window.setTimeout(loadQuickItems, 200);
342
+ });
343
+ document.getElementById("reg-search").addEventListener("input", () => {
344
+ definitionFilter.value = "";
345
+ });
346
+ document.querySelector(".quick-search").addEventListener("toggle", event => {
347
+ if (event.target.open) quickSearchInput.focus();
348
+ });
349
+ </script>
350
+ {% endblock %}