Implement tags editor widget for Django admin (#1729)

Implement a sleek inline tag editor with autocomplete and AJAX support:

- Create TagEditorWidget and InlineTagEditorWidget in core/widgets.py
  - Pills display with X remove button, sorted alphabetically
  - Text input with HTML5 datalist autocomplete
  - Enter/Space/Comma to add tags, auto-creates if doesn't exist
  - Backspace removes last tag when input is empty

- Add API endpoints in api/v1_core.py
  - GET /tags/autocomplete/ - search tags by name
  - POST /tags/create/ - get_or_create tag
  - POST /tags/add-to-snapshot/ - add tag to snapshot via AJAX
  - POST /tags/remove-from-snapshot/ - remove tag from snapshot

- Update admin_snapshots.py
  - Replace FilteredSelectMultiple with TagEditorWidget in bulk actions
  - Create SnapshotAdminForm with tags_editor field
  - Update title_str() to render inline tag editor in list view
  - Remove TagInline, use widget instead

- Add CSS styles in templates/admin/base.html
  - Blue gradient pill styling matching admin theme
  - Focus ring and hover states
  - Compact inline variant for list view

<!-- IMPORTANT: Do not submit PRs with only formatting / PEP8 / line
length changes. -->

# Summary

<!--e.g. This PR fixes ABC or adds the ability to do XYZ...-->

# Related issues

<!-- e.g. #123 or Roadmap goal #
https://github.com/pirate/ArchiveBox/wiki/Roadmap -->

# Changes these areas

- [ ] Bugfixes
- [ ] Feature behavior
- [ ] Command line interface
- [ ] Configuration options
- [ ] Internal architecture
- [ ] Snapshot data layout on disk

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Implemented a new interactive tags editor for Django admin with
autocomplete and AJAX add/remove, replacing the old multi-select and
inline. This makes tagging snapshots faster and safer in detail, list,
and bulk actions.

- **New Features**
- TagEditorWidget and InlineTagEditorWidget with pill UI and remove
buttons, XSS-safe rendering, and delegated events.
- Keyboard support: Enter/Space/Comma to add, Backspace to remove last
when input is empty.
- Datalist autocomplete and debounced search via GET
/tags/autocomplete/.
- AJAX endpoints: POST /tags/create/, /tags/add-to-snapshot/,
/tags/remove-from-snapshot/.

- **Refactors**
- Replaced FilteredSelectMultiple with TagEditorWidget in bulk actions;
parse comma-separated tags and use bulk_create/delete for efficient
add/remove.
- Added SnapshotAdminForm with tags_editor field; saves tags
case-insensitively and fixes remove_tags matching.
- Rendered inline tag editor in list view via title_str; removed
TagInline.
- Added CSS in admin/base.html for pill styling, focus ring, and compact
inline variant.

<sup>Written for commit 0dee662f41.
Summary will update on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Nick Sweeting
2025-12-30 11:59:39 -08:00
committed by GitHub
4 changed files with 1005 additions and 26 deletions

View File

@@ -300,3 +300,160 @@ def get_any(request, id: str):
pass
raise HttpError(404, 'Object with given ID not found')
### Tag Editor API Endpoints #########################################################################
class TagAutocompleteSchema(Schema):
tags: List[dict]
class TagCreateSchema(Schema):
name: str
class TagCreateResponseSchema(Schema):
success: bool
tag_id: int
tag_name: str
created: bool
class TagSnapshotRequestSchema(Schema):
snapshot_id: str
tag_name: Optional[str] = None
tag_id: Optional[int] = None
class TagSnapshotResponseSchema(Schema):
success: bool
tag_id: int
tag_name: str
@router.get("/tags/autocomplete/", response=TagAutocompleteSchema, url_name="tags_autocomplete")
def tags_autocomplete(request, q: str = ""):
"""Return tags matching the query for autocomplete."""
if not q:
# Return all tags if no query (limited to 50)
tags = Tag.objects.all().order_by('name')[:50]
else:
tags = Tag.objects.filter(name__icontains=q).order_by('name')[:20]
return {
'tags': [{'id': tag.pk, 'name': tag.name, 'slug': tag.slug} for tag in tags]
}
@router.post("/tags/create/", response=TagCreateResponseSchema, url_name="tags_create")
def tags_create(request, data: TagCreateSchema):
"""Create a new tag or return existing one."""
name = data.name.strip()
if not name:
raise HttpError(400, 'Tag name is required')
tag, created = Tag.objects.get_or_create(
name__iexact=name,
defaults={
'name': name,
'created_by': request.user if request.user.is_authenticated else None,
}
)
# If found by case-insensitive match, use that tag
if not created:
tag = Tag.objects.filter(name__iexact=name).first()
return {
'success': True,
'tag_id': tag.pk,
'tag_name': tag.name,
'created': created,
}
@router.post("/tags/add-to-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_add_to_snapshot")
def tags_add_to_snapshot(request, data: TagSnapshotRequestSchema):
"""Add a tag to a snapshot. Creates the tag if it doesn't exist."""
# Get the snapshot
try:
snapshot = Snapshot.objects.get(
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
)
except Snapshot.DoesNotExist:
raise HttpError(404, 'Snapshot not found')
except Snapshot.MultipleObjectsReturned:
snapshot = Snapshot.objects.filter(
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
).first()
# Get or create the tag
if data.tag_name:
name = data.tag_name.strip()
if not name:
raise HttpError(400, 'Tag name is required')
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={
'name': name,
'created_by': request.user if request.user.is_authenticated else None,
}
)
# If found by case-insensitive match, use that tag
tag = Tag.objects.filter(name__iexact=name).first() or tag
elif data.tag_id:
try:
tag = Tag.objects.get(pk=data.tag_id)
except Tag.DoesNotExist:
raise HttpError(404, 'Tag not found')
else:
raise HttpError(400, 'Either tag_name or tag_id is required')
# Add the tag to the snapshot
snapshot.tags.add(tag)
return {
'success': True,
'tag_id': tag.pk,
'tag_name': tag.name,
}
@router.post("/tags/remove-from-snapshot/", response=TagSnapshotResponseSchema, url_name="tags_remove_from_snapshot")
def tags_remove_from_snapshot(request, data: TagSnapshotRequestSchema):
"""Remove a tag from a snapshot."""
# Get the snapshot
try:
snapshot = Snapshot.objects.get(
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
)
except Snapshot.DoesNotExist:
raise HttpError(404, 'Snapshot not found')
except Snapshot.MultipleObjectsReturned:
snapshot = Snapshot.objects.filter(
Q(id__startswith=data.snapshot_id) | Q(timestamp__startswith=data.snapshot_id)
).first()
# Get the tag
if data.tag_id:
try:
tag = Tag.objects.get(pk=data.tag_id)
except Tag.DoesNotExist:
raise HttpError(404, 'Tag not found')
elif data.tag_name:
try:
tag = Tag.objects.get(name__iexact=data.tag_name.strip())
except Tag.DoesNotExist:
raise HttpError(404, 'Tag not found')
else:
raise HttpError(400, 'Either tag_name or tag_id is required')
# Remove the tag from the snapshot
snapshot.tags.remove(tag)
return {
'success': True,
'tag_id': tag.pk,
'tag_name': tag.name,
}

