Skip to content

Commit

Permalink
Allow saving search preferences (#540)
Browse files Browse the repository at this point in the history
* Add indicator for modified filters

* Rename shared filter values

* Add update search preferences handler

* Separate search and preferences forms

* Properly initialize bookmark search from get or post

* Add tests for applying search preferences

* Implement saving search preferences

* Remove bookmark search query alias

* Use search preferences as default

* Only show save button for authenticated users

* Only show modified indicator if preferences are modified

* Fix overriding search preferences

* Add missing migration
  • Loading branch information
sissbruecker committed Oct 1, 2023
1 parent 4a2642f commit 41f79e3
Show file tree
Hide file tree
Showing 22 changed files with 1,094 additions and 442 deletions.
6 changes: 3 additions & 3 deletions bookmarks/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def get_queryset(self):
user = self.request.user
# For list action, use query set that applies search and tag projections
if self.action == 'list':
search = BookmarkSearch.from_request(self.request)
search = BookmarkSearch.from_request(self.request.GET)
return queries.query_bookmarks(user, user.profile, search)

# For single entity actions use default query set without projections
Expand All @@ -46,7 +46,7 @@ def get_serializer_context(self):
@action(methods=['get'], detail=False)
def archived(self, request):
user = request.user
search = BookmarkSearch.from_request(request)
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
Expand All @@ -55,7 +55,7 @@ def archived(self, request):

@action(methods=['get'], detail=False)
def shared(self, request):
search = BookmarkSearch.from_request(request)
search = BookmarkSearch.from_request(request.GET)
user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, search, public_only)
Expand Down
2 changes: 1 addition & 1 deletion bookmarks/feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class FeedContext:
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(query=request.GET.get('q', ''))
search = BookmarkSearch(q=request.GET.get('q', ''))
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
return FeedContext(feed_token, query_set)

Expand Down
18 changes: 18 additions & 0 deletions bookmarks/migrations/0025_userprofile_search_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-09-30 10:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('bookmarks', '0024_userprofile_enable_public_sharing'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='search_preferences',
field=models.JSONField(default=dict),
),
]
67 changes: 43 additions & 24 deletions bookmarks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.core.handlers.wsgi import WSGIRequest
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.http import QueryDict

from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
Expand Down Expand Up @@ -130,15 +130,16 @@ class BookmarkSearch:
SORT_TITLE_ASC = 'title_asc'
SORT_TITLE_DESC = 'title_desc'

FILTER_SHARED_OFF = ''
FILTER_SHARED_SHARED = 'shared'
FILTER_SHARED_UNSHARED = 'unshared'
FILTER_SHARED_OFF = 'off'
FILTER_SHARED_SHARED = 'yes'
FILTER_SHARED_UNSHARED = 'no'

FILTER_UNREAD_OFF = ''
FILTER_UNREAD_OFF = 'off'
FILTER_UNREAD_YES = 'yes'
FILTER_UNREAD_NO = 'no'

params = ['q', 'user', 'sort', 'shared', 'unread']
preferences = ['sort', 'shared', 'unread']
defaults = {
'q': '',
'user': '',
Expand All @@ -148,43 +149,59 @@ class BookmarkSearch:
}

def __init__(self,
q: str = defaults['q'],
query: str = defaults['q'], # alias for q
user: str = defaults['user'],
sort: str = defaults['sort'],
shared: str = defaults['shared'],
unread: str = defaults['unread']):
self.q = q or query
self.user = user
self.sort = sort
self.shared = shared
self.unread = unread

@property
def query(self):
return self.q
q: str = None,
user: str = None,
sort: str = None,
shared: str = None,
unread: str = None,
preferences: dict = None):
if not preferences:
preferences = {}
self.defaults = {**BookmarkSearch.defaults, **preferences}

self.q = q or self.defaults['q']
self.user = user or self.defaults['user']
self.sort = sort or self.defaults['sort']
self.shared = shared or self.defaults['shared']
self.unread = unread or self.defaults['unread']

def is_modified(self, param):
value = self.__dict__[param]
return value and value != BookmarkSearch.defaults[param]
return value != self.defaults[param]

@property
def modified_params(self):
return [field for field in self.params if self.is_modified(field)]

@property
def modified_preferences(self):
return [preference for preference in self.preferences if self.is_modified(preference)]

@property
def has_modifications(self):
return len(self.modified_params) > 0

@property
def has_modified_preferences(self):
return len(self.modified_preferences) > 0

@property
def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params}

@property
def preferences_dict(self):
return {preference: self.__dict__[preference] for preference in self.preferences}

@staticmethod
def from_request(request: WSGIRequest):
def from_request(query_dict: QueryDict, preferences: dict = None):
initial_values = {}
for param in BookmarkSearch.params:
value = request.GET.get(param)
value = query_dict.get(param)
if value:
initial_values[param] = value

return BookmarkSearch(**initial_values)
return BookmarkSearch(**initial_values, preferences=preferences)


