mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-04-06 07:47:53 +10:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user