View File

@@ -11,7 +11,6 @@ from django.utils import timezone
from django import forms
from django.template import Template, RequestContext
from django.contrib.admin.helpers import ActionForm
from django.contrib.admin.widgets import FilteredSelectMultiple
from archivebox.config import DATA_DIR
from archivebox.config.common import SERVER_CONFIG
@@ -24,8 +23,8 @@ from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
from archivebox.workers.tasks import bg_archive_snapshots, bg_add
from archivebox.core.models import Tag, Snapshot
from archivebox.core.admin_tags import TagInline
from archivebox.core.admin_archiveresults import ArchiveResultInline, render_archiveresults_list
from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget
# GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': [], 'CAN_UPGRADE': False}
@@ -36,16 +35,30 @@ class SnapshotActionForm(ActionForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Define tags field in __init__ to avoid database access during app initialization
self.fields['tags'] = forms.ModelMultipleChoiceField(
self.fields['tags'] = forms.CharField(
label='Edit tags',
queryset=Tag.objects.all(),
required=False,
widget=FilteredSelectMultiple(
'core_tag__name',
False,
),
widget=TagEditorWidget(),
)
def clean_tags(self):
"""Parse comma-separated tag names into Tag objects."""
tags_str = self.cleaned_data.get('tags', '')
if not tags_str:
return []
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
tags = []
for name in tag_names:
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={'name': name}
)
# Use the existing tag if found by case-insensitive match
tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
return tags
# TODO: allow selecting actions for specific extractor plugins? is this useful?
# plugin = forms.ChoiceField(
# choices=ArchiveResult.PLUGIN_CHOICES,
@@ -54,10 +67,59 @@ class SnapshotActionForm(ActionForm):
# )
class SnapshotAdminForm(forms.ModelForm):
"""Custom form for Snapshot admin with tag editor widget."""
tags_editor = forms.CharField(
label='Tags',
required=False,
widget=TagEditorWidget(),
help_text='Type tag names and press Enter or Space to add. Click × to remove.',
)
class Meta:
model = Snapshot
fields = '__all__'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize tags_editor with current tags
if self.instance and self.instance.pk:
self.initial['tags_editor'] = ','.join(
sorted(tag.name for tag in self.instance.tags.all())
)
def save(self, commit=True):
instance = super().save(commit=False)
# Handle tags_editor field
if commit:
instance.save()
self._save_m2m()
# Parse and save tags from tags_editor
tags_str = self.cleaned_data.get('tags_editor', '')
if tags_str:
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
tags = []
for name in tag_names:
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={'name': name}
)
tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
instance.tags.set(tags)
else:
instance.tags.clear()
return instance
class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
form = SnapshotAdminForm
list_display = ('created_at', 'title_str', 'status', 'files', 'size', 'url_str')
sort_fields = ('title_str', 'url_str', 'created_at', 'status', 'crawl')
readonly_fields = ('admin_actions', 'status_info', 'tags_str', 'imported_timestamp', 'created_at', 'modified_at', 'downloaded_at', 'output_dir', 'archiveresults_list')
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')
list_filter = ('created_at', 'downloaded_at', 'archiveresult__status', 'crawl__created_by', 'tags__name')
@@ -66,6 +128,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
'fields': ('url', 'title'),
'classes': ('card', 'wide'),
}),
('Tags', {
'fields': ('tags_editor',),
'classes': ('card',),
}),
('Status', {
'fields': ('status', 'retry_at', 'status_info'),
'classes': ('card',),
@@ -75,7 +141,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
'classes': ('card',),
}),
('Relations', {
'fields': ('crawl', 'tags_str'),
'fields': ('crawl',),
'classes': ('card',),
}),
('Config', {
@@ -98,7 +164,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
ordering = ['-created_at']
actions = ['add_tags', 'remove_tags', 'update_titles', 'update_snapshots', 'resnapshot_snapshot', 'overwrite_snapshots', 'delete_snapshots']
inlines = [TagInline] # Removed ArchiveResultInline, using custom renderer instead
inlines = [] # Removed TagInline, using TagEditorWidget instead
list_per_page = min(max(5, SERVER_CONFIG.SNAPSHOTS_PER_PAGE), 5000)
action_form = SnapshotActionForm
@@ -257,11 +323,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
ordering='title',
)
def title_str(self, obj):
tags = ''.join(
format_html('<a href="/admin/core/snapshot/?tags__id__exact={}"><span class="tag">{}</span></a> ', tag.pk, tag.name)
for tag in obj.tags.all()
if str(tag.name).strip()
# Render inline tag editor widget
widget = InlineTagEditorWidget(snapshot_id=str(obj.pk))
tags_html = widget.render(
name=f'tags_{obj.pk}',
value=obj.tags.all(),
attrs={'id': f'tags_{obj.pk}'},
snapshot_id=str(obj.pk),
)
# Show title if available, otherwise show URL
display_text = obj.title or obj.url
css_class = 'fetched' if obj.title else 'pending'
@@ -278,7 +348,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
obj.archive_path,
css_class,
urldecode(htmldecode(display_text))[:128]
) + mark_safe(f' <span class="tags">{tags}</span>')
) + mark_safe(f' <span class="tags-inline-editor">{tags_html}</span>')
@admin.display(
description='Files Saved',
@@ -428,13 +498,41 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
description="+"
)
def add_tags(self, request, queryset):
tags = request.POST.getlist('tags')
print('[+] Adding tags', tags, 'to Snapshots', queryset)
for obj in queryset:
obj.tags.add(*tags)
from archivebox.core.models import SnapshotTag
# Get tags from the form - now comma-separated string
tags_str = request.POST.get('tags', '')
if not tags_str:
messages.warning(request, "No tags specified.")
return
# Parse comma-separated tag names and get/create Tag objects
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
tags = []
for name in tag_names:
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={'name': name}
)
tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
# Get snapshot IDs efficiently (works with select_across for all pages)
snapshot_ids = list(queryset.values_list('id', flat=True))
num_snapshots = len(snapshot_ids)
print('[+] Adding tags', [t.name for t in tags], 'to', num_snapshots, 'Snapshots')
# Bulk create M2M relationships (1 query per tag, not per snapshot)
for tag in tags:
SnapshotTag.objects.bulk_create(
[SnapshotTag(snapshot_id=sid, tag=tag) for sid in snapshot_ids],
ignore_conflicts=True # Skip if relationship already exists
)
messages.success(
request,
f"Added {len(tags)} tags to {queryset.count()} Snapshots.",
f"Added {len(tags)} tag(s) to {num_snapshots} Snapshot(s).",
)
@@ -442,11 +540,40 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
description=""
)
def remove_tags(self, request, queryset):
tags = request.POST.getlist('tags')
print('[-] Removing tags', tags, 'to Snapshots', queryset)
for obj in queryset:
obj.tags.remove(*tags)
from archivebox.core.models import SnapshotTag
# Get tags from the form - now comma-separated string
tags_str = request.POST.get('tags', '')
if not tags_str:
messages.warning(request, "No tags specified.")
return
# Parse comma-separated tag names and find matching Tag objects (case-insensitive)
tag_names = [name.strip() for name in tags_str.split(',') if name.strip()]
tags = []
for name in tag_names:
tag = Tag.objects.filter(name__iexact=name).first()
if tag:
tags.append(tag)
if not tags:
messages.warning(request, "No matching tags found.")
return
# Get snapshot IDs efficiently (works with select_across for all pages)
snapshot_ids = list(queryset.values_list('id', flat=True))
num_snapshots = len(snapshot_ids)
tag_ids = [t.pk for t in tags]
print('[-] Removing tags', [t.name for t in tags], 'from', num_snapshots, 'Snapshots')
# Bulk delete M2M relationships (1 query total, not per snapshot)
deleted_count, _ = SnapshotTag.objects.filter(
snapshot_id__in=snapshot_ids,
tag_id__in=tag_ids
).delete()
messages.success(
request,
f"Removed {len(tags)} tags from {queryset.count()} Snapshots.",
f"Removed {len(tags)} tag(s) from {num_snapshots} Snapshot(s) ({deleted_count} associations deleted).",
)

