Improve quick composer defaults and due filter

This commit is contained in:
rucki
2026-09-05 12:09:35 +02:00
parent c6309cabd8
commit 60786e6814
7 changed files with 70 additions and 20 deletions
+3
View File
@@ -180,3 +180,6 @@ cython_debug/
# Local my2dos API token # Local my2dos API token
.my2dos_api_key .my2dos_api_key
# Local my2dos project API config
.my2dos
+1 -3
View File
@@ -57,10 +57,9 @@ class UserSettingsForm(forms.ModelForm):
class UserPreferenceForm(forms.ModelForm): class UserPreferenceForm(forms.ModelForm):
class Meta: class Meta:
model = UserPreference model = UserPreference
fields = ['language', 'default_item_kind', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines'] fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
labels = { labels = {
'language': _('Language'), 'language': _('Language'),
'default_item_kind': _('Default type for new entries'),
'show_notes': _('Show notes'), 'show_notes': _('Show notes'),
'show_open_todos': _('Show open tasks'), 'show_open_todos': _('Show open tasks'),
'show_done_todos': _('Show completed tasks'), 'show_done_todos': _('Show completed tasks'),
@@ -70,7 +69,6 @@ class UserPreferenceForm(forms.ModelForm):
} }
widgets = { widgets = {
'language': forms.Select(attrs={'class': 'form-select'}), 'language': forms.Select(attrs={'class': 'form-select'}),
'default_item_kind': forms.Select(attrs={'class': 'form-select'}),
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}), 'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
} }
+8
View File
@@ -13,4 +13,12 @@
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('numbered')" title="{% trans 'Numbered list' %}">1.</button> <button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('numbered')" title="{% trans 'Numbered list' %}">1.</button>
</div> </div>
<span class="small text-muted ms-2 d-none d-md-inline">{% trans 'Select text, then format it' %}</span> <span class="small text-muted ms-2 d-none d-md-inline">{% trans 'Select text, then format it' %}</span>
{% if item_kind_choices %}
<label class="ms-auto mb-0 d-flex align-items-center gap-2 small text-muted">
<span class="d-none d-sm-inline">{% trans 'Type' %}</span>
<select class="form-select form-select-sm w-auto" name="default_item_kind" title="{% trans 'Default type for new entry' %}">
{% for value, label in item_kind_choices %}<option value="{{ value }}"{% if value == selected_item_kind %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</label>
{% endif %}
</div> </div>
+8 -2
View File
@@ -109,7 +109,7 @@
</div> </div>
</aside> </aside>
<section class="col-lg-9"> <section class="col-lg-9">
<form class="desktop-quick-create position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}"> <form class="desktop-quick-create position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in composer_tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
{% csrf_token %} {% csrf_token %}
<fieldset :disabled="editingItem" :class="{'opacity-50': editingItem}"> <fieldset :disabled="editingItem" :class="{'opacity-50': editingItem}">
<div class="small text-muted mb-2" x-show="editingItem" x-cloak>{% trans "Inline editor is open; quick entry is disabled." %}</div> <div class="small text-muted mb-2" x-show="editingItem" x-cloak>{% trans "Inline editor is open; quick entry is disabled." %}</div>
@@ -119,7 +119,7 @@
<button type="button" class="btn btn-sm btn-success flex-fill" @click="insert('/link')">Link</button> <button type="button" class="btn btn-sm btn-success flex-fill" @click="insert('/link')">Link</button>
<button type="button" class="btn btn-sm btn-info flex-fill" @click="insert('/journal')">Journal</button> <button type="button" class="btn btn-sm btn-info flex-fill" @click="insert('/journal')">Journal</button>
</div> </div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details> <details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in composer_tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ form.content }} {{ form.content }}
<div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak> <div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template> <template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
@@ -135,7 +135,13 @@
</fieldset> </fieldset>
</form> </form>
<div id="items" class="vstack gap-3"> <div id="items" class="vstack gap-3">
{% if active_status == 'due' %}
{% for item in due_now_items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-4">{% trans "No due tasks." %}</div>{% endfor %}
{% if upcoming_due_items %}<div class="small text-muted text-uppercase fw-semibold mt-2">{% trans "Due in the next 7 days" %}</div>{% endif %}
{% for item in upcoming_due_items %}{% include 'core/_item.html' %}{% endfor %}
{% else %}
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %} {% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %}
{% endif %}
</div> </div>
</section> </section>
</div> </div>
+1 -1
View File
@@ -35,7 +35,7 @@
{% csrf_token %}<input type="hidden" name="action" value="preferences"> {% csrf_token %}<input type="hidden" name="action" value="preferences">
<div class="row g-3"> <div class="row g-3">
{% for field in preference_form %} {% for field in preference_form %}
{% if field.name == 'language' or field.name == 'default_item_kind' or field.name == 'overview_lines' %} {% if field.name == 'language' or field.name == 'overview_lines' %}
<div class="col-12"><label class="form-label">{{ field.label }}</label>{{ field }}</div> <div class="col-12"><label class="form-label">{{ field.label }}</label>{{ field }}</div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
+33 -5
View File
@@ -1,11 +1,13 @@
import shutil import shutil
import tempfile import tempfile
from datetime import datetime, time, timedelta
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown, markdown_title from .templatetags.markdown_extras import markdown, markdown_title
@@ -49,6 +51,11 @@ class DefaultItemKindTests(TestCase):
self.assertRedirects(response, reverse('index')) self.assertRedirects(response, reverse('index'))
self.assertEqual(Item.objects.get().kind, Item.Kind.TODO) self.assertEqual(Item.objects.get().kind, Item.Kind.TODO)
def test_quick_create_can_override_default_from_composer(self):
response = self.client.post(reverse('index'), {'content': 'Composer note', 'default_item_kind': Item.Kind.NOTE})
self.assertRedirects(response, reverse('index'))
self.assertEqual(Item.objects.get().kind, Item.Kind.NOTE)
def test_explicit_command_overrides_default(self): def test_explicit_command_overrides_default(self):
kind, _, content, _ = parse_quick_content('/note Explicit note', Item.Kind.TODO) kind, _, content, _ = parse_quick_content('/note Explicit note', Item.Kind.TODO)
self.assertEqual(kind, Item.Kind.NOTE) self.assertEqual(kind, Item.Kind.NOTE)
@@ -203,7 +210,8 @@ class NavigationAndLayoutTests(TestCase):
def test_preference_selects_are_stacked(self): def test_preference_selects_are_stacked(self):
response = self.client.get(reverse('settings')) response = self.client.get(reverse('settings'))
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0] preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 3) self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 2)
self.assertNotContains(response, 'name="default_item_kind"')
def test_composer_menu_is_before_toolbar_and_file_controls(self): def test_composer_menu_is_before_toolbar_and_file_controls(self):
response = self.client.get(reverse('index')) response = self.client.get(reverse('index'))
@@ -222,7 +230,7 @@ class NavigationAndLayoutTests(TestCase):
response = self.client.get(reverse('edit_item', args=[item.id])) response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, 'inline-edit-form') self.assertContains(response, 'inline-edit-form')
def test_tag_filter_list_depends_on_current_filters(self): def test_tag_filter_list_depends_on_current_filters_but_composer_gets_all_tags(self):
open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly') open_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Open #openonly')
open_todo.sync_metadata() open_todo.sync_metadata()
done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True) done_todo = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Done #archiveonly', is_done=True)
@@ -231,14 +239,34 @@ class NavigationAndLayoutTests(TestCase):
note.sync_metadata() note.sync_metadata()
response = self.client.get(f'{reverse("index")}?status=archive') response = self.client.get(f'{reverse("index")}?status=archive')
self.assertContains(response, '#archiveonly') html = response.content.decode()
self.assertNotContains(response, '#openonly') filter_tags = html.split('id="tag-list"', 1)[1].split('</div>', 1)[0]
self.assertNotContains(response, '#noteonly') self.assertIn('#archiveonly', filter_tags)
self.assertNotIn('#openonly', filter_tags)
self.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html)
response = self.client.get(f'{reverse("tag_list")}?status=archive') response = self.client.get(f'{reverse("tag_list")}?status=archive')
self.assertContains(response, '#archiveonly') self.assertContains(response, '#archiveonly')
self.assertNotContains(response, '#openonly') self.assertNotContains(response, '#openonly')
def test_due_filter_also_shows_next_seven_days_separately(self):
today = timezone.localdate()
due = timezone.make_aware(datetime.combine(today, time(12, 0)))
upcoming = timezone.make_aware(datetime.combine(today + timedelta(days=3), time(12, 0)))
later = timezone.make_aware(datetime.combine(today + timedelta(days=8), time(12, 0)))
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Due now', due_at=due)
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Upcoming soon', due_at=upcoming)
Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Later task', due_at=later)
response = self.client.get(f'{reverse("index")}?status=due')
self.assertContains(response, 'Due now')
self.assertContains(response, 'Upcoming soon')
self.assertContains(response, 'Due in the next 7 days')
self.assertNotContains(response, 'Later task')
class AttachmentEditTests(TestCase): class AttachmentEditTests(TestCase):
def setUp(self): def setUp(self):
+15 -8
View File
@@ -305,8 +305,8 @@ def index_filter_tags(request, user, prefs, active_tags=None):
if status == 'open': if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False) items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due': elif status == 'due':
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59))) upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end) items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end)
elif status == 'archive': elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True) items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences: elif not applied_preferences:
@@ -327,7 +327,7 @@ def index(request):
if form.is_valid(): if form.is_valid():
raw = form.cleaned_data['content'] raw = form.cleaned_data['content']
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) kind, visibility, content, url = parse_quick_content(raw, requested_default_item_kind(request.POST, prefs))
active_tag = request.GET.get('tag') active_tag = request.GET.get('tag')
if active_tag and not TAG_RE.search(without_markdown_code(content)): if active_tag and not TAG_RE.search(without_markdown_code(content)):
content = f'{content} #{active_tag.strip().lower()}' content = f'{content} #{active_tag.strip().lower()}'
@@ -379,26 +379,28 @@ def index(request):
items = items.filter(type_filter) if type_filter else items.none() items = items.filter(type_filter) if type_filter else items.none()
if day: if day:
items = items.filter(created_at__date=day) items = items.filter(created_at__date=day)
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
if status == 'open': if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False) items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due': elif status == 'due':
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59))) items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end).order_by('due_at', '-created_at')
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at')
elif status == 'archive': elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True) items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences: elif not applied_preferences:
items = items.exclude(kind=Item.Kind.TODO, is_done=True) items = items.exclude(kind=Item.Kind.TODO, is_done=True)
if q: if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct() items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count() due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count()
due_now_items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at') if status == 'due' else Item.objects.none()
upcoming_due_items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__gt=today_end, due_at__lte=upcoming_end).order_by('due_at', '-created_at') if status == 'due' else Item.objects.none()
if prefs.due_first and status != 'archive': if prefs.due_first and status != 'archive':
items = items.annotate(due_rank=Case( items = items.annotate(due_rank=Case(
When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)), When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)),
default=Value(1), output_field=IntegerField(), default=Value(1), output_field=IntegerField(),
)).order_by('due_rank', 'due_at', '-created_at') )).order_by('due_rank', 'due_at', '-created_at')
return render(request, 'core/index.html', { return render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines, 'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': Tag.objects.all(), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
}) })
@@ -423,7 +425,7 @@ def new_item(request):
if requested_kind in {'todo', 'link', 'journal'} and not TYPE_COMMAND_RE.match(raw.strip()): if requested_kind in {'todo', 'link', 'journal'} and not TYPE_COMMAND_RE.match(raw.strip()):
raw = f'/{requested_kind} {raw}' raw = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) kind, visibility, content, url = parse_quick_content(raw, requested_default_item_kind(request.POST, prefs))
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at) item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
@@ -1167,6 +1169,11 @@ def tag_list(request):
return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode}) return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
def requested_default_item_kind(post, prefs):
kind = post.get('default_item_kind') or prefs.default_item_kind
return kind if kind in Item.Kind.values else prefs.default_item_kind
@login_required @login_required
def api_tags(request): def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))}) return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})