From 68d1bfc2b93f80cf3eee0657354a6d0efe99153a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Thu, 9 Nov 2023 14:49:00 +0100 Subject: [PATCH] UX improvements * ignore English articles when sorting names * added a new sort_name field that gets automatically created * automatically fill certain values in forms: * new game: name and sort name after typing * new edition: name and sort name when selecting game * new purchase: platform when selecting edition --- CHANGELOG.md | 6 ++ games/forms.py | 41 +++++++++--- games/migrations/0024_edition_sort_name.py | 39 +++++++++++ games/migrations/0025_game_sort_name.py | 39 +++++++++++ games/models.py | 36 +++++++++- games/static/js/add_edition.js | 43 +++++------- games/static/js/add_game.js | 12 ++++ games/static/js/add_purchase.js | 12 ++++ games/static/js/utils.js | 76 +++++++++++++++++++++- games/templates/add.html | 8 +++ games/templates/add_edition.html | 22 ------- games/views.py | 5 +- 12 files changed, 278 insertions(+), 61 deletions(-) create mode 100644 games/migrations/0024_edition_sort_name.py create mode 100644 games/migrations/0025_game_sort_name.py create mode 100644 games/static/js/add_game.js create mode 100644 games/static/js/add_purchase.js delete mode 100644 games/templates/add_edition.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f5cfd..93e377e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ ### Improved * game overview: simplify playtime range display * new session: order devices alphabetically +* ignore English articles when sorting names + * added a new sort_name field that gets automatically created +* automatically fill certain values in forms: + * new game: name and sort name after typing + * new edition: name and sort name when selecting game + * new purchase: platform when selecting edition ## 1.3.0 / 2023-11-05 15:09+01:00 diff --git a/games/forms.py b/games/forms.py index 6858bfc..f1e17e8 100644 --- a/games/forms.py +++ b/games/forms.py @@ -6,7 +6,6 @@ custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_datetime_widget = forms.DateTimeInput( attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M" ) -autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"}) autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) @@ -15,8 +14,8 @@ class SessionForm(forms.ModelForm): # queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") # ) purchase = forms.ModelChoiceField( - queryset=Purchase.objects.order_by("edition__name"), - widget=autofocus_select_widget, + queryset=Purchase.objects.order_by("edition__sort_name"), + widget=forms.Select(attrs={"autofocus": "autofocus"}), ) device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) @@ -39,12 +38,21 @@ class SessionForm(forms.ModelForm): class EditionChoiceField(forms.ModelChoiceField): def label_from_instance(self, obj) -> str: - return f"{obj.name} ({obj.platform}, {obj.year_released})" + return f"{obj.sort_name} ({obj.platform}, {obj.year_released})" + + +class IncludePlatformSelect(forms.Select): + def create_option(self, name, value, *args, **kwargs): + option = super().create_option(name, value, *args, **kwargs) + if value: + option["attrs"]["data-platform"] = value.instance.platform.id + return option class PurchaseForm(forms.ModelForm): edition = EditionChoiceField( - queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget + queryset=Edition.objects.order_by("sort_name"), + widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), ) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) @@ -67,9 +75,24 @@ class PurchaseForm(forms.ModelForm): ] +class IncludeNameSelect(forms.Select): + def create_option(self, name, value, *args, **kwargs): + option = super().create_option(name, value, *args, **kwargs) + if value: + option["attrs"]["data-name"] = value.instance.name + return option + + +class GameModelChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + # Use sort_name as the label for the option + return obj.sort_name + + class EditionForm(forms.ModelForm): - game = forms.ModelChoiceField( - queryset=Game.objects.order_by("name"), widget=autofocus_select_widget + game = GameModelChoiceField( + queryset=Game.objects.order_by("sort_name"), + widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}), ) platform = forms.ModelChoiceField( queryset=Platform.objects.order_by("name"), required=False @@ -77,13 +100,13 @@ class EditionForm(forms.ModelForm): class Meta: model = Edition - fields = ["game", "name", "platform", "year_released", "wikidata"] + fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"] class GameForm(forms.ModelForm): class Meta: model = Game - fields = ["name", "year_released", "wikidata"] + fields = ["name", "sort_name", "year_released", "wikidata"] widgets = {"name": autofocus_input_widget} diff --git a/games/migrations/0024_edition_sort_name.py b/games/migrations/0024_edition_sort_name.py new file mode 100644 index 0000000..5a7b980 --- /dev/null +++ b/games/migrations/0024_edition_sort_name.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.5 on 2023-11-09 09:32 + +from django.db import migrations, models + + +def create_sort_name(apps, schema_editor): + Edition = apps.get_model( + "games", "Edition" + ) # Replace 'your_app_name' with the actual name of your app + + for edition in Edition.objects.all(): + name = edition.name + # Check for articles at the beginning of the name and move them to the end + if name.lower().startswith("the "): + sort_name = f"{name[4:]}, The" + elif name.lower().startswith("a "): + sort_name = f"{name[2:]}, A" + elif name.lower().startswith("an "): + sort_name = f"{name[3:]}, An" + else: + sort_name = name + # Save the sort_name back to the database + edition.sort_name = sort_name + edition.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0023_purchase_date_finished"), + ] + + operations = [ + migrations.AddField( + model_name="edition", + name="sort_name", + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.RunPython(create_sort_name), + ] diff --git a/games/migrations/0025_game_sort_name.py b/games/migrations/0025_game_sort_name.py new file mode 100644 index 0000000..bae6d24 --- /dev/null +++ b/games/migrations/0025_game_sort_name.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.5 on 2023-11-09 09:32 + +from django.db import migrations, models + + +def create_sort_name(apps, schema_editor): + Game = apps.get_model( + "games", "Game" + ) # Replace 'your_app_name' with the actual name of your app + + for game in Game.objects.all(): + name = game.name + # Check for articles at the beginning of the name and move them to the end + if name.lower().startswith("the "): + sort_name = f"{name[4:]}, The" + elif name.lower().startswith("a "): + sort_name = f"{name[2:]}, A" + elif name.lower().startswith("an "): + sort_name = f"{name[3:]}, An" + else: + sort_name = name + # Save the sort_name back to the database + game.sort_name = sort_name + game.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0024_edition_sort_name"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="sort_name", + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.RunPython(create_sort_name), + ] diff --git a/games/models.py b/games/models.py index c16943f..7f2ce1b 100644 --- a/games/models.py +++ b/games/models.py @@ -10,12 +10,29 @@ from django.db.models import F, Manager, Sum class Game(models.Model): name = models.CharField(max_length=255) + sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) def __str__(self): return self.name + def save(self, *args, **kwargs): + # Logic to create sort_name from name + def get_sort_name(name): + articles = ["a", "an", "the"] + name_parts = name.split() + first_word = name_parts[0].lower() + if first_word in articles: + # Move the first word to the end + name_parts.append(name_parts.pop(0)) + return ", ".join(name_parts).capitalize() + else: + return name.capitalize() + + self.sort_name = get_sort_name(self.name) + super().save(*args, **kwargs) # Call the "real" save() method. + class Edition(models.Model): class Meta: @@ -23,6 +40,7 @@ class Edition(models.Model): game = models.ForeignKey("Game", on_delete=models.CASCADE) name = models.CharField(max_length=255) + sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) platform = models.ForeignKey( "Platform", on_delete=models.CASCADE, null=True, blank=True, default=None ) @@ -30,7 +48,23 @@ class Edition(models.Model): wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) def __str__(self): - return self.name + return self.sort_name + + def save(self, *args, **kwargs): + # Logic to create sort_name from name + def get_sort_name(name): + articles = ["a", "an", "the"] + name_parts = name.split() + first_word = name_parts[0].lower() + if first_word in articles: + # Move the first word to the end + name_parts.append(name_parts.pop(0)) + return ", ".join(name_parts).capitalize() + else: + return name.capitalize() + + self.sort_name = get_sort_name(self.name) + super().save(*args, **kwargs) # Call the "real" save() method. class PurchaseQueryset(models.QuerySet): diff --git a/games/static/js/add_edition.js b/games/static/js/add_edition.js index cda1368..4ed0035 100644 --- a/games/static/js/add_edition.js +++ b/games/static/js/add_edition.js @@ -1,29 +1,18 @@ -/** - * @description Sync select field with input field until user focuses it. - * @param {HTMLSelectElement} sourceElementSelector - * @param {HTMLInputElement} targetElementSelector - */ -function syncSelectInputUntilChanged( - sourceElementSelector, - targetElementSelector -) { - const sourceElement = document.querySelector(sourceElementSelector); - const targetElement = document.querySelector(targetElementSelector); - function sourceElementHandler(event) { - let selected = event.target.value; - let selectedValue = document.querySelector( - `#id_game option[value='${selected}']` - ).textContent; - targetElement.value = selectedValue; - } - function targetElementHandler(event) { - sourceElement.removeEventListener("change", sourceElementHandler); - } +import { syncSelectInputUntilChanged } from './utils.js'; - sourceElement.addEventListener("change", sourceElementHandler); - targetElement.addEventListener("focus", targetElementHandler); -} +let syncData = [ + { + "source": "#id_game", + "source_value": "dataset.name", + "target": "#id_name", + "target_value": "value" + }, + { + "source": "#id_game", + "source_value": "textContent", + "target": "#id_sort_name", + "target_value": "value" + }, +] -window.addEventListener("load", () => { - syncSelectInputUntilChanged("#id_game", "#id_name"); -}); +syncSelectInputUntilChanged(syncData, "form"); diff --git a/games/static/js/add_game.js b/games/static/js/add_game.js new file mode 100644 index 0000000..617e28a --- /dev/null +++ b/games/static/js/add_game.js @@ -0,0 +1,12 @@ +import { syncSelectInputUntilChanged } from './utils.js' + +let syncData = [ + { + "source": "#id_name", + "source_value": "value", + "target": "#id_sort_name", + "target_value": "value" + } +] + +syncSelectInputUntilChanged(syncData, "form") diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js new file mode 100644 index 0000000..40c93f5 --- /dev/null +++ b/games/static/js/add_purchase.js @@ -0,0 +1,12 @@ +import { syncSelectInputUntilChanged } from './utils.js' + +let syncData = [ + { + "source": "#id_edition", + "source_value": "dataset.platform", + "target": "#id_platform", + "target_value": "value" + } +] + +syncSelectInputUntilChanged(syncData, "form") diff --git a/games/static/js/utils.js b/games/static/js/utils.js index ddaaa3b..3c45e4e 100644 --- a/games/static/js/utils.js +++ b/games/static/js/utils.js @@ -3,7 +3,7 @@ * @param {Date} date * @returns {string} */ -export function toISOUTCString(date) { +function toISOUTCString(date) { function stringAndPad(number) { return number.toString().padStart(2, 0); } @@ -14,3 +14,77 @@ export function toISOUTCString(date) { const minutes = stringAndPad(date.getMinutes()); return `${year}-${month}-${day}T${hours}:${minutes}`; } + +/** + * @description Sync values between source and target elements based on syncData configuration. + * @param {Array} syncData - Array of objects to define source and target elements with their respective value types. + */ +function syncSelectInputUntilChanged(syncData, parentSelector = document) { + const parentElement = + parentSelector === document + ? document + : document.querySelector(parentSelector); + + if (!parentElement) { + console.error(`The parent selector "${parentSelector}" is not valid.`); + return; + } + // Set up a single change event listener on the document for handling all source changes + parentElement.addEventListener("change", function (event) { + // Loop through each sync configuration item + syncData.forEach((syncItem) => { + // Check if the change event target matches the source selector + if (event.target.matches(syncItem.source)) { + const sourceElement = event.target; + const valueToSync = getValueFromProperty( + sourceElement, + syncItem.source_value + ); + const targetElement = document.querySelector(syncItem.target); + + if (targetElement && valueToSync !== null) { + targetElement[syncItem.target_value] = valueToSync; + } + } + }); + }); + + // Set up a single focus event listener on the document for handling all target focuses + parentElement.addEventListener( + "focus", + function (event) { + // Loop through each sync configuration item + syncData.forEach((syncItem) => { + // Check if the focus event target matches the target selector + if (event.target.matches(syncItem.target)) { + // Remove the change event listener to stop syncing + // This assumes you want to stop syncing once any target receives focus + // You may need a more sophisticated way to remove listeners if you want to stop + // syncing selectively based on other conditions + document.removeEventListener("change", syncSelectInputUntilChanged); + } + }); + }, + true + ); // Use capture phase to ensure the event is captured during focus, not bubble +} + +/** + * @description Retrieve the value from the source element based on the provided property. + * @param {Element} sourceElement - The source HTML element. + * @param {string} property - The property to retrieve the value from. + */ +function getValueFromProperty(sourceElement, property) { + let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement + if (property.startsWith("dataset.")) { + let datasetKey = property.slice(8); // Remove 'dataset.' part + return source.dataset[datasetKey]; + } else if (property in source) { + return source[property]; + } else { + console.error(`Property ${property} is not valid for the option element.`); + return null; + } +} + +export { toISOUTCString, syncSelectInputUntilChanged }; diff --git a/games/templates/add.html b/games/templates/add.html index b2ac101..b9241cd 100644 --- a/games/templates/add.html +++ b/games/templates/add.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load static %} {% block title %}{{ title }}{% endblock title %} @@ -15,3 +16,10 @@ {% endblock content %} + +{% block scripts %} + {% if script_name %} + + {% endif %} +{% endblock scripts %} + \ No newline at end of file diff --git a/games/templates/add_edition.html b/games/templates/add_edition.html deleted file mode 100644 index 30bb35e..0000000 --- a/games/templates/add_edition.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ title }}{% endblock title %} - -{% block content %} -
- - {% csrf_token %} - - {{ form.as_table }} - - - - -
-
-{% endblock content %} - -{% block scripts %} -{% load static %} - -{% endblock scripts %} diff --git a/games/views.py b/games/views.py index 417f832..d2e486e 100644 --- a/games/views.py +++ b/games/views.py @@ -407,6 +407,7 @@ def add_purchase(request): context["form"] = form context["title"] = "Add New Purchase" + context["script_name"] = "add_purchase.js" return render(request, "add.html", context) @@ -419,6 +420,7 @@ def add_game(request): context["form"] = form context["title"] = "Add New Game" + context["script_name"] = "add_game.js" return render(request, "add.html", context) @@ -431,7 +433,8 @@ def add_edition(request): context["form"] = form context["title"] = "Add New Edition" - return render(request, "add_edition.html", context) + context["script_name"] = "add_edition.js" + return render(request, "add.html", context) def add_platform(request):