512
archivebox/core/widgets.py Normal file
View File

@@ -0,0 +1,512 @@
__package__ = 'archivebox.core'
import json
from django import forms
from django.utils.html import escape
class TagEditorWidget(forms.Widget):
"""
A widget that renders tags as clickable pills with inline editing.
- Displays existing tags alphabetically as styled pills with X remove button
- Text input with HTML5 datalist for autocomplete suggestions
- Press Enter or Space to create new tags (auto-creates if doesn't exist)
- Uses AJAX for autocomplete and tag creation
"""
template_name = None # We render manually
class Media:
css = {'all': []}
js = []
def __init__(self, attrs=None, snapshot_id=None):
self.snapshot_id = snapshot_id
super().__init__(attrs)
def _escape(self, value):
"""Escape HTML entities in value."""
return escape(str(value)) if value else ''
def render(self, name, value, attrs=None, renderer=None):
"""
Render the tag editor widget.
Args:
name: Field name
value: Can be:
- QuerySet of Tag objects (from M2M field)
- List of tag names
- Comma-separated string of tag names
- None
attrs: HTML attributes
renderer: Not used
"""
# Parse value to get list of tag names
tags = []
if value:
if hasattr(value, 'all'): # QuerySet
tags = sorted([tag.name for tag in value.all()])
elif isinstance(value, (list, tuple)):
if value and hasattr(value[0], 'name'): # List of Tag objects
tags = sorted([tag.name for tag in value])
else: # List of strings or IDs
# Could be tag IDs from form submission
from archivebox.core.models import Tag
tag_names = []
for v in value:
if isinstance(v, str) and not v.isdigit():
tag_names.append(v)
else:
try:
tag = Tag.objects.get(pk=v)
tag_names.append(tag.name)
except (Tag.DoesNotExist, ValueError):
if isinstance(v, str):
tag_names.append(v)
tags = sorted(tag_names)
elif isinstance(value, str):
tags = sorted([t.strip() for t in value.split(',') if t.strip()])
widget_id = attrs.get('id', name) if attrs else name
# Build pills HTML
pills_html = ''
for tag in tags:
pills_html += f'''
<span class="tag-pill" data-tag="{self._escape(tag)}">
{self._escape(tag)}
<button type="button" class="tag-remove-btn" data-tag-name="{self._escape(tag)}">&times;</button>
</span>
'''
# Build the widget HTML
html = f'''
<div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
<div id="{widget_id}_pills" class="tag-pills">
{pills_html}
</div>
<input type="text"
id="{widget_id}_input"
class="tag-inline-input"
list="{widget_id}_datalist"
placeholder="Add tag..."
autocomplete="off"
onkeydown="handleTagKeydown_{widget_id}(event)"
oninput="fetchTagAutocomplete_{widget_id}(this.value)"
>
<datalist id="{widget_id}_datalist"></datalist>
<input type="hidden" name="{name}" id="{widget_id}" value="{self._escape(','.join(tags))}">
</div>
<script>
(function() {{
var currentTags_{widget_id} = {json.dumps(tags)};
var autocompleteTimeout_{widget_id} = null;
window.focusTagInput_{widget_id} = function(event) {{
if (event.target.classList.contains('tag-remove-btn')) return;
document.getElementById('{widget_id}_input').focus();
}};
window.updateHiddenInput_{widget_id} = function() {{
document.getElementById('{widget_id}').value = currentTags_{widget_id}.join(',');
}};
window.addTag_{widget_id} = function(tagName) {{
tagName = tagName.trim();
if (!tagName) return;
// Check if tag already exists (case-insensitive)
var exists = currentTags_{widget_id}.some(function(t) {{
return t.toLowerCase() === tagName.toLowerCase();
}});
if (exists) {{
document.getElementById('{widget_id}_input').value = '';
return;
}}
// Add to current tags
currentTags_{widget_id}.push(tagName);
currentTags_{widget_id}.sort(function(a, b) {{
return a.toLowerCase().localeCompare(b.toLowerCase());
}});
// Rebuild pills
rebuildPills_{widget_id}();
updateHiddenInput_{widget_id}();
// Clear input
document.getElementById('{widget_id}_input').value = '';
// Create tag via API if it doesn't exist (fire and forget)
fetch('/api/v1/core/tags/create/', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
}},
body: JSON.stringify({{ name: tagName }})
}}).catch(function(err) {{
console.log('Tag creation note:', err);
}});
}};
window.removeTag_{widget_id} = function(tagName) {{
currentTags_{widget_id} = currentTags_{widget_id}.filter(function(t) {{
return t.toLowerCase() !== tagName.toLowerCase();
}});
rebuildPills_{widget_id}();
updateHiddenInput_{widget_id}();
}};
window.rebuildPills_{widget_id} = function() {{
var container = document.getElementById('{widget_id}_pills');
container.innerHTML = '';
currentTags_{widget_id}.forEach(function(tag) {{
var pill = document.createElement('span');
pill.className = 'tag-pill';
pill.setAttribute('data-tag', tag);
var tagText = document.createTextNode(tag);
pill.appendChild(tagText);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'tag-remove-btn';
removeBtn.setAttribute('data-tag-name', tag);
removeBtn.innerHTML = '&times;';
pill.appendChild(removeBtn);
container.appendChild(pill);
}});
}};
// Add event delegation for remove buttons
document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
if (event.target.classList.contains('tag-remove-btn')) {{
var tagName = event.target.getAttribute('data-tag-name');
if (tagName) {{
removeTag_{widget_id}(tagName);
}}
}}
}});
window.handleTagKeydown_{widget_id} = function(event) {{
var input = event.target;
var value = input.value.trim();
if (event.key === 'Enter' || event.key === ' ' || event.key === ',') {{
event.preventDefault();
if (value) {{
// Handle comma-separated values
value.split(',').forEach(function(tag) {{
addTag_{widget_id}(tag.trim());
}});
}}
}} else if (event.key === 'Backspace' && !value && currentTags_{widget_id}.length > 0) {{
// Remove last tag on backspace when input is empty
var lastTag = currentTags_{widget_id}.pop();
rebuildPills_{widget_id}();
updateHiddenInput_{widget_id}();
}}
}};
window.fetchTagAutocomplete_{widget_id} = function(query) {{
if (autocompleteTimeout_{widget_id}) {{
clearTimeout(autocompleteTimeout_{widget_id});
}}
autocompleteTimeout_{widget_id} = setTimeout(function() {{
if (!query || query.length < 1) {{
document.getElementById('{widget_id}_datalist').innerHTML = '';
return;
}}
fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
.then(function(response) {{ return response.json(); }})
.then(function(data) {{
var datalist = document.getElementById('{widget_id}_datalist');
datalist.innerHTML = '';
(data.tags || []).forEach(function(tag) {{
var option = document.createElement('option');
option.value = tag.name;
datalist.appendChild(option);
}});
}})
.catch(function(err) {{
console.log('Autocomplete error:', err);
}});
}}, 150);
}};
function escapeHtml(text) {{
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
function getCSRFToken() {{
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {{
var cookie = cookies[i].trim();
if (cookie.startsWith('csrftoken=')) {{
return cookie.substring('csrftoken='.length);
}}
}}
// Fallback to hidden input
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
return input ? input.value : '';
}}
}})();
</script>
'''
return html
class InlineTagEditorWidget(TagEditorWidget):
"""
Inline version of TagEditorWidget for use in list views.
Includes AJAX save functionality for immediate persistence.
"""
def __init__(self, attrs=None, snapshot_id=None):
super().__init__(attrs, snapshot_id)
self.snapshot_id = snapshot_id
def render(self, name, value, attrs=None, renderer=None, snapshot_id=None):
"""Render inline tag editor with AJAX save."""
# Use snapshot_id from __init__ or from render call
snapshot_id = snapshot_id or self.snapshot_id
# Parse value to get list of tag dicts with id and name
tags = []
tag_data = []
if value:
if hasattr(value, 'all'): # QuerySet
for tag in value.all():
tag_data.append({'id': tag.pk, 'name': tag.name})
tag_data.sort(key=lambda x: x['name'].lower())
tags = [t['name'] for t in tag_data]
elif isinstance(value, (list, tuple)):
if value and hasattr(value[0], 'name'):
for tag in value:
tag_data.append({'id': tag.pk, 'name': tag.name})
tag_data.sort(key=lambda x: x['name'].lower())
tags = [t['name'] for t in tag_data]
widget_id = f"inline_tags_{snapshot_id}" if snapshot_id else (attrs.get('id', name) if attrs else name)
# Build pills HTML with filter links
pills_html = ''
for td in tag_data:
pills_html += f'''
<span class="tag-pill" data-tag="{self._escape(td['name'])}" data-tag-id="{td['id']}">
<a href="/admin/core/snapshot/?tags__id__exact={td['id']}" class="tag-link">{self._escape(td['name'])}</a>
<button type="button" class="tag-remove-btn" data-tag-id="{td['id']}" data-tag-name="{self._escape(td['name'])}">&times;</button>
</span>
'''
html = f'''
<span id="{widget_id}_container" class="tag-editor-inline" onclick="focusInlineTagInput_{widget_id}(event)">
<span id="{widget_id}_pills" class="tag-pills-inline">
{pills_html}
</span>
<input type="text"
id="{widget_id}_input"
class="tag-inline-input-sm"
list="{widget_id}_datalist"
placeholder="+"
autocomplete="off"
onkeydown="handleInlineTagKeydown_{widget_id}(event)"
oninput="fetchInlineTagAutocomplete_{widget_id}(this.value)"
onfocus="this.placeholder='add tag...'"
onblur="this.placeholder='+'"
>
<datalist id="{widget_id}_datalist"></datalist>
</span>
<script>
(function() {{
var snapshotId_{widget_id} = '{snapshot_id}';
var currentTagData_{widget_id} = {json.dumps(tag_data)};
var autocompleteTimeout_{widget_id} = null;
window.focusInlineTagInput_{widget_id} = function(event) {{
event.stopPropagation();
if (event.target.classList.contains('tag-remove-btn') || event.target.classList.contains('tag-link')) return;
document.getElementById('{widget_id}_input').focus();
}};
window.addInlineTag_{widget_id} = function(tagName) {{
tagName = tagName.trim();
if (!tagName) return;
// Check if tag already exists
var exists = currentTagData_{widget_id}.some(function(t) {{
return t.name.toLowerCase() === tagName.toLowerCase();
}});
if (exists) {{
document.getElementById('{widget_id}_input').value = '';
return;
}}
// Add via API
fetch('/api/v1/core/tags/add-to-snapshot/', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
}},
body: JSON.stringify({{
snapshot_id: snapshotId_{widget_id},
tag_name: tagName
}})
}})
.then(function(response) {{ return response.json(); }})
.then(function(data) {{
if (data.success) {{
currentTagData_{widget_id}.push({{ id: data.tag_id, name: data.tag_name }});
currentTagData_{widget_id}.sort(function(a, b) {{
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}});
rebuildInlinePills_{widget_id}();
}}
}})
.catch(function(err) {{
console.error('Error adding tag:', err);
}});
document.getElementById('{widget_id}_input').value = '';
}};
window.removeInlineTag_{widget_id} = function(tagId) {{
fetch('/api/v1/core/tags/remove-from-snapshot/', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
}},
body: JSON.stringify({{
snapshot_id: snapshotId_{widget_id},
tag_id: tagId
}})
}})
.then(function(response) {{ return response.json(); }})
.then(function(data) {{
if (data.success) {{
currentTagData_{widget_id} = currentTagData_{widget_id}.filter(function(t) {{
return t.id !== tagId;
}});
rebuildInlinePills_{widget_id}();
}}
}})
.catch(function(err) {{
console.error('Error removing tag:', err);
}});
}};
window.rebuildInlinePills_{widget_id} = function() {{
var container = document.getElementById('{widget_id}_pills');
container.innerHTML = '';
currentTagData_{widget_id}.forEach(function(td) {{
var pill = document.createElement('span');
pill.className = 'tag-pill';
pill.setAttribute('data-tag', td.name);
pill.setAttribute('data-tag-id', td.id);
var link = document.createElement('a');
link.href = '/admin/core/snapshot/?tags__id__exact=' + td.id;
link.className = 'tag-link';
link.textContent = td.name;
pill.appendChild(link);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'tag-remove-btn';
removeBtn.setAttribute('data-tag-id', td.id);
removeBtn.setAttribute('data-tag-name', td.name);
removeBtn.innerHTML = '&times;';
pill.appendChild(removeBtn);
container.appendChild(pill);
}});
}};
// Add event delegation for remove buttons
document.getElementById('{widget_id}_pills').addEventListener('click', function(event) {{
if (event.target.classList.contains('tag-remove-btn')) {{
event.stopPropagation();
event.preventDefault();
var tagId = parseInt(event.target.getAttribute('data-tag-id'), 10);
if (tagId) {{
removeInlineTag_{widget_id}(tagId);
}}
}}
}});
window.handleInlineTagKeydown_{widget_id} = function(event) {{
event.stopPropagation();
var input = event.target;
var value = input.value.trim();
if (event.key === 'Enter' || event.key === ',') {{
event.preventDefault();
if (value) {{
value.split(',').forEach(function(tag) {{
addInlineTag_{widget_id}(tag.trim());
}});
}}
}}
}};
window.fetchInlineTagAutocomplete_{widget_id} = function(query) {{
if (autocompleteTimeout_{widget_id}) {{
clearTimeout(autocompleteTimeout_{widget_id});
}}
autocompleteTimeout_{widget_id} = setTimeout(function() {{
if (!query || query.length < 1) {{
document.getElementById('{widget_id}_datalist').innerHTML = '';
return;
}}
fetch('/api/v1/core/tags/autocomplete/?q=' + encodeURIComponent(query))
.then(function(response) {{ return response.json(); }})
.then(function(data) {{
var datalist = document.getElementById('{widget_id}_datalist');
datalist.innerHTML = '';
(data.tags || []).forEach(function(tag) {{
var option = document.createElement('option');
option.value = tag.name;
datalist.appendChild(option);
}});
}})
.catch(function(err) {{
console.log('Autocomplete error:', err);
}});
}}, 150);
}};
function escapeHtml(text) {{
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
function getCSRFToken() {{
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {{
var cookie = cookies[i].trim();
if (cookie.startsWith('csrftoken=')) {{
return cookie.substring('csrftoken='.length);
}}
}}
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
return input ? input.value : '';
}}
}})();
</script>
'''
return html

