wagtail-developer-reference 0.1.2__tar.gz → 0.1.3__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 (20) hide show
  1. {wagtail_developer_reference-0.1.2/wagtail_developer_reference.egg-info → wagtail_developer_reference-0.1.3}/PKG-INFO +8 -7
  2. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/README.md +7 -6
  3. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/pyproject.toml +1 -1
  4. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/setup.py +1 -1
  5. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/migrations/0001_initial.py +1 -2
  6. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/templates/detail.html +60 -3
  7. wagtail_developer_reference-0.1.3/wagtail_developer_reference/templates/index.html +313 -0
  8. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/wagtail_hooks.py +122 -98
  9. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3/wagtail_developer_reference.egg-info}/PKG-INFO +8 -7
  10. wagtail_developer_reference-0.1.2/wagtail_developer_reference/templates/index.html +0 -164
  11. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/MANIFEST.in +0 -0
  12. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/setup.cfg +0 -0
  13. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/__init__.py +0 -0
  14. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/apps.py +0 -0
  15. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/migrations/__init__.py +0 -0
  16. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference/models.py +0 -0
  17. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference.egg-info/SOURCES.txt +0 -0
  18. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference.egg-info/dependency_links.txt +0 -0
  19. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/wagtail_developer_reference.egg-info/requires.txt +0 -0
  20. {wagtail_developer_reference-0.1.2 → wagtail_developer_reference-0.1.3}/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.3
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,15 +32,16 @@ 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
+ * **On-demand usage tracking:** Apply filters before calculating page and block usage, keeping the registry quick to open.
36
+ * **Interactive registry:** Combine text, PAGE/BLOCK, site and total-usage-range filters to find exactly the components you need.
37
+ * **Nested block discovery:** Browse visible block labels and full nested definition paths, with safe fallbacks for unlabeled blocks.
38
+ * **Usage inspection:** Search and paginate matching pages, then copy the visible usage table in one click.
39
+ * **Template discovery:** See the template path associated with every component.
39
40
 
40
41
  ## Installation
41
42
 
42
- Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
43
- itself requires Python 3.10+.
43
+ Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4.2 LTS (the latest
44
+ stable Wagtail release at v0.1.3). Wagtail 7.4 itself requires Python 3.10+.
44
45
 
45
46
  1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
