Browse Source

Improve admin snapshot list/grid views with better UX (#1744)

Nick Sweeting 1 month ago
parent
commit
bbbfffd0fa

+ 101 - 1
archivebox/core/admin_snapshots.py

@@ -117,7 +117,7 @@ class SnapshotAdminForm(forms.ModelForm):
 
 class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
     form = SnapshotAdminForm
-    list_display = ('created_at', 'title_str', 'status', 'files', 'size', 'url_str')
+    list_display = ('created_at', 'title_str', 'status_with_progress', 'files', 'size_with_stats', 'url_str')
     sort_fields = ('title_str', 'url_str', 'created_at', 'status', 'crawl')
     readonly_fields = ('admin_actions', 'status_info', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list')
     search_fields = ('id', 'url', 'timestamp', 'title', 'tags__name')
@@ -376,6 +376,106 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
             size_txt,
         )
 
+    @admin.display(
+        description='Status',
+        ordering='status',
+    )
+    def status_with_progress(self, obj):
+        """Show status with progress bar for in-progress snapshots."""
+        stats = obj.get_progress_stats()
+
+        # Status badge colors
+        status_colors = {
+            'queued': ('#f59e0b', '#fef3c7'),      # amber
+            'started': ('#3b82f6', '#dbeafe'),     # blue
+            'sealed': ('#10b981', '#d1fae5'),      # green
+            'succeeded': ('#10b981', '#d1fae5'),   # green
+            'failed': ('#ef4444', '#fee2e2'),      # red
+            'backoff': ('#f59e0b', '#fef3c7'),     # amber
+            'skipped': ('#6b7280', '#f3f4f6'),     # gray
+        }
+        fg_color, bg_color = status_colors.get(obj.status, ('#6b7280', '#f3f4f6'))
+
+        # For started snapshots, show progress bar
+        if obj.status == 'started' and stats['total'] > 0:
+            percent = stats['percent']
+            running = stats['running']
+            succeeded = stats['succeeded']
+            failed = stats['failed']
+
+            return format_html(
+                '''<div style="min-width: 120px;">
+                    <div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
+                        <span class="snapshot-progress-spinner"></span>
+                        <span style="font-size: 11px; color: #64748b;">{}/{} hooks</span>
+                    </div>
+                    <div style="background: #e2e8f0; border-radius: 4px; height: 6px; overflow: hidden;">
+                        <div style="background: linear-gradient(90deg, #10b981 0%, #10b981 {}%, #ef4444 {}%, #ef4444 {}%, #3b82f6 {}%, #3b82f6 100%);
+                                    width: {}%; height: 100%; transition: width 0.3s;"></div>
+                    </div>
+                    <div style="font-size: 10px; color: #94a3b8; margin-top: 2px;">
+                        ✓{} ✗{} ⏳{}
+                    </div>
+                </div>''',
+                succeeded + failed + stats['skipped'],
+                stats['total'],
+                int(succeeded / stats['total'] * 100) if stats['total'] else 0,
+                int(succeeded / stats['total'] * 100) if stats['total'] else 0,
+                int((succeeded + failed) / stats['total'] * 100) if stats['total'] else 0,
+                int((succeeded + failed) / stats['total'] * 100) if stats['total'] else 0,
+                percent,
+                succeeded,
+                failed,
+                running,
+            )
+
+        # For other statuses, show simple badge
+        return format_html(
+            '<span style="display: inline-block; padding: 2px 8px; border-radius: 12px; '
+            'font-size: 11px; font-weight: 500; background: {}; color: {};">{}</span>',
+            bg_color,
+            fg_color,
+            obj.status.upper(),
+        )
+
+    @admin.display(
+        description='Size',
+    )
+    def size_with_stats(self, obj):
+        """Show archive size with output size from archive results."""
+        stats = obj.get_progress_stats()
+
+        # Use output_size from archive results if available, fallback to disk size
+        output_size = stats['output_size']
+        archive_size = os.access(Path(obj.output_dir) / 'index.html', os.F_OK) and obj.archive_size
+
+        size_bytes = output_size or archive_size or 0
+
+        if size_bytes:
+            size_txt = printable_filesize(size_bytes)
+            if size_bytes > 52428800:  # 50MB
+                size_txt = mark_safe(f'<b>{size_txt}</b>')
+        else:
+            size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
+
+        # Show hook statistics
+        if stats['total'] > 0:
+            return format_html(
+                '<a href="/{}" title="View all files" style="white-space: nowrap;">'
+                '{}</a>'
+                '<div style="font-size: 10px; color: #94a3b8; margin-top: 2px;">'
+                '{}/{} hooks</div>',
+                obj.archive_path,
+                size_txt,
+                stats['succeeded'],
+                stats['total'],
+            )
+
+        return format_html(
+            '<a href="/{}" title="View all files">{}</a>',
+            obj.archive_path,
+            size_txt,
+        )
 
     @admin.display(
         description='Original URL',

+ 50 - 0
archivebox/core/models.py

@@ -1712,6 +1712,56 @@ class Snapshot(ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHea
         # otherwise archiveresults exist and are all finished, so it's finished
         return True
 
+    def get_progress_stats(self) -> dict:
+        """
+        Get progress statistics for this snapshot's archiving process.
+
+        Returns dict with:
+            - total: Total number of archive results
+            - succeeded: Number of succeeded results
+            - failed: Number of failed results
+            - running: Number of currently running results
+            - pending: Number of pending/queued results
+            - percent: Completion percentage (0-100)
+            - output_size: Total output size in bytes
+            - is_sealed: Whether the snapshot is in a final state
+        """
+        from django.db.models import Sum
+
+        results = self.archiveresult_set.all()
+
+        # Count by status
+        succeeded = results.filter(status='succeeded').count()
+        failed = results.filter(status='failed').count()
+        running = results.filter(status='started').count()
+        skipped = results.filter(status='skipped').count()
+        total = results.count()
+        pending = total - succeeded - failed - running - skipped
+
+        # Calculate percentage (succeeded + failed + skipped as completed)
+        completed = succeeded + failed + skipped
+        percent = int((completed / total * 100) if total > 0 else 0)
+
+        # Sum output sizes
+        output_size = results.filter(status='succeeded').aggregate(
+            total_size=Sum('output_size')
+        )['total_size'] or 0
+
+        # Check if sealed
+        is_sealed = self.status in (self.StatusChoices.SEALED, self.StatusChoices.FAILED, self.StatusChoices.BACKOFF)
+
+        return {
+            'total': total,
+            'succeeded': succeeded,
+            'failed': failed,
+            'running': running,
+            'pending': pending,
+            'skipped': skipped,
+            'percent': percent,
+            'output_size': output_size,
+            'is_sealed': is_sealed,
+        }
+
     def retry_failed_archiveresults(self, retry_at: Optional['timezone.datetime'] = None) -> int:
         """
         Reset failed/skipped ArchiveResults to queued for retry.

+ 21 - 5
archivebox/templates/admin/base.html

@@ -1346,10 +1346,16 @@
 
             <div id="content" class="{% block coltype %}colM{% endblock %}">
                 {% if opts.model_name == 'snapshot' and cl %}
-                    <small id="snapshot-view-mode">
-                        <a href="#list" title="List view" id="snapshot-view-list">☰</a> | 
-                        <a href="#grid" title="Grid view" id="snapshot-view-grid" style="letter-spacing: -.4em;">⣿⣿</a> 
-                    </small>
+                    <div id="snapshot-view-mode">
+                        <a href="#list" title="List view" id="snapshot-view-list">
+                            <span class="view-icon">☰</span>
+                            <span class="view-label">List</span>
+                        </a>
+                        <a href="#grid" title="Grid view" id="snapshot-view-grid">
+                            <span class="view-icon">⊞</span>
+                            <span class="view-label">Grid</span>
+                        </a>
+                    </div>
                 {% endif %}
                 {% block pretitle %}{% endblock %}
                 {% block content_title %}{# {% if title %}<h1>{{ title }}</h1>{% endif %} #}{% endblock %}
@@ -1500,10 +1506,20 @@
                 $("#snapshot-view-list").click(selectSnapshotListView)
                 $("#snapshot-view-grid").click(selectSnapshotGridView)
 
+                // Set active class based on current view
+                const isGridView = window.location.pathname === "{% url 'admin:grid' %}"
+                if (isGridView) {
+                    $("#snapshot-view-grid").addClass('active')
+                    $("#snapshot-view-list").removeClass('active')
+                } else {
+                    $("#snapshot-view-list").addClass('active')
+                    $("#snapshot-view-grid").removeClass('active')
+                }
+
                 $('#changelist-form .card input:checkbox').change(function() {
                     if ($(this).is(':checked'))
                         $(this).parents('.card').addClass('selected-card')
-                    else 
+                    else
                         $(this).parents('.card').removeClass('selected-card')
                 })
             };

+ 26 - 4
archivebox/templates/admin/snapshots_grid.html

@@ -126,6 +126,21 @@
   .cards .card .card-info .timestamp {
       font-weight: 600;
   }
+  .cards .card .card-progress {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      padding: 4px 0;
+  }
+  .cards .card .card-progress .progress-text {
+      font-size: 11px;
+      color: #3b82f6;
+      font-weight: 500;
+  }
+  .cards .card.archiving {
+      border-color: #3b82f6;
+      box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+  }
   .cards .card .card-footer code {
       display: inline-block;
       width: 100%;
@@ -145,14 +160,21 @@
 {% block content %}
   <section class="cards">
     {% for obj in results %}
-      <div class="card">
+      <div class="card{% if obj.status == 'started' %} archiving{% endif %}">
           <div class="card-info">
             <a href="{% url 'admin:core_snapshot_change' obj.pk %}">
               <span class="timestamp">{{obj.bookmarked_at}}</span>
             </a>
-            <div style="padding: 4px 0;">
-              {{ obj.icons|safe }}
-            </div>
+            {% if obj.status == 'started' %}
+              <div class="card-progress">
+                <span class="snapshot-progress-spinner"></span>
+                <span class="progress-text">Archiving...</span>
+              </div>
+            {% else %}
+              <div style="padding: 4px 0;">
+                {{ obj.icons|safe }}
+              </div>
+            {% endif %}
             <label>
               <span>🗄&nbsp; {{ obj.archive_size | file_size }}</span>
               <input type="checkbox" name="_selected_action" value="{{obj.pk}}"/>

+ 153 - 9
archivebox/templates/static/admin.css

@@ -46,17 +46,46 @@ div.breadcrumbs {
     height: 25px;
 }
 
+/* View Mode Switcher - Prominent Toggle */
 #snapshot-view-mode {
     float: right;
     margin-bottom: -40px;
-    display: inline-block;
+    display: inline-flex;
+    align-items: center;
     margin-top: 3px;
     margin-right: 10px;
-    font-size: 14px;
-    opacity: 0.8;
+    font-size: 13px;
+    background: #f1f5f9;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    padding: 2px;
+    gap: 2px;
 }
 #snapshot-view-mode a {
-    color: #ccc;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    padding: 6px 12px;
+    color: #64748b;
+    text-decoration: none;
+    border-radius: 6px;
+    font-weight: 500;
+    transition: all 0.15s ease;
+    white-space: nowrap;
+}
+#snapshot-view-mode a:hover {
+    color: #334155;
+    background: #e2e8f0;
+}
+#snapshot-view-mode a.active {
+    background: #fff;
+    color: #1e293b;
+    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+#snapshot-view-mode .view-icon {
+    font-size: 14px;
+    line-height: 1;
 }
 
 body.model-snapshot.change-list div.breadcrumbs,
@@ -191,8 +220,79 @@ body.model-snapshot.change-list #content .object-tools {
     margin-top: 3px;
 }
 
+/* Filter Sidebar - Improved Layout */
+#content #changelist-filter {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 10px;
+    box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+    overflow: hidden;
+}
 #content #changelist-filter h2 {
-    border-radius: 4px 4px 0px 0px;
+    border-radius: 0;
+    background: #f8fafc;
+    color: #475569;
+    font-size: 11px;
+    font-weight: 600;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    padding: 10px 12px;
+    margin: 0;
+    border-bottom: 1px solid #e2e8f0;
+}
+#content #changelist-filter h3 {
+    font-size: 11px;
+    font-weight: 600;
+    color: #64748b;
+    text-transform: uppercase;
+    letter-spacing: 0.03em;
+    padding: 10px 12px 4px;
+    margin: 0;
+    background: transparent;
+}
+#content #changelist-filter ul {
+    padding: 0 6px 8px;
+    margin: 0;
+    list-style: none;
+}
+#content #changelist-filter li {
+    margin: 0;
+}
+#content #changelist-filter li a {
+    display: block;
+    padding: 6px 10px;
+    color: #475569;
+    text-decoration: none;
+    font-size: 12px;
+    border-radius: 5px;
+    transition: background 0.15s ease, color 0.15s ease;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+#content #changelist-filter li a:hover {
+    background: #f1f5f9;
+    color: #1e293b;
+}
+#content #changelist-filter li.selected a {
+    background: #eff6ff;
+    color: #2563eb;
+    font-weight: 500;
+}
+#content #changelist-filter-clear {
+    padding: 8px 12px;
+    margin: 0;
+    border-bottom: 1px solid #e2e8f0;
+    background: #fef2f2;
+}
+#content #changelist-filter-clear a {
+    color: #dc2626;
+    font-size: 12px;
+    font-weight: 500;
+    text-decoration: none;
+}
+#content #changelist-filter-clear a:hover {
+    text-decoration: underline;
 }
 
 #changelist .paginator {
@@ -203,15 +303,15 @@ body.model-snapshot.change-list #content .object-tools {
 @media (min-width: 767px) {
     #content #changelist-filter {
         top: 35px;
-        width: 110px;
+        width: 160px;
         margin-bottom: 35px;
     }
 
     .change-list .filtered .results,
-    .change-list .filtered .paginator, 
-    .filtered #toolbar, 
+    .change-list .filtered .paginator,
+    .filtered #toolbar,
     .filtered div.xfull {
-        margin-right: 115px;
+        margin-right: 168px;
     }
 }
 
@@ -356,3 +456,47 @@ tbody .output-link:hover {opacity: 1;}
 .fade-in-progress-url {
     animation: fadeIn 14s;
 }
+
+/* Snapshot Progress Spinner */
+.snapshot-progress-spinner {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    border: 2px solid #e2e8f0;
+    border-top-color: #3b82f6;
+    border-radius: 50%;
+    animation: snapshot-spin 0.8s linear infinite;
+}
+
+@keyframes snapshot-spin {
+    to { transform: rotate(360deg); }
+}
+
+/* Status Badges */
+.status-badge {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 12px;
+    font-size: 11px;
+    font-weight: 500;
+}
+.status-badge.queued { background: #fef3c7; color: #f59e0b; }
+.status-badge.started { background: #dbeafe; color: #3b82f6; }
+.status-badge.sealed { background: #d1fae5; color: #10b981; }
+.status-badge.succeeded { background: #d1fae5; color: #10b981; }
+.status-badge.failed { background: #fee2e2; color: #ef4444; }
+.status-badge.backoff { background: #fef3c7; color: #f59e0b; }
+.status-badge.skipped { background: #f3f4f6; color: #6b7280; }
+
+/* Progress Bar */
+.snapshot-progress-bar {
+    background: #e2e8f0;
+    border-radius: 4px;
+    height: 6px;
+    overflow: hidden;
+}
+.snapshot-progress-bar-fill {
+    height: 100%;
+    transition: width 0.3s ease;
+    border-radius: 4px;
+}

+ 256 - 0
archivebox/tests/test_admin_views.py

@@ -0,0 +1,256 @@
+"""
+Tests for admin snapshot views and search functionality.
+
+Tests cover:
+- Admin snapshot list view
+- Admin grid view
+- Search functionality (both admin and public)
+- Snapshot progress statistics
+"""
+
+import pytest
+from django.test import TestCase, Client, override_settings
+from django.urls import reverse
+from django.contrib.auth import get_user_model
+
+pytestmark = pytest.mark.django_db
+
+
+User = get_user_model()
+
+
[email protected]
+def admin_user(db):
+    """Create admin user for tests."""
+    return User.objects.create_superuser(
+        username='testadmin',
+        email='[email protected]',
+        password='testpassword'
+    )
+
+
[email protected]
+def crawl(admin_user, db):
+    """Create test crawl."""
+    from archivebox.crawls.models import Crawl
+    return Crawl.objects.create(
+        urls='https://example.com',
+        created_by=admin_user,
+    )
+
+
[email protected]
+def snapshot(crawl, db):
+    """Create test snapshot."""
+    from archivebox.core.models import Snapshot
+    return Snapshot.objects.create(
+        url='https://example.com',
+        crawl=crawl,
+        status=Snapshot.StatusChoices.STARTED,
+    )
+
+
+class TestSnapshotProgressStats:
+    """Tests for Snapshot.get_progress_stats() method."""
+
+    def test_get_progress_stats_empty(self, snapshot):
+        """Test progress stats with no archive results."""
+        stats = snapshot.get_progress_stats()
+
+        assert stats['total'] == 0
+        assert stats['succeeded'] == 0
+        assert stats['failed'] == 0
+        assert stats['running'] == 0
+        assert stats['pending'] == 0
+        assert stats['percent'] == 0
+        assert stats['output_size'] == 0
+        assert stats['is_sealed'] is False
+
+    def test_get_progress_stats_with_results(self, snapshot, db):
+        """Test progress stats with various archive result statuses."""
+        from archivebox.core.models import ArchiveResult
+
+        # Create some archive results
+        ArchiveResult.objects.create(
+            snapshot=snapshot,
+            plugin='wget',
+            status='succeeded',
+            output_size=1000,
+        )
+        ArchiveResult.objects.create(
+            snapshot=snapshot,
+            plugin='screenshot',
+            status='succeeded',
+            output_size=2000,
+        )
+        ArchiveResult.objects.create(
+            snapshot=snapshot,
+            plugin='pdf',
+            status='failed',
+        )
+        ArchiveResult.objects.create(
+            snapshot=snapshot,
+            plugin='readability',
+            status='started',
+        )
+
+        stats = snapshot.get_progress_stats()
+
+        assert stats['total'] == 4
+        assert stats['succeeded'] == 2
+        assert stats['failed'] == 1
+        assert stats['running'] == 1
+        assert stats['output_size'] == 3000
+        assert stats['percent'] == 75  # (2 succeeded + 1 failed) / 4 total
+
+    def test_get_progress_stats_sealed(self, snapshot):
+        """Test progress stats for sealed snapshot."""
+        from archivebox.core.models import Snapshot
+        snapshot.status = Snapshot.StatusChoices.SEALED
+        snapshot.save()
+
+        stats = snapshot.get_progress_stats()
+        assert stats['is_sealed'] is True
+
+
+class TestAdminSnapshotListView:
+    """Tests for the admin snapshot list view."""
+
+    def test_list_view_renders(self, client, admin_user):
+        """Test that the list view renders successfully."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url)
+
+        assert response.status_code == 200
+
+    def test_list_view_with_snapshots(self, client, admin_user, snapshot):
+        """Test list view with snapshots displays them."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url)
+
+        assert response.status_code == 200
+        assert b'example.com' in response.content
+
+    def test_grid_view_renders(self, client, admin_user):
+        """Test that the grid view renders successfully."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:grid')
+        response = client.get(url)
+
+        assert response.status_code == 200
+
+    def test_view_mode_switcher_present(self, client, admin_user):
+        """Test that view mode switcher is present."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url)
+
+        assert response.status_code == 200
+        # Check for view mode toggle elements
+        assert b'snapshot-view-mode' in response.content
+        assert b'snapshot-view-list' in response.content
+        assert b'snapshot-view-grid' in response.content
+
+
+class TestAdminSnapshotSearch:
+    """Tests for admin snapshot search functionality."""
+
+    def test_search_by_url(self, client, admin_user, snapshot):
+        """Test searching snapshots by URL."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url, {'q': 'example.com'})
+
+        assert response.status_code == 200
+        # The search should find the example.com snapshot
+        assert b'example.com' in response.content
+
+    def test_search_by_title(self, client, admin_user, crawl, db):
+        """Test searching snapshots by title."""
+        from archivebox.core.models import Snapshot
+        Snapshot.objects.create(
+            url='https://example.com/titled',
+            title='Unique Title For Testing',
+            crawl=crawl,
+        )
+
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url, {'q': 'Unique Title'})
+
+        assert response.status_code == 200
+
+    def test_search_by_tag(self, client, admin_user, snapshot, db):
+        """Test searching snapshots by tag."""
+        from archivebox.core.models import Tag
+        tag = Tag.objects.create(name='test-search-tag')
+        snapshot.tags.add(tag)
+
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url, {'q': 'test-search-tag'})
+
+        assert response.status_code == 200
+
+    def test_empty_search(self, client, admin_user):
+        """Test empty search returns all snapshots."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url, {'q': ''})
+
+        assert response.status_code == 200
+
+    def test_no_results_search(self, client, admin_user):
+        """Test search with no results."""
+        client.login(username='testadmin', password='testpassword')
+        url = reverse('admin:core_snapshot_changelist')
+        response = client.get(url, {'q': 'nonexistent-url-xyz789'})
+
+        assert response.status_code == 200
+
+
+class TestPublicIndexSearch:
+    """Tests for public index search functionality."""
+
+    @pytest.fixture
+    def public_snapshot(self, crawl, db):
+        """Create sealed snapshot for public index."""
+        from archivebox.core.models import Snapshot
+        return Snapshot.objects.create(
+            url='https://public-example.com',
+            title='Public Example Website',
+            crawl=crawl,
+            status=Snapshot.StatusChoices.SEALED,
+        )
+
+    @override_settings(PUBLIC_INDEX=True)
+    def test_public_search_by_url(self, client, public_snapshot):
+        """Test public search by URL."""
+        response = client.get('/public/', {'q': 'public-example.com'})
+        assert response.status_code == 200
+
+    @override_settings(PUBLIC_INDEX=True)
+    def test_public_search_by_title(self, client, public_snapshot):
+        """Test public search by title."""
+        response = client.get('/public/', {'q': 'Public Example'})
+        assert response.status_code == 200
+
+    @override_settings(PUBLIC_INDEX=True)
+    def test_public_search_query_type_meta(self, client, public_snapshot):
+        """Test public search with query_type=meta."""
+        response = client.get('/public/', {'q': 'example', 'query_type': 'meta'})
+        assert response.status_code == 200
+
+    @override_settings(PUBLIC_INDEX=True)
+    def test_public_search_query_type_url(self, client, public_snapshot):
+        """Test public search with query_type=url."""
+        response = client.get('/public/', {'q': 'public-example.com', 'query_type': 'url'})
+        assert response.status_code == 200
+
+    @override_settings(PUBLIC_INDEX=True)
+    def test_public_search_query_type_title(self, client, public_snapshot):
+        """Test public search with query_type=title."""
+        response = client.get('/public/', {'q': 'Website', 'query_type': 'title'})
+        assert response.status_code == 200