View File

@@ -1059,6 +1059,189 @@
color: #2563eb;
margin-right: 8px;
}
/* ============================================
Tag Editor Widget Styles
============================================ */
/* Main container - acts as input field */
.tag-editor-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 12px;
min-height: 42px;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
cursor: text;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.tag-editor-container:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
/* Pills container */
.tag-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
/* Individual tag pill */
.tag-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px 4px 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
font-size: 13px;
font-weight: 500;
border-radius: 16px;
white-space: nowrap;
transition: all 0.15s ease;
-webkit-font-smoothing: antialiased;
}
.tag-pill:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.tag-pill a.tag-link {
color: #fff;
text-decoration: none;
}
.tag-pill a.tag-link:hover {
text-decoration: underline;
}
/* Remove button on pills */
.tag-remove-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
opacity: 0.7;
transition: all 0.15s ease;
}
.tag-remove-btn:hover {
background: rgba(255, 255, 255, 0.4);
opacity: 1;
}
/* Inline input for adding tags */
.tag-inline-input {
flex: 1;
min-width: 120px;
padding: 4px 0;
border: none;
outline: none;
font-size: 14px;
font-family: inherit;
background: transparent;
color: #1e293b;
}
.tag-inline-input::placeholder {
color: #94a3b8;
}
/* Inline editor for list view - more compact */
.tag-editor-inline {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 2px 4px;
background: transparent;
border-radius: 4px;
cursor: text;
vertical-align: middle;
}
.tag-pills-inline {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.tag-editor-inline .tag-pill {
padding: 2px 6px 2px 8px;
font-size: 11px;
border-radius: 12px;
}
.tag-editor-inline .tag-remove-btn {
width: 14px;
height: 14px;
font-size: 12px;
}
.tag-inline-input-sm {
width: 24px;
min-width: 24px;
max-width: 100px;
padding: 2px 4px;
border: none;
outline: none;
font-size: 11px;
font-family: inherit;
background: transparent;
color: #64748b;
transition: width 0.15s ease;
}
.tag-inline-input-sm:focus {
width: 80px;
color: #1e293b;
}
.tag-inline-input-sm::placeholder {
color: #94a3b8;
}
/* Container in list view title column */
.tags-inline-editor {
display: inline;
margin-left: 8px;
}
/* Existing tag styles (keep for backwards compat) */
.tags .tag {
display: inline-block;
padding: 2px 8px;
margin: 1px 2px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
font-size: 11px;
font-weight: 500;
border-radius: 12px;
text-decoration: none;
transition: all 0.15s ease;
}
.tags .tag:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
</style>
{% endblock %}