46
47
  \`\`\`python
@@ -6,15 +6,16 @@
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
+ * **On-demand usage tracking:** Apply filters before calculating page and block usage, keeping the registry quick to open.
10
+ * **Interactive registry:** Combine text, PAGE/BLOCK, site and total-usage-range filters to find exactly the components you need.
11
+ * **Nested block discovery:** Browse visible block labels and full nested definition paths, with safe fallbacks for unlabeled blocks.
12
+ * **Usage inspection:** Search and paginate matching pages, then copy the visible usage table in one click.
13
+ * **Template discovery:** See the template path associated with every component.
13
14
 
14
15
  ## Installation
15
16
 
16
- Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
17
- itself requires Python 3.10+.
17
+ Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4.2 LTS (the latest
18
+ stable Wagtail release at v0.1.3). Wagtail 7.4 itself requires Python 3.10+.
18
19
 
19
20
  1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
20
21
  \`\`\`python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wagtail-developer-reference"
3
- version = "0.1.2"
3
+ version = "0.1.3"
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.3",
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
  }
@@ -111,11 +130,21 @@
111
130
  <a href="{% url 'dev_reference' %}">&larr; Back to System Registry</a>
112
131
  </div>
113
132
  <h1>Locations for: <strong>{{ component_name }}</strong></h1>
114
- <p>Found in {{ pages|length }} location{{ pages|length|pluralize }}</p>
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>
@@ -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,313 @@
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 component_type, component_name, display_name in quick_search_items %}
124
+ <button type="button" class="quick-search-item" data-search="{{ component_name }}" data-type="{{ component_type }}" data-quick-search="{{ component_name }} {{ display_name }}" data-quick-index="{{ forloop.counter0 }}">
125
+ <span class="type-tag type-tag--{{ component_type|lower }}">{{ component_type }}</span>{{ display_name }}
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
+ <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>
133
+ <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>
134
+ <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>
135
+ <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>
136
+ <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>
137
+ <div class="filter-actions"><button type="submit" class="button">Apply Filters</button><a class="button button-secondary" href="{% url 'dev_reference' %}">Clear</a></div>
138
+ </form>
139
+ {% if filter_error %}<p class="error-message">{{ filter_error }}</p>{% endif %}
140
+ {% if is_applied %}
141
+ <table class="listing" id="registry-table">
142
+ <thead>
143
+ <tr>
144
+ <th class="sortable col-site" onclick="sortTable(0)">Site</th>
145
+ <th class="sortable" onclick="sortTable(1)">Hierarchy</th>
146
+ <th class="sortable col-type" onclick="sortTable(2)">Type</th>
147
+ <th class="sortable" onclick="sortTable(3)">Structure & Metadata</th>
148
+ <th class="sortable col-centered" onclick="sortTable(4)">Draft</th>
149
+ <th class="sortable col-centered" onclick="sortTable(5)">Live</th>
150
+ <th class="sortable col-centered" onclick="sortTable(6)">Total</th>
151
+ <th class="sortable" onclick="sortTable(7)">Template Path</th>
152
+ </tr>
153
+ </thead>
154
+ <tbody id="registry-body">
155
+ {% for item in audit_data %}
156
+ <tr class="registry-row" data-type="{{ item.type|upper }}" data-site="{{ item.site_id }}">
157
+ <td class="col-site">{{ item.site_name }}</td>
158
+ <td><strong>{{ item.hierarchy }}</strong></td>
159
+ <td class="col-type"><span class="type-tag type-tag--{{ item.type|lower }}">{{ item.type }}</span></td>
160
+ <td class="field-cell">
161
+ <button
162
+ type="button"
163
+ class="field-toggle"
164
+ aria-expanded="false"
165
+ aria-controls="field-details-{{ forloop.counter }}"
166
+ >
167
+ S: {{ item.fields.structure|length }} · M: {{ item.fields.metadata|length }}
168
+ </button>
169
+ <div id="field-details-{{ forloop.counter }}" class="field-details" hidden>
170
+ {% for field_info in item.fields.structure %}
171
+ <div class="field-pill">
172
+ <code>{{ field_info.label }} <small class="field-type">({{ field_info.type }})</small></code>
173
+ </div>
174
+ {% endfor %}
175
+ {% for field_info in item.fields.metadata %}
176
+ <div class="field-pill field-pill--metadata">
177
+ <code>{{ field_info.label }} <small class="field-type">({{ field_info.type }})</small></code>
178
+ </div>
179
+ {% endfor %}
180
+ </div>
181
+ </td>
182
+ <td class="col-centered" data-sort="{{ item.usage.draft }}">
183
+ <span class="{% if item.usage.draft > 0 %}count-draft{% else %}zero-usage{% endif %}">{{ item.usage.draft }}</span>
184
+ </td>
185
+ <td class="col-centered" data-sort="{{ item.usage.live }}">
186
+ <span class="{% if item.usage.live > 0 %}count-live{% else %}zero-usage{% endif %}">{{ item.usage.live }}</span>
187
+ </td>
188
+ <td class="col-centered" data-sort="{{ item.usage.total }}">
189
+ {% if item.usage.total > 0 %}
190
+ <a href="{% url 'dev_usage_detail' item.type item.clean_name %}?site={{ item.site_id }}" class="button button-small usage-link">
191
+ {{ item.usage.total }}
192
+ </a>
193
+ {% else %}
194
+ <span class="zero-usage">0</span>
195
+ {% endif %}
196
+ </td>
197
+ <td>
198
+ {% if item.template %}
199
+ <span class="template-label" title="{{ item.template }}">{{ item.template_label }}</span>
200
+ <button type="button" class="template-copy" data-template="{{ item.template }}">Copy</button>
201
+ {% else %}
202
+ <span class="template-empty">—</span>
203
+ {% endif %}
204
+ </td>
205
+ </tr>
206
+ {% endfor %}
207
+ </tbody>
208
+ </table>
209
+ {% else %}
210
+ <p class="help-block">Choose filters and select <strong>Apply Filters</strong> to load registry usage.</p>
211
+ {% endif %}
212
+ </div>
213
+
214
+ <script>
215
+ let currentSortColumn = -1;
216
+ let isSortAscending = true;
217
+
218
+ function sortTable(columnIndex) {
219
+ const tableBody = document.getElementById("registry-body");
220
+ const tableRows = Array.from(tableBody.querySelectorAll(".registry-row"));
221
+
222
+ isSortAscending = (currentSortColumn === columnIndex) ? !isSortAscending : true;
223
+ currentSortColumn = columnIndex;
224
+
225
+ tableRows.sort((rowA, rowB) => {
226
+ let cellA = rowA.cells[columnIndex];
227
+ let cellB = rowB.cells[columnIndex];
228
+ let valueA = cellA.getAttribute('data-sort') || cellA.textContent.trim().toLowerCase();
229
+ let valueB = cellB.getAttribute('data-sort') || cellB.textContent.trim().toLowerCase();
230
+
231
+ if (!isNaN(parseFloat(valueA)) && isFinite(valueA) && !isNaN(parseFloat(valueB)) && isFinite(valueB)) {
232
+ return isSortAscending ? valueA - valueB : valueB - valueA;
233
+ }
234
+ return isSortAscending ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
235
+ }).forEach(sortedRow => tableBody.appendChild(sortedRow));
236
+ }
237
+
238
+ function runFilters() {
239
+ const searchTerm = document.getElementById("reg-search").value.toLowerCase();
240
+ const typeFilter = document.getElementById("reg-filter").value;
241
+ const siteFilter = document.getElementById("reg-site-filter").value;
242
+ document.querySelectorAll(".registry-row").forEach(row => {
243
+ const textMatches = row.textContent.toLowerCase().includes(searchTerm);
244
+ const typeMatches = typeFilter === "ALL" || row.dataset.type === typeFilter;
245
+ const siteMatches = siteFilter === "ALL" || row.dataset.site === siteFilter;
246
+ row.style.display = textMatches && typeMatches && siteMatches ? "" : "none";
247
+ });
248
+ }
249
+
250
+ document.querySelectorAll(".field-toggle").forEach(button => {
251
+ button.addEventListener("click", () => {
252
+ const details = document.getElementById(button.getAttribute("aria-controls"));
253
+ const isExpanded = button.getAttribute("aria-expanded") === "true";
254
+ document.querySelectorAll(".field-toggle").forEach(otherButton => {
255
+ otherButton.setAttribute("aria-expanded", "false");
256
+ document.getElementById(otherButton.getAttribute("aria-controls")).hidden = true;
257
+ });
258
+ if (!isExpanded) {
259
+ button.setAttribute("aria-expanded", "true");
260
+ details.hidden = false;
261
+ }
262
+ });
263
+ });
264
+
265
+ document.querySelectorAll(".template-copy").forEach(button => {
266
+ button.addEventListener("click", () => {
267
+ navigator.clipboard.writeText(button.dataset.template).then(() => {
268
+ button.textContent = "Copied";
269
+ window.setTimeout(() => { button.textContent = "Copy"; }, 1500);
270
+ });
271
+ });
272
+ });
273
+
274
+ const quickSearchInput = document.getElementById("quick-search-filter");
275
+ const quickSearchStatus = document.getElementById("quick-search-status");
276
+ const quickSearchItems = Array.from(document.querySelectorAll(".quick-search-item"));
277
+ const initialQuickSearchLimit = 30;
278
+
279
+ function runQuickSearch() {
280
+ const query = quickSearchInput.value.trim().toLowerCase();
281
+ let matches = 0;
282
+ quickSearchItems.forEach(button => {
283
+ const matchesQuery = button.dataset.quickSearch.toLowerCase().includes(query);
284
+ const withinInitialLimit = query || Number(button.dataset.quickIndex) < initialQuickSearchLimit;
285
+ const isVisible = matchesQuery && withinInitialLimit;
286
+ button.hidden = !isVisible;
287
+ if (matchesQuery) matches += 1;
288
+ });
289
+ quickSearchStatus.textContent = query
290
+ ? `${matches} matching component${matches === 1 ? "" : "s"}`
291
+ : `Showing the first ${Math.min(initialQuickSearchLimit, matches)} of ${matches} components. Type to narrow the list.`;
292
+ }
293
+
294
+ document.querySelectorAll(".quick-search-item").forEach(button => {
295
+ button.addEventListener("click", () => {
296
+ document.getElementById("reg-search").value = button.dataset.search;
297
+ document.getElementById("reg-filter").value = button.dataset.type;
298
+ document.getElementById("registry-filter-form").submit();
299
+ });
300
+ });
301
+ quickSearchInput.addEventListener("input", runQuickSearch);
302
+ document.querySelector(".quick-search").addEventListener("toggle", event => {
303
+ if (event.target.open) quickSearchInput.focus();
304
+ });
305
+ runQuickSearch();
306
+
307
+ if (document.getElementById("registry-table")) {
308
+ document.getElementById("reg-search").addEventListener("input", runFilters);
309
+ document.getElementById("reg-filter").addEventListener("change", runFilters);
310
+ document.getElementById("reg-site-filter").addEventListener("change", runFilters);
311
+ }
312
+ </script>
313
+ {% endblock %}
@@ -1,25 +1,24 @@
1
+ # ruff: noqa: E501
1
2
  from __future__ import annotations
2
3
 
3
4
  import inspect
4
5
  from collections.abc import Iterable
5
- from typing import Any, Literal, TypedDict
6
+ from pathlib import Path
7
+ from typing import Any, Literal, NotRequired, TypedDict
6
8
 
7
9
  from django.contrib.auth.decorators import user_passes_test
8
10
  from django.core.exceptions import PermissionDenied
9
- from django.db.models import Field
11
+ from django.core.paginator import Paginator
12
+ from django.db.models import Field, Q
10
13
  from django.http import HttpRequest, HttpResponse
11
14
  from django.shortcuts import render
12
15
  from django.template.loader import get_template
13
- from django.urls import path
14
- from django.urls import reverse
16
+ from django.urls import path, reverse
15
17
  from django.urls.resolvers import URLPattern
16
18
  from wagtail import hooks
17
19
  from wagtail.admin.menu import MenuItem
18
- from wagtail.blocks import StructBlock
19
20
  from wagtail.fields import StreamField
20
- from wagtail.models import Page
21
- from wagtail.models import Site, get_page_models
22
-
21
+ from wagtail.models import Page, Site, get_page_models
23
22
 
24
23
  ACCESS_PERMISSION_APP_LABEL: str = "wagtail_developer_reference"
25
24
  ACCESS_PERMISSION_CODENAME: str = "access_developer_reference"
@@ -34,19 +33,27 @@ class UsageData(TypedDict):
34
33
  draft: int
35
34
 
36
35
 
36
+ class FieldInfo(TypedDict):
37
+ label: str
38
+ type: str
39
+ block_name: NotRequired[str | None]
40
+
41
+
37
42
  class FieldGroups(TypedDict):
38
- structure: list[str]
39
- metadata: list[str]
43
+ structure: list[FieldInfo]
44
+ metadata: list[FieldInfo]
40
45
 
41
46
 
42
47
  class AuditItem(TypedDict):
48
+ site_id: int
43
49
  site_name: str
44
50
  hierarchy: str
45
51
  clean_name: str
46
52
  type: ComponentType
47
53
  fields: FieldGroups
48
54
  usage: UsageData
49
- template: str
55
+ template: str | None
56
+ template_label: str | None
50
57
 
51
58
 
52
59
  def user_can_access_developer_reference(user: Any) -> bool:
@@ -99,38 +106,35 @@ class WagtailAuditor:
99
106
  "latest_revision_created_at",
100
107
  }
101
108
 
102
- def get_template_path(self, template_name: str | None) -> str:
109
+ def get_template_path(self, template_name: str | None) -> str | None:
103
110
  if not template_name:
104
- return "Default Rendering"
111
+ return None
105
112
  try:
106
113
  return get_template(template_name).origin.name
107
114
  except Exception:
108
- return "Template not found"
115
+ return template_name
109
116
 
110
- def _analyze_model_field(
111
- self,
112
- field: Field[Any, Any],
113
- ) -> tuple[str | None, FieldCategory | None]:
114
- if field.name in self.excluded_fields:
117
+ def get_template_label(self, template_path: str | None) -> str | None:
118
+ return Path(template_path).name if template_path else None
119
+
120
+
121
+ def _analyze_model_field(self, field: Field[Any, Any]) -> tuple[FieldInfo | None, FieldCategory | None]:
122
+ if field.name in self.excluded_fields or (field.is_relation and not field.many_to_one):
115
123
  return None, None
116
- if not field.is_relation or field.many_to_one:
117
- label = getattr(field, "verbose_name", field.name)
118
- if hasattr(label, "__str__"):
119
- label = str(label).capitalize()
120
- field_type = type(field).__name__
121
- is_structure = "StreamField" in field_type or "RichTextField" in field_type
122
- category = "structure" if is_structure else "metadata"
123
- return f"{label} ({field_type})", category
124
- return None, None
124
+ field_type = type(field).__name__
125
+ category: FieldCategory = "structure" if isinstance(field, StreamField) or "RichTextField" in field_type else "metadata"
126
+ return {"label": str(getattr(field, "verbose_name", field.name)).capitalize(), "type": field_type}, category
125
127
 
126
128
  def get_internal_structure(self, object_instance: Any) -> FieldGroups:
127
- structure_fields: list[str] = []
128
- metadata_fields: list[str] = []
129
+ structure_fields: list[FieldInfo] = []
130
+ metadata_fields: list[FieldInfo] = []
129
131
 
130
132
  if hasattr(object_instance, "child_blocks"):
131
133
  for name, block in object_instance.child_blocks.items():
132
- label = getattr(block.meta, "label", name)
133
- structure_fields.append(f"{label} ({type(block).__name__})")
134
+ label = getattr(block.meta, "label", None) or name
135
+ structure_fields.append(
136
+ {"label": str(label), "type": type(block).__name__}
137
+ )
134
138
  elif hasattr(object_instance, "_meta"):
135
139
  for field in object_instance._meta.get_fields():
136
140
  info, category = self._analyze_model_field(field)
@@ -172,64 +176,44 @@ class WagtailAuditor:
172
176
  "draft": total_count - live_count,
173
177
  }
174
178
 
175
- def process_block(
176
- self,
177
- block_name: str,
178
- block_object: Any,
179
- parent_label: str,
180
- site: Site,
181
- ) -> None:
179
+ def process_block(self, block_name: str, block_object: Any, parent_label: str, site: Site, include_usage: bool = False) -> None:
182
180
  is_class = inspect.isclass(block_object)
183
- block_class = (
184
- block_object.__name__ if is_class else block_object.__class__.__name__
185
- )
186
- signature = f"{site.id}_{block_class}_{block_name}"
181
+ block_class = block_object.__name__ if is_class else block_object.__class__.__name__
182
+ signature = f"{site.id}_{parent_label}_{block_class}_{block_name}"
187
183
  if signature in self.seen_blocks:
188
184
  return
189
-
190
- site_usage = {"live": 0, "total": 0, "draft": 0}
191
- for model in self.models_with_streamfields:
192
- usage_data = self._get_usage_for_site(model, site, block_name)
193
- site_usage["live"] += usage_data["live"]
194
- site_usage["total"] += usage_data["total"]
195
- site_usage["draft"] += usage_data["draft"]
196
-
197
- block_label = str(getattr(block_object.meta, "label", block_name))
198
- self.results.append(
199
- {
200
- "site_name": str(site),
201
- "hierarchy": f"{parent_label} → {block_label}",
202
- "clean_name": block_name,
203
- "type": "BLOCK",
204
- "fields": self.get_internal_structure(block_object),
205
- "usage": site_usage,
206
- "template": self.get_template_path(
207
- getattr(block_object.meta, "template", None)
208
- ),
209
- }
210
- )
185
+ block_label = str(getattr(block_object.meta, "label", None) or block_name)
186
+ template_path = self.get_template_path(getattr(block_object.meta, "template", None))
187
+ usage = {"live": 0, "total": 0, "draft": 0}
188
+ if include_usage:
189
+ for model in self.models_with_streamfields:
190
+ model_usage = self._get_usage_for_site(model, site, block_name)
191
+ for key in usage:
192
+ usage[key] += model_usage[key]
193
+ self.results.append({"site_id": site.id, "site_name": site.site_name, "hierarchy": f"{parent_label} → {block_label}", "clean_name": block_name, "type": "BLOCK", "fields": self.get_internal_structure(block_object), "usage": usage, "template": template_path, "template_label": self.get_template_label(template_path)})
211
194
  self.seen_blocks.add(signature)
195
+ for child_name, child_object in getattr(block_object, "child_blocks", {}).items():
196
+ self.process_block(child_name, child_object, f"{parent_label} → {block_label}", site, include_usage)
212
197
 
213
- if isinstance(block_object, StructBlock):
214
- for child_name, child_object in block_object.child_blocks.items():
215
- self.process_block(child_name, child_object, parent_label, site)
216
-
217
- def run(self) -> list[AuditItem]:
198
+ def run(self, include_usage: bool = False) -> list[AuditItem]:
218
199
  for site in self.sites:
219
200
  for model in self.all_page_models:
220
- usage_data = self._get_usage_for_site(model, site)
201
+ usage_data = self._get_usage_for_site(model, site) if include_usage else {"live": 0, "total": 0, "draft": 0}
221
202
  default_tpl = f"{model._meta.app_label}/{model._meta.model_name}.html"
203
+ template_path = self.get_template_path(
204
+ getattr(model, "template", default_tpl)
205
+ )
222
206
  self.results.append(
223
207
  {
224
- "site_name": str(site),
208
+ "site_id": site.id,
209
+ "site_name": site.site_name,
225
210
  "hierarchy": str(model._meta.verbose_name).title(),
226
211
  "clean_name": model.__name__,
227
212
  "type": "PAGE",
228
213
  "fields": self.get_internal_structure(model),
229
214
  "usage": usage_data,
230
- "template": self.get_template_path(
231
- getattr(model, "template", default_tpl)
232
- ),
215
+ "template": template_path,
216
+ "template_label": self.get_template_label(template_path),
233
217
  }
234
218
  )
235
219
  for field in model._meta.get_fields():
@@ -241,19 +225,45 @@ class WagtailAuditor:
241
225
  block_object,
242
226
  str(model._meta.verbose_name).title(),
243
227
  site,
228
+ include_usage,
244
229
  )
245
230
  return self.results
246
231
 
247
232
 
248
233
  @user_passes_test(require_developer_reference_access)
249
234
  def developer_reference_view(request: HttpRequest) -> HttpResponse:
235
+ applied = request.GET.get("apply") == "1"
250
236
  auditor = WagtailAuditor()
251
- audit_results = auditor.run()
252
- sorted_results = sorted(
253
- audit_results,
254
- key=lambda item: (item["site_name"], item["hierarchy"]),
255
- )
256
- return render(request, "index.html", {"audit_data": sorted_results})
237
+ audit_results = auditor.run(include_usage=applied)
238
+ quick_search_by_component = {}
239
+ for item in audit_results:
240
+ key = (item["type"], item["hierarchy"])
241
+ quick_search_by_component.setdefault(key, (item["type"], item["hierarchy"], item["hierarchy"]))
242
+ quick_search_items = sorted(quick_search_by_component.values(), key=lambda item: (item[2].casefold(), item[0]))
243
+ filters = {"q": request.GET.get("q", "").strip(), "type": request.GET.get("type", "ALL"), "site": request.GET.get("site", ""), "min_usage": request.GET.get("min_usage", ""), "max_usage": request.GET.get("max_usage", "")}
244
+ error = ""
245
+ filtered_results: list[AuditItem] = []
246
+ if applied:
247
+ try:
248
+ minimum = int(filters["min_usage"]) if filters["min_usage"] else None
249
+ maximum = int(filters["max_usage"]) if filters["max_usage"] else None
250
+ if (minimum is not None and minimum < 0) or (maximum is not None and maximum < 0) or (minimum is not None and maximum is not None and minimum > maximum):
251
+ raise ValueError
252
+ except ValueError:
253
+ error = "Usage range must contain non-negative numbers, with min no greater than max."
254
+ minimum = maximum = None
255
+ if not error:
256
+ query = filters["q"].casefold()
257
+ for item in audit_results:
258
+ matches_query = not query or query in " ".join((item["site_name"], item["hierarchy"], item["clean_name"])).casefold()
259
+ matches_type = filters["type"] == "ALL" or item["type"] == filters["type"]
260
+ matches_site = not filters["site"] or str(item["site_id"]) == filters["site"]
261
+ total = item["usage"]["total"]
262
+ matches_min = minimum is None or total >= minimum
263
+ matches_max = maximum is None or total <= maximum
264
+ if matches_query and matches_type and matches_site and matches_min and matches_max:
265
+ filtered_results.append(item)
266
+ return render(request, "index.html", {"audit_data": sorted(filtered_results, key=lambda item: (item["site_name"], item["hierarchy"])), "quick_search_items": quick_search_items, "sites": Site.objects.order_by("site_name", "id"), "filters": filters, "is_applied": applied, "filter_error": error})
257
267
 
258
268
 
259
269
  @user_passes_test(require_developer_reference_access)
@@ -262,17 +272,28 @@ def developer_usage_detail_view(
262
272
  component_type: str,
263
273
  component_name: str,
264
274
  ) -> HttpResponse:
265
- site_filter_name = request.GET.get("site")
266
- found_pages: list[Page] = []
275
+ site_filter = request.GET.get("site")
276
+ site = Site.objects.filter(pk=site_filter).first()
277
+ pages = Page.objects.none()
278
+
279
+ # Do not load every matching page into Python. Scope the query to the site
280
+ # and let the database return only the current results page.
281
+ if site_filter:
282
+ site_pages = (
283
+ Page.objects.descendant_of(site.root_page) if site else Page.objects.none()
284
+ )
285
+ else:
286
+ site_pages = Page.objects.all()
267
287
  if component_type == "PAGE":
268
288
  target_model = next(
269
289
  (model for model in get_page_models() if model.__name__ == component_name),
270
290
  None,
271
291
  )
272
292
  if target_model:
273
- found_pages = list(target_model.objects.all())
293
+ pages = site_pages.filter(pk__in=target_model.objects.values("pk"))
274
294
  else:
275
295
  search_key = f'"{component_name}"'
296
+ component_filter = Q()
276
297
  for model in get_page_models():
277
298
  stream_fields = [
278
299
  f.name
@@ -280,28 +301,31 @@ def developer_usage_detail_view(
280
301
  if isinstance(f, StreamField)
281
302
  ]
282
303
  for field_name in stream_fields:
283
- found_pages.extend(
284
- list(
285
- model.objects.filter(
286
- **{f"{field_name}__icontains": search_key}
287
- )
288
- )
304
+ component_filter |= Q(
305
+ pk__in=model.objects.filter(
306
+ **{f"{field_name}__icontains": search_key}
307
+ ).values("pk")
289
308
  )
309
+ if component_filter:
310
+ pages = site_pages.filter(component_filter)
290
311
 
291
- if site_filter_name:
292
- found_pages = [
293
- page
294
- for page in found_pages
295
- if str(page.get_site()) == site_filter_name
296
- ]
312
+ search_query = request.GET.get("q", "").strip()
313
+ if search_query:
314
+ pages = pages.filter(
315
+ Q(title__icontains=search_query) | Q(slug__icontains=search_query)
316
+ )
297
317
 
298
- unique_pages = {page.id: page for page in found_pages}.values()
318
+ paginator = Paginator(pages.order_by("title", "pk").distinct(), 50)
319
+ page_obj = paginator.get_page(request.GET.get("page"))
299
320
  return render(
300
321
  request,
301
322
  "detail.html",
302
323
  {
303
324
  "component_name": component_name,
304
- "pages": unique_pages,
325
+ "pages": page_obj,
326
+ "page_obj": page_obj,
327
+ "search_query": search_query,
328
+ "site_filter": site_filter or "",
305
329
  },
306
330
  )
307
331
 
@@ -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.3
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,15 +32,16 @@ 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
+ * **On-demand usage tracking:** Apply filters before calculating page and block usage, keeping the registry quick to open.
36
+ * **Interactive registry:** Combine text, PAGE/BLOCK, site and total-usage-range filters to find exactly the components you need.
37
+ * **Nested block discovery:** Browse visible block labels and full nested definition paths, with safe fallbacks for unlabeled blocks.
38
+ * **Usage inspection:** Search and paginate matching pages, then copy the visible usage table in one click.
39
+ * **Template discovery:** See the template path associated with every component.
39
40
 
40
41
  ## Installation
41
42
 
42
- Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4 LTS. Wagtail 7.4
43
- itself requires Python 3.10+.
43
+ Compatibility: Python 3.9+ and Wagtail 4.1 through 7.4.2 LTS (the latest
44
+ stable Wagtail release at v0.1.3). Wagtail 7.4 itself requires Python 3.10+.
44
45
 
45
46
  1. Add \`wagtail_developer_reference\` to your \`INSTALLED_APPS\`:
46
47
  \`\`\`python
@@ -1,164 +0,0 @@
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
- }
10
- .registry-controls div { flex: 1; }
11
- .registry-controls label { display: block; font-weight: bold; color: white; margin-bottom: 0.5rem; }
12
- .registry-controls input, .registry-controls select {
13
- width: 100%; padding: 0.6rem; border: 1px solid #dcbd3d; background: #333; color: white; border-radius: 0;
14
- }
15
-
16
- .listing thead tr { border-bottom: 2px solid #dcbd3d; }
17
- .col-centered { text-align: center; width: 5rem; }
18
- .col-site { width: 10rem; }
19
- .col-type { width: 8rem; white-space: nowrap; }
20
-
21
- .sortable { cursor: pointer; position: relative; transition: background 0.2s; }
22
- .sortable:hover { background: #444; }
23
- .sortable::after {
24
- content: '↕';
25
- font-size: 0.8rem;
26
- margin-left: 5px;
27
- opacity: 0.3;
28
- }
29
-
30
- .type-tag {
31
- background: #222; color: #dcbd3d; border: 1px solid #dcbd3d;
32
- padding: 2px 10px; text-transform: uppercase; font-size: 0.7rem;
33
- display: inline-block; white-space: nowrap;
34
- }
35
-
36
- .field-pill {
37
- background: #222; border: 1px solid #dcbd3d; padding: 2px 6px;
38
- margin-bottom: 4px; display: inline-block; font-size: 0.75rem;
39
- }
40
- .field-pill--metadata { border-color: #777; background: #333; }
41
-
42
- .count-draft { color: #dcbd3d; font-weight: bold; }
43
- .count-live { color: #1fb53a; font-weight: bold; }
44
- .zero-usage { color: #af7f7f; opacity: 0.5; }
45
-
46
- .usage-link { background: #dcbd3d !important; color: #222 !important; font-weight: bold; border-radius: 0; }
47
- .template-path { color: #ccc; font-family: monospace; font-size: 0.7rem; cursor: pointer; }
48
-
49
- [data-wagtail-theme="dark"] .listing tbody tr:hover {
50
- background-color: #333 !important;
51
- }
52
- </style>
53
- {% endblock %}
54
-
55
- {% block content %}
56
- {% include "wagtailadmin/shared/header.html" with title="System Registry" icon="code" %}
57
- <div class="nice-padding">
58
- <div class="registry-controls">
59
- <div>
60
- <label>Search Registry</label>
61
- <input type="text" id="reg-search" placeholder="Filter by name, field or site...">
62
- </div>
63
- <div id="type-filter-container">
64
- <label>Component Type</label>
65
- <select id="reg-filter">
66
- <option value="ALL">All</option>
67
- <option value="PAGE">Pages Only</option>
68
- <option value="BLOCK">Blocks Only</option>
69
- </select>
70
- </div>
71
- </div>
72
-
73
- <table class="listing" id="registry-table">
74
- <thead>
75
- <tr>
76
- <th class="sortable col-site" onclick="sortTable(0)">Site</th>
77
- <th class="sortable" onclick="sortTable(1)">Hierarchy</th>
78
- <th class="sortable col-type" onclick="sortTable(2)">Type</th>
79
- <th class="sortable" onclick="sortTable(3)">Structure & Metadata</th>
80
- <th class="sortable col-centered" onclick="sortTable(4)">Draft</th>
81
- <th class="sortable col-centered" onclick="sortTable(5)">Live</th>
82
- <th class="sortable col-centered" onclick="sortTable(6)">Total</th>
83
- <th class="sortable" onclick="sortTable(7)">Template Path</th>
84
- </tr>
85
- </thead>
86
- <tbody id="registry-body">
87
- {% for item in audit_data %}
88
- <tr class="registry-row" data-type="{{ item.type|upper }}">
89
- <td class="col-site">{{ item.site_name }}</td>
90
- <td><strong>{{ item.hierarchy }}</strong></td>
91
- <td class="col-type"><span class="type-tag">{{ item.type }}</span></td>
92
- <td>
93
- {% for field_info in item.fields.structure %}
94
- <div class="field-pill"><code>{{ field_info }}</code></div>
95
- {% endfor %}
96
- {% for field_info in item.fields.metadata %}
97
- <div class="field-pill field-pill--metadata"><code>{{ field_info }}</code></div>
98
- {% endfor %}
99
- </td>
100
- <td class="col-centered" data-sort="{{ item.usage.draft }}">
101
- <span class="{% if item.usage.draft > 0 %}count-draft{% else %}zero-usage{% endif %}">{{ item.usage.draft }}</span>
102
- </td>
103
- <td class="col-centered" data-sort="{{ item.usage.live }}">
104
- <span class="{% if item.usage.live > 0 %}count-live{% else %}zero-usage{% endif %}">{{ item.usage.live }}</span>
105
- </td>
106
- <td class="col-centered" data-sort="{{ item.usage.total }}">
107
- {% if item.usage.total > 0 %}
108
- <a href="{% url 'dev_usage_detail' item.type item.clean_name %}?site={{ item.site_name }}" class="button button-small usage-link">
109
- {{ item.usage.total }}
110
- </a>
111
- {% else %}
112
- <span class="zero-usage">0</span>
113
- {% endif %}
114
- </td>
115
- <td>
116
- <small class="template-path" onclick="navigator.clipboard.writeText(this.textContent.trim())">
117
- {{ item.template }}
118
- </small>
119
- </td>
120
- </tr>
121
- {% endfor %}
122
- </tbody>
123
- </table>
124
- </div>
125
-
126
- <script>
127
- let currentSortColumn = -1;
128
- let isSortAscending = true;
129
-
130
- function sortTable(columnIndex) {
131
- const tableBody = document.getElementById("registry-body");
132
- const tableRows = Array.from(tableBody.querySelectorAll(".registry-row"));
133
-
134
- isSortAscending = (currentSortColumn === columnIndex) ? !isSortAscending : true;
135
- currentSortColumn = columnIndex;
136
-
137
- tableRows.sort((rowA, rowB) => {
138
- let cellA = rowA.cells[columnIndex];
139
- let cellB = rowB.cells[columnIndex];
140
- let valueA = cellA.getAttribute('data-sort') || cellA.textContent.trim().toLowerCase();
141
- let valueB = cellB.getAttribute('data-sort') || cellB.textContent.trim().toLowerCase();
142
-
143
- if (!isNaN(parseFloat(valueA)) && isFinite(valueA) && !isNaN(parseFloat(valueB)) && isFinite(valueB)) {
144
- return isSortAscending ? valueA - valueB : valueB - valueA;
145
- }
146
- return isSortAscending ? valueA.localeCompare(valueB) : valueB.localeCompare(valueA);
147
- }).forEach(sortedRow => tableBody.appendChild(sortedRow));
148
- }
149
-
150
- function runFilters() {
151
- const searchTerm = document.getElementById('reg-search').value.toLowerCase();
152
- const filterValue = document.getElementById('reg-filter').value;
153
- document.querySelectorAll('.registry-row').forEach(row => {
154
- const textMatches = row.textContent.toLowerCase().includes(searchTerm);
155
- const rowType = row.getAttribute('data-type');
156
- const typeMatches = (filterValue === 'ALL') || (rowType === filterValue);
157
- row.style.display = (textMatches && typeMatches) ? '' : 'none';
158
- });
159
- }
160
-
161
- document.getElementById('reg-search').addEventListener('input', runFilters);
162
- document.getElementById('reg-filter').addEventListener('change', runFilters);
163
- </script>
164
- {% endblock %}