class BookmarkSearchForm(forms.Form):
Expand Down Expand Up @@ -214,6 +231,7 @@ class BookmarkSearchForm(forms.Form):
def __init__(self, search: BookmarkSearch, editable_fields: List[str] = None, users: List[User] = None):
super().__init__()
editable_fields = editable_fields or []
self.editable_fields = editable_fields

# set choices for user field if users are provided
if users:
Expand Down Expand Up @@ -282,6 +300,7 @@ class UserProfile(models.Model):
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
search_preferences = models.JSONField(default=dict, null=False)


class UserProfileForm(forms.ModelForm):
Expand Down
2 changes: 1 addition & 1 deletion bookmarks/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: Bo
query_set = query_set.filter(owner=user)

# Split query into search terms and tags
query = parse_query_string(search.query)
query = parse_query_string(search.q)

# Filter for search terms and tags
for term in query['search_terms']:
Expand Down
2 changes: 1 addition & 1 deletion bookmarks/styles/bookmark-page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
}
}

.bookmarks-page #search {
.bookmarks-page .search-container {
flex: 1 1 0;
display: flex;
justify-content: flex-end;
Expand Down
1 change: 1 addition & 0 deletions bookmarks/styles/spectre.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
@import "../../node_modules/spectre.css/src/media";

// Components
@import "../../node_modules/spectre.css/src/badges";
@import "../../node_modules/spectre.css/src/dropdowns";
@import "../../node_modules/spectre.css/src/empty";
@import "../../node_modules/spectre.css/src/menus";
Expand Down
91 changes: 54 additions & 37 deletions bookmarks/templates/bookmarks/search.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
{% load widget_tweaks %}

<form id="search" action="" method="get" role="search">
<div class="input-group">
<div class="search-container">
<form id="search" class="input-group" action="" method="get" role="search">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ search.query }}">
value="{{ search.q }}">
<input type="submit" value="Search" class="btn input-group-btn">
</div>
{% for hidden_field in search_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form>
<div class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
Expand All @@ -23,40 +26,54 @@
</svg>
</button>
<div class="menu text-sm" tabindex="0">
<div class="form-group">
<label for="{{ form.sort.id_for_label }}" class="form-label">Sort by</label>
{{ form.sort|add_class:"form-select select-sm" }}
</div>
<div class="form-group radio-group">
<div class="form-label">Shared filter</div>
{% for radio in form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
<div class="form-group radio-group">
<div class="form-label">Unread filter</div>
{% for radio in form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
<form id="search_preferences" action="" method="post">
{% csrf_token %}
{% if 'sort' in preferences_form.editable_fields %}
<div class="form-group">
<label for="{{ preferences_form.sort.id_for_label }}"
class="form-label{% if 'sort' in search.modified_params %} text-bold{% endif %}">Sort by</label>
{{ preferences_form.sort|add_class:"form-select select-sm" }}
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
{% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
{% endif %}
{% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
{% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
<i class="form-icon"></i>
{{ radio.choice_label }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="actions">
<button type="submit" class="btn btn-sm btn-primary" name="apply">Apply</button>
{% if request.user.is_authenticated %}
<button type="submit" class="btn btn-sm" name="save">Save as default</button>
{% endif %}
</div>

{% for hidden_field in preferences_form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</div>
<div class="actions">
<button type="submit" class="btn btn-sm btn-primary">Apply</button>
</div>
</form>
</div>
</div>
</div>

{% for hidden_field in form.hidden_fields %}
{{ hidden_field }}
{% endfor %}
</form>

{# Replace search input with auto-complete component #}
<script type="application/javascript">
Expand All @@ -65,7 +82,7 @@
const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)]
const search = {
q: '{{ search.query }}',
q: '{{ search.q }}',
user: '{{ search.user }}',
shared: '{{ search.shared }}',
unread: '{{ search.unread }}',
Expand All @@ -78,7 +95,7 @@
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ search.query|safe }}',
value: '{{ search.q|safe }}',
tags: uniqueTags,
mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',
Expand Down
10 changes: 8 additions & 2 deletions bookmarks/templatetags/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
form = BookmarkSearchForm(search, editable_fields=['q', 'sort', 'shared', 'unread'])
search_form = BookmarkSearchForm(search, editable_fields=['q'])

if mode == 'shared':
preferences_form = BookmarkSearchForm(search, editable_fields=['sort'])
else:
preferences_form = BookmarkSearchForm(search, editable_fields=['sort', 'shared', 'unread'])
return {
'request': context['request'],
'search': search,
'form': form,
'search_form': search_form,
'preferences_form': preferences_form,
'tags_string': tags_string,
'mode': mode,
}
Expand Down
8 changes: 7 additions & 1 deletion bookmarks/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def setup_numbered_bookmarks(self,
tags = []
if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)]
tags = [self.setup_tag(name=tag_name, user=user)]
bookmark = self.setup_bookmark(url=url,
title=title,
is_archived=archived,
Expand Down Expand Up @@ -139,6 +139,12 @@ def setup_user(self, name: str = None, enable_sharing: bool = False, enable_publ
user.profile.save()
return user

def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
return all_tags

def get_random_string(self, length: int = 32):
return get_random_string(length=length)

Expand Down
Loading

0 comments on commit 41f79e3

Please sign in to comment.