Add nested collection management
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{% load markdown_extras %}
|
||||||
|
{% for section in sections %}
|
||||||
|
<li>
|
||||||
|
<a href="#section-{{ section.id }}">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:90 }}{% endif %}</a>
|
||||||
|
{% if section.sub_collection and section.sub_collection.sections.all %}
|
||||||
|
<ol>
|
||||||
|
{% include 'core/_collection_toc_items.html' with sections=section.sub_collection.sections.all %}
|
||||||
|
</ol>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
{% if sections or tag_items %}
|
{% if sections or tag_items %}
|
||||||
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
|
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
|
||||||
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
|
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
|
||||||
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:90 }}{% endif %}</a></li>{% endfor %}{% endif %}</ol>
|
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% include 'core/_collection_toc_items.html' with sections=sections %}{% endif %}</ol>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
{% load i18n markdown_extras %}
|
{% load i18n markdown_extras %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
|
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
|
||||||
<div class="d-none d-md-block">
|
<div id="collection-manage" class="d-none d-md-block" hx-boost="true" hx-target="#collection-manage" hx-select="#collection-manage" hx-swap="outerHTML show:none" hx-on::after-settle="if(window.collectionFocus==='__last'){document.querySelector('#collection-sections section:last-of-type')?.scrollIntoView({block:'center'})}else if(window.collectionFocus){document.getElementById(window.collectionFocus)?.scrollIntoView({block:'center'})}window.collectionFocus=null">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ collection.get_absolute_url }}">← {% trans "View collection" %}</a>
|
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ collection.get_absolute_url }}" hx-boost="false">← {% trans "View collection" %}</a>
|
||||||
<h1 class="display-6 mb-2">{{ collection.title }}</h1>
|
<h1 class="display-6 mb-2">{{ collection.title }}</h1>
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}
|
{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}
|
||||||
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
|
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}<div class="d-flex gap-2"><a class="btn btn-outline-secondary" href="{% url 'collection_edit' collection.id %}">{% trans "Collection settings" %}</a>{% if can_delete %}<form method="post" action="{% url 'collection_delete' collection.id %}" onsubmit="return confirm('{% trans "Delete collection? The notes remain available." %}')">{% csrf_token %}<button class="btn btn-outline-danger">{% trans "Delete" %}</button></form>{% endif %}</div>{% endif %}
|
{% if can_edit %}<div class="d-flex gap-2"><a class="btn btn-outline-secondary" href="{% url 'collection_edit' collection.id %}" hx-boost="false">{% trans "Collection settings" %}</a>{% if can_delete %}<form method="post" action="{% url 'collection_delete' collection.id %}" hx-boost="false" onsubmit="return confirm('{% trans "Delete collection? The notes remain available." %}')">{% csrf_token %}<button class="btn btn-outline-danger">{% trans "Delete" %}</button></form>{% endif %}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
|
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
|
||||||
|
|
||||||
{% if sections %}
|
{% if sections %}
|
||||||
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:90 }}{% endif %}</a></li>{% endfor %}</ol></div></div>
|
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% include 'core/_collection_toc_items.html' with sections=sections %}</ol></div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if pending_item %}
|
{% if pending_item %}
|
||||||
@@ -35,20 +35,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="vstack gap-4 mb-5">
|
<div id="collection-sections" class="vstack gap-4 mb-5">
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
|
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
<h2 class="h3 mb-0">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}</h2>
|
<h2 class="h3 mb-0">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}</h2>
|
||||||
<div class="d-flex gap-2 align-items-center">{% if section.item %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>{% else %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'collection_manage' section.sub_collection.id %}">{% trans "Edit collection" %}</a><a class="small text-muted" href="{{ section.sub_collection.get_absolute_url }}">#{{ section.sub_collection.id }}</a>{% endif %}</div>
|
<div class="d-flex gap-2 align-items-center">{% if section.item %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}" hx-boost="false">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>{% else %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'collection_manage' section.sub_collection.id %}" hx-boost="false">{% trans "Edit collection" %}</a><a class="small text-muted" href="{{ section.sub_collection.get_absolute_url }}">#{{ section.sub_collection.id }}</a>{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if section.item %}<div class="content">{{ section.item|item_markdown }}</div>
|
{% if section.item %}<div class="content">{{ section.item|item_markdown }}</div>
|
||||||
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}{% else %}<div class="text-muted">{% trans "Nested collection" %}: {{ section.sub_collection.title }}</div>{% endif %}
|
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}{% else %}<div class="text-muted">{% trans "Nested collection" %}: {{ section.sub_collection.title }}</div>{% endif %}
|
||||||
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
|
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
|
||||||
<form method="post" class="d-flex gap-2 flex-grow-1">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
|
<form method="post" class="d-flex gap-2 flex-grow-1" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
|
||||||
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
|
<form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
|
||||||
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form>
|
<form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form>
|
||||||
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="remove"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-danger">{% trans "Remove" %}</button></form>
|
<form method="post" hx-on::before-request="window.collectionFocus='collection-sections'">{% csrf_token %}<input type="hidden" name="action" value="remove"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-danger">{% trans "Remove" %}</button></form>
|
||||||
</div>{% endif %}
|
</div>{% endif %}
|
||||||
</div></section>
|
</div></section>
|
||||||
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
|
||||||
@@ -60,11 +60,11 @@
|
|||||||
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
|
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
|
||||||
<h3 class="h6">{% trans "Notes" %}</h3>
|
<h3 class="h6">{% trans "Notes" %}</h3>
|
||||||
<div class="vstack gap-2 mb-4">
|
<div class="vstack gap-2 mb-4">
|
||||||
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
|
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="h6">{% trans "Collections" %}</h3>
|
<h3 class="h6">{% trans "Collections" %}</h3>
|
||||||
<div class="vstack gap-2">
|
<div class="vstack gap-2">
|
||||||
{% for candidate in collection_candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ candidate.title }}</div><div class="small text-muted">#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add_collection"><input type="hidden" name="collection_id" value="{{ candidate.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching collections." %}</div>{% endfor %}
|
{% for candidate in collection_candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ candidate.title }}</div><div class="small text-muted">#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}</div></div><form method="post" hx-on::before-request="window.collectionFocus='__last'">{% csrf_token %}<input type="hidden" name="action" value="add_collection"><input type="hidden" name="collection_id" value="{{ candidate.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching collections." %}</div>{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -337,10 +337,22 @@ class CollectionTests(TestCase):
|
|||||||
CollectionSection.objects.create(collection=collection, item=note, position=0)
|
CollectionSection.objects.create(collection=collection, item=note, position=0)
|
||||||
manage_url = reverse('collection_manage', args=[collection.id])
|
manage_url = reverse('collection_manage', args=[collection.id])
|
||||||
response = self.client.get(manage_url)
|
response = self.client.get(manage_url)
|
||||||
|
self.assertContains(response, 'id="collection-manage"')
|
||||||
|
self.assertContains(response, 'hx-boost="true"')
|
||||||
|
self.assertContains(response, 'hx-swap="outerHTML show:none"')
|
||||||
self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
|
self.assertContains(response, f'{reverse("item_editor", args=[note.id])}?next={manage_url}')
|
||||||
self.assertContains(response, '>Section</')
|
self.assertContains(response, '>Section</')
|
||||||
self.assertNotContains(response, '>## Section</')
|
self.assertNotContains(response, '>## Section</')
|
||||||
|
|
||||||
|
def test_collection_manage_preserves_search_when_adding_note(self):
|
||||||
|
collection = Collection.objects.create(owner=self.user, title='Reading view')
|
||||||
|
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Findable note')
|
||||||
|
manage_url = reverse('collection_manage', args=[collection.id])
|
||||||
|
|
||||||
|
response = self.client.post(f'{manage_url}?q=Findable', {'action': 'add', 'item_id': note.id})
|
||||||
|
|
||||||
|
self.assertRedirects(response, f'{manage_url}?q=Findable')
|
||||||
|
|
||||||
def test_collection_can_include_collection(self):
|
def test_collection_can_include_collection(self):
|
||||||
parent = Collection.objects.create(owner=self.user, title='Parent')
|
parent = Collection.objects.create(owner=self.user, title='Parent')
|
||||||
child = Collection.objects.create(owner=self.user, title='Child')
|
child = Collection.objects.create(owner=self.user, title='Child')
|
||||||
|
|||||||
+8
-6
@@ -800,27 +800,29 @@ def collection_manage(request, pk):
|
|||||||
if collection.mode == Collection.Mode.TAGS:
|
if collection.mode == Collection.Mode.TAGS:
|
||||||
return redirect('collection_edit', pk=collection.pk)
|
return redirect('collection_edit', pk=collection.pk)
|
||||||
manage_url = reverse('collection_manage', args=[collection.pk])
|
manage_url = reverse('collection_manage', args=[collection.pk])
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
redirect_url = f'{manage_url}?q={q}' if q else manage_url
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = request.POST.get('action')
|
action = request.POST.get('action')
|
||||||
if action in {'add', 'confirm_add'}:
|
if action in {'add', 'confirm_add'}:
|
||||||
item = get_object_or_404(editable_collection_notes(request.user), pk=request.POST.get('item_id'))
|
item = get_object_or_404(editable_collection_notes(request.user), pk=request.POST.get('item_id'))
|
||||||
if collection_item_is_compatible(collection, item):
|
if collection_item_is_compatible(collection, item):
|
||||||
add_collection_section(collection, item)
|
add_collection_section(collection, item)
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
if action == 'add':
|
if action == 'add':
|
||||||
return render(request, 'core/collection_manage.html', collection_detail_context(request, collection, item))
|
return render(request, 'core/collection_manage.html', collection_detail_context(request, collection, item))
|
||||||
mode = request.POST.get('mode')
|
mode = request.POST.get('mode')
|
||||||
if mode == 'convert':
|
if mode == 'convert':
|
||||||
if not item_can_change_for_collection(request.user, item, collection):
|
if not item_can_change_for_collection(request.user, item, collection):
|
||||||
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
|
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
item = adapt_item_for_collection(item, collection)
|
item = adapt_item_for_collection(item, collection)
|
||||||
elif mode == 'copy':
|
elif mode == 'copy':
|
||||||
item = copy_item_for_collection(item, collection, request.user)
|
item = copy_item_for_collection(item, collection, request.user)
|
||||||
else:
|
else:
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
add_collection_section(collection, item)
|
add_collection_section(collection, item)
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
if action == 'add_collection':
|
if action == 'add_collection':
|
||||||
sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL)
|
sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL)
|
||||||
if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
|
if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
|
||||||
@@ -829,7 +831,7 @@ def collection_manage(request, pk):
|
|||||||
messages.error(request, _('This collection has an incompatible visibility.'))
|
messages.error(request, _('This collection has an incompatible visibility.'))
|
||||||
else:
|
else:
|
||||||
add_collection_subcollection(collection, sub_collection)
|
add_collection_subcollection(collection, sub_collection)
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
|
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
|
||||||
if action == 'remove':
|
if action == 'remove':
|
||||||
section.delete()
|
section.delete()
|
||||||
@@ -848,7 +850,7 @@ def collection_manage(request, pk):
|
|||||||
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
|
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
|
||||||
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
|
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
|
||||||
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
|
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
|
||||||
return redirect(manage_url)
|
return redirect(redirect_url)
|
||||||
return render(request, 'core/collection_manage.html', collection_detail_context(request, collection))
|
return render(request, 'core/collection_manage.html', collection_detail_context(request, collection))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user