Improve quick composer defaults and due filter
This commit is contained in:
@@ -180,3 +180,6 @@ cython_debug/
|
||||
|
||||
# Local my2dos API token
|
||||
.my2dos_api_key
|
||||
|
||||
# Local my2dos project API config
|
||||
.my2dos
|
||||
|
||||
+1
-3
@@ -57,10 +57,9 @@ class UserSettingsForm(forms.ModelForm):
|
||||
class UserPreferenceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
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 = {
|
||||
'language': _('Language'),
|
||||
'default_item_kind': _('Default type for new entries'),
|
||||
'show_notes': _('Show notes'),
|
||||
'show_open_todos': _('Show open tasks'),
|
||||
'show_done_todos': _('Show completed tasks'),
|
||||
@@ -70,7 +69,6 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
}
|
||||
widgets = {
|
||||
'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}),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,12 @@
|
||||
<button type="button" class="btn btn-sm btn-light" @mousedown.prevent="formatSelection('numbered')" title="{% trans 'Numbered list' %}">1.</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
<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 %}
|
||||
<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>
|
||||
@@ -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-info flex-fill" @click="insert('/journal')">Journal</button>
|
||||
</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 }}
|
||||
<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>
|
||||
@@ -135,7 +135,13 @@
|
||||
</fieldset>
|
||||
</form>
|
||||
<div id="items" class="vstack gap-3">
|
||||
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{% csrf_token %}<input type="hidden" name="action" value="preferences">
|
||||
<div class="row g-3">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
+33
-5
@@ -1,11 +1,13 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, time, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
|
||||
from .templatetags.markdown_extras import markdown, markdown_title
|
||||
@@ -49,6 +51,11 @@ class DefaultItemKindTests(TestCase):
|
||||
self.assertRedirects(response, reverse('index'))
|
||||
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):
|
||||
kind, _, content, _ = parse_quick_content('/note Explicit note', Item.Kind.TODO)
|
||||
self.assertEqual(kind, Item.Kind.NOTE)
|
||||
@@ -203,7 +210,8 @@ class NavigationAndLayoutTests(TestCase):
|
||||
def test_preference_selects_are_stacked(self):
|
||||
response = self.client.get(reverse('settings'))
|
||||
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):
|
||||
response = self.client.get(reverse('index'))
|
||||
@@ -222,7 +230,7 @@ class NavigationAndLayoutTests(TestCase):
|
||||
response = self.client.get(reverse('edit_item', args=[item.id]))
|
||||
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.sync_metadata()
|
||||
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()
|
||||
|
||||
response = self.client.get(f'{reverse("index")}?status=archive')
|
||||
self.assertContains(response, '#archiveonly')
|
||||
self.assertNotContains(response, '#openonly')
|
||||
self.assertNotContains(response, '#noteonly')
|
||||
html = response.content.decode()
|
||||
filter_tags = html.split('id="tag-list"', 1)[1].split('</div>', 1)[0]
|
||||
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')
|
||||
self.assertContains(response, '#archiveonly')
|
||||
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):
|
||||
def setUp(self):
|
||||
|
||||
+15
-8
@@ -305,8 +305,8 @@ def index_filter_tags(request, user, prefs, active_tags=None):
|
||||
if status == 'open':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||
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__lte=today_end)
|
||||
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__isnull=False, due_at__lte=upcoming_end)
|
||||
elif status == 'archive':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
||||
elif not applied_preferences:
|
||||
@@ -327,7 +327,7 @@ def index(request):
|
||||
if form.is_valid():
|
||||
raw = form.cleaned_data['content']
|
||||
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')
|
||||
if active_tag and not TAG_RE.search(without_markdown_code(content)):
|
||||
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()
|
||||
if 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':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||
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__lte=today_end).order_by('due_at', '-created_at')
|
||||
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')
|
||||
elif status == 'archive':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
||||
elif not applied_preferences:
|
||||
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
|
||||
if q:
|
||||
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_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':
|
||||
items = items.annotate(due_rank=Case(
|
||||
When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)),
|
||||
default=Value(1), output_field=IntegerField(),
|
||||
)).order_by('due_rank', 'due_at', '-created_at')
|
||||
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()):
|
||||
raw = f'/{requested_kind} {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)
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
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})
|
||||
|
||||
|
||||
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
|
||||
def api_tags(request):
|
||||
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
|
||||
|
||||
Reference in New Issue
Block a user