UX improvements
continuous-integration/drone/push Build is passing Details

* 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
This commit is contained in:
Lukáš Kucharczyk 2023-11-09 14:49:00 +01:00
parent 866f2526e6
commit a879360ebd
12 changed files with 278 additions and 61 deletions

View File

@ -26,6 +26,12 @@
### Improved ### Improved
* game overview: simplify playtime range display * game overview: simplify playtime range display
* new session: order devices alphabetically * 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 ## 1.3.0 / 2023-11-05 15:09+01:00

View File

@ -6,7 +6,6 @@ custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M" 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"}) 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") # queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# ) # )
purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name"), queryset=Purchase.objects.order_by("edition__sort_name"),
widget=autofocus_select_widget, widget=forms.Select(attrs={"autofocus": "autofocus"}),
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name")) device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
@ -39,12 +38,21 @@ class SessionForm(forms.ModelForm):
class EditionChoiceField(forms.ModelChoiceField): class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str: 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): class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField( 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")) 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): class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField( game = GameModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
) )
platform = forms.ModelChoiceField( platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False queryset=Platform.objects.order_by("name"), required=False
@ -77,13 +100,13 @@ class EditionForm(forms.ModelForm):
class Meta: class Meta:
model = Edition model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"] fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "year_released", "wikidata"] fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}

View File

@ -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),
]

View File

@ -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),
]

View File

@ -10,12 +10,29 @@ from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) 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) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name 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 Edition(models.Model):
class Meta: class Meta:
@ -23,6 +40,7 @@ class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey( platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None "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) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): 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): class PurchaseQueryset(models.QuerySet):

View File

@ -1,29 +1,18 @@
/** import { syncSelectInputUntilChanged } from './utils.js';
* @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);
}
sourceElement.addEventListener("change", sourceElementHandler); let syncData = [
targetElement.addEventListener("focus", targetElementHandler); {
} "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(syncData, "form");
syncSelectInputUntilChanged("#id_game", "#id_name");
});

View File

@ -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")

View File

@ -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")

View File

@ -3,7 +3,7 @@
* @param {Date} date * @param {Date} date
* @returns {string} * @returns {string}
*/ */
export function toISOUTCString(date) { function toISOUTCString(date) {
function stringAndPad(number) { function stringAndPad(number) {
return number.toString().padStart(2, 0); return number.toString().padStart(2, 0);
} }
@ -14,3 +14,77 @@ export function toISOUTCString(date) {
const minutes = stringAndPad(date.getMinutes()); const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`; 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 };

View File

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %} {% block title %}{{ title }}{% endblock title %}
@ -15,3 +16,10 @@
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% endblock scripts %}

View File

@ -407,6 +407,7 @@ def add_purchase(request):
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js"
return render(request, "add.html", context) return render(request, "add.html", context)
@ -419,6 +420,7 @@ def add_game(request):
context["form"] = form context["form"] = form
context["title"] = "Add New Game" context["title"] = "Add New Game"
context["script_name"] = "add_game.js"
return render(request, "add.html", context) return render(request, "add.html", context)
@ -431,7 +433,8 @@ def add_edition(request):
context["form"] = form context["form"] = form
context["title"] = "Add New Edition" 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): def add_platform(request):