diff --git a/Pipfile b/Pipfile index 65cf4750..3fccb522 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ python-dotenv = "*" pyyaml = "*" uritemplate = "*" whitenoise = "*" +django-taggit = "==2.1.0" [dev-packages] coveralls = "*" diff --git a/api/serializers.py b/api/serializers.py index eff30f91..73da3e1a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -6,6 +6,8 @@ from rest_framework.exceptions import ValidationError from django.contrib.auth.models import User from django.utils import timezone +from taggit.serializers import TagListSerializerField, TaggitSerializer + from core import models @@ -125,10 +127,12 @@ class FeedingSerializer(CoreModelWithDurationSerializer): ) -class NoteSerializer(CoreModelSerializer): +class NoteSerializer(TaggitSerializer, CoreModelSerializer): class Meta: model = models.Note - fields = ("id", "child", "note", "time") + fields = ("id", "child", "note", "time", "tags") + + tags = TagListSerializerField(required=False) class SleepSerializer(CoreModelWithDurationSerializer): @@ -196,3 +200,14 @@ class BMISerializer(CoreModelSerializer): class Meta: model = models.BMI fields = ("id", "child", "bmi", "date", "notes") + + +class TagsSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = models.Tag + fields = ("slug", "name", "color", "last_used") + extra_kwargs = { + "slug": {"required": False, "read_only": True}, + "color": {"required": False}, + "last_used": {"required": False, "read_only": True}, + } diff --git a/api/tests.py b/api/tests.py index f7f299c7..420b1da8 100644 --- a/api/tests.py +++ b/api/tests.py @@ -251,13 +251,14 @@ class NoteAPITestCase(TestBase.BabyBuddyAPITestCaseBase): def test_get(self): response = self.client.get(self.endpoint) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( + self.assertDictEqual( response.data["results"][0], { "id": 1, "child": 1, "note": "Fake note.", "time": "2017-11-17T22:45:00-05:00", + "tags": [], }, ) @@ -551,3 +552,56 @@ class WeightAPITestCase(TestBase.BabyBuddyAPITestCaseBase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, entry) + + +class TagsAPITestCase(TestBase.BabyBuddyAPITestCaseBase): + endpoint = reverse("api:tag-list") + model = models.Tag + + def test_get(self): + response = self.client.get(self.endpoint) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + dict(response.data["results"][0]), + { + "name": "a name", + "slug": "a-name", + "color": "#FF0000", + "last_used": "2017-11-18T11:00:00-05:00", + }, + ) + + def test_post(self): + data = {"name": "new tag", "color": "#123456"} + response = self.client.post(self.endpoint, data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + response = self.client.get(self.endpoint) + results = response.json()["results"] + results_by_name = {r["name"]: r for r in results} + + tag_data = results_by_name["new tag"] + self.assertDictContainsSubset(data, tag_data) + self.assertEqual(tag_data["slug"], "new-tag") + self.assertTrue(tag_data["last_used"]) + + def test_patch(self): + endpoint = f"{self.endpoint}a-name/" + + modified_data = { + "name": "A different name", + "color": "#567890", + } + response = self.client.patch( + endpoint, + modified_data, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictContainsSubset(modified_data, response.data) + + def test_delete(self): + endpoint = f"{self.endpoint}a-name/" + response = self.client.delete(endpoint) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + response = self.client.delete(endpoint) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/api/urls.py b/api/urls.py index e423c7d2..e27c86fa 100644 --- a/api/urls.py +++ b/api/urls.py @@ -18,6 +18,7 @@ router.register(r"weight", views.WeightViewSet) router.register(r"height", views.HeightViewSet) router.register(r"head-circumference", views.HeadCircumferenceViewSet) router.register(r"bmi", views.BMIViewSet) +router.register(r"tags", views.TagsViewSet) app_name = "api" diff --git a/api/views.py b/api/views.py index 5790c950..b65a2c1e 100644 --- a/api/views.py +++ b/api/views.py @@ -92,3 +92,10 @@ class BMIViewSet(viewsets.ModelViewSet): queryset = models.BMI.objects.all() serializer_class = serializers.BMISerializer filterset_fields = ("child", "date") + + +class TagsViewSet(viewsets.ModelViewSet): + queryset = models.Tag.objects.all() + serializer_class = serializers.TagsSerializer + lookup_field = "slug" + filterset_fields = ("last_used", "name") diff --git a/babybuddy/fixtures/tests.json b/babybuddy/fixtures/tests.json index 67cc57cc..183de75d 100644 --- a/babybuddy/fixtures/tests.json +++ b/babybuddy/fixtures/tests.json @@ -462,5 +462,16 @@ "date": "2017-11-18", "notes": "before feed" } + }, + { + "model": "core.tag", + "pk": 1, + "fields": + { + "name": "a name", + "slug": "a-name", + "color": "#FF0000", + "last_used": "2017-11-18T16:00:00Z" + } } -] \ No newline at end of file +] diff --git a/babybuddy/migrations/0021_alter_settings_language.py b/babybuddy/migrations/0021_alter_settings_language.py new file mode 100644 index 00000000..e02543be --- /dev/null +++ b/babybuddy/migrations/0021_alter_settings_language.py @@ -0,0 +1,37 @@ +# Generated by Django 4.0.3 on 2022-03-05 11:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("babybuddy", "0020_update_language_en_to_en_us"), + ] + + operations = [ + migrations.AlterField( + model_name="settings", + name="language", + field=models.CharField( + choices=[ + ("zh-hans", "Chinese (simplified)"), + ("nl", "Dutch"), + ("en-US", "English (US)"), + ("en-GB", "English (UK)"), + ("fr", "French"), + ("fi", "Finnish"), + ("de", "German"), + ("it", "Italian"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("es", "Spanish"), + ("sv", "Swedish"), + ("tr", "Turkish"), + ], + default="en-US", + max_length=255, + verbose_name="Language", + ), + ), + ] diff --git a/babybuddy/static_src/js/tags_editor.js b/babybuddy/static_src/js/tags_editor.js new file mode 100644 index 00000000..56be2277 --- /dev/null +++ b/babybuddy/static_src/js/tags_editor.js @@ -0,0 +1,354 @@ +(function() { + /** + * Parse a string as hexadecimal number + */ + function hexParse(x) { + return parseInt(x, 16); + } + + /** + * Get the contrasting color for any hex color + * + * Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/ + * - Modified with slightly softer colors + * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com + * Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/ + * @param {String} A hexcolor value + * @return {String} The contrasting color (black or white) + */ + function computeComplementaryColor(hexcolor) { + + // If a leading # is provided, remove it + if (hexcolor.slice(0, 1) === '#') { + hexcolor = hexcolor.slice(1); + } + + // If a three-character hexcode, make six-character + if (hexcolor.length === 3) { + hexcolor = hexcolor.split('').map(function (hex) { + return hex + hex; + }).join(''); + } + + // Convert to RGB value + let r = parseInt(hexcolor.substr(0,2),16); + let g = parseInt(hexcolor.substr(2,2),16); + let b = parseInt(hexcolor.substr(4,2),16); + + // Get YIQ ratio + let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000; + + // Check contrast + return (yiq >= 128) ? '#101010' : '#EFEFEF'; + } + + // CSRF token should always be present because it is auto-included with + // every tag-editor widget + const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value; + + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now + + const req = new XMLHttpRequest(); + req.addEventListener('load', () => { + if ((req.status >= 200) && (req.status < 300)) { + success(req.responseText, req); + } else { + fail(req.responseText, req); + } + }); + for (const name of ["error", "timeout", "abort"]) { + req.addEventListener(name, () => { + fail(req.responseText, req); + }); + } + req.timeout = 20000; + + req.open(method, uri); + req.setRequestHeader("Content-Type", "application/json"); + req.setRequestHeader("Accept", "application/json"); + req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); + req.send(data); + } + + /** + * Base class allowing generic operations on the tag lists, like: + * + * - Adding tags to a tag list + * - Updating or creating new tags with a set name and color + * - Controlling the error modal + */ + class TaggingBase { + constructor(widget) { + this.prototype = widget.querySelector('.prototype-tag'); + this.listeners = []; + + this.modalElement = widget.querySelector('.tag-editor-error-modal'); + this.modalBodyNode = this.modalElement.querySelector('.modal-body'); + + // Clean whitespace text nodes between spans + for (const n of this.modalBodyNode.childNodes) { + if (n.nodeType === Node.TEXT_NODE) { + this.modalBodyNode.removeChild(n); + } + } + } + + showModal(msg) { + const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`); + if (!selectedMessage) { + selectedMessage = this.modalBodyNode.childNodes[0]; + } + + for (const n of this.modalBodyNode.childNodes) { + n.classList.add('d-none'); + } + selectedMessage.classList.remove('d-none'); + + jQuery(this.modalElement).modal('show'); + } + + addTagListUpdatedListener(c) { + this.listeners.push(c); + } + + callTagListUpdatedListeners() { + for (const l of this.listeners) { + l(); + } + } + + updateTag(tag, name, color, actionSymbol) { + const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0]; + + name = name || tag.getAttribute("data-value"); + color = color || tag.getAttribute("data-color"); + actionSymbol = actionSymbol || actionTextNode.textContent; + + tag.childNodes[0].textContent = name; + tag.setAttribute("data-value", name); + tag.setAttribute("data-color", color); + + const textColor = computeComplementaryColor(color); + tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`); + actionTextNode.textContent = actionSymbol; + } + + createNewTag(name, color, actionSymbol) { + const tag = this.prototype.cloneNode(true); + tag.classList.remove("prototype-tag"); + tag.classList.add("tag"); + this.updateTag(tag, name, color, actionSymbol); + return tag; + } + + insertTag(list, tag) { + list.appendChild(tag); + this.callTagListUpdatedListeners(); + } + }; + + /** + * Handler for the edit field allowing to dynamically create new tags. + * + * Handles user inputs for the editor. Calls the 'onInsertNewTag' callback + * when the creation of a new tag has been requested. All backend handling + * like guareteening that the requested tag exists is handled by this class, + * the only task left is to add the new tag to the tags-list when + * 'onInsertNewTag' is called. + */ + class AddNewTagControl { + /** + * @param widget + * The root DOM element of the widget + * @param taggingBase + * Reference to a common TaggingBase class to be used by this widget + * @param onInsertNewTag + * Callback that is called when a new tag should be added to the + * tags widget. + */ + constructor(widget, taggingBase, onInsertNewTag) { + this.widget = widget; + this.taggingBase = taggingBase; + + this.apiTagsUrl = widget.getAttribute('data-tags-url'); + this.createTagInputs = widget.querySelector('.create-tag-inputs'); + this.addTagInput = this.createTagInputs.querySelector('input[type="text"]'); + this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag'); + + this.addTagInput.value = ""; + + this.onInsertNewTag = onInsertNewTag; + + this.addTagButton.addEventListener('click', () => this.onCreateTagClicked()); + this.addTagInput.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === "enter") { + e.preventDefault(); + this.onCreateTagClicked(); + } + }); + } + + /** + * Callback called when the the "Add" button of the add-tag input is + * clicked or enter is pressed in the editor. + */ + onCreateTagClicked() { + // TODO: Make promise based + + const tagName = this.addTagInput.value.trim(); + const uriTagName = encodeURIComponent(tagName); + + const fail = (msg) => { + this.addTagInput.select(); + this.taggingBase.showModal(msg || "generic"); + }; + + if (!tagName) { + fail('invalid-tag-name'); + return; + } + + const addTag = (name, color) => { + const tag = this.taggingBase.createNewTag(name, color, "-"); + this.addTagInput.value = ""; + this.onInsertNewTag(tag); + }; + + const data = JSON.stringify({ + 'name': this.addTagInput.value + }); + + doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null, + (text) => { + const json = JSON.parse(text); + if (json.count) { + const tagJson = json.results[0]; + addTag(tagJson.name, tagJson.color); + } else { + doReq("POST", this.apiTagsUrl, data, + (text) => { + const tagJson = JSON.parse(text); + addTag(tagJson.name, tagJson.color); + }, () => fail("tag-creation-failed") + ); + } + }, () => fail("tag-checking-failed") + ); + } + }; + + /** + * JavaScript implementation for the tags editor. + * + * This class uses TaggingBase and AddNewTagControl to provide the custom + * tag editor controls. This mainly consists of updating the hidden + * input values with the current list of tags and adding/removing + * tags from the current-tags- or recently-used-lists. + */ + class TagsEditor { + /** + * @param tagEditorRoot + * The root DOM element of the widget. + */ + constructor(tagEditorRoot) { + this.widget = tagEditorRoot; + this.taggingBase = new TaggingBase(this.widget); + this.addTagControl = new AddNewTagControl( + this.widget, this.taggingBase, (t) => this.insertNewTag(t) + ); + + this.currentTags = this.widget.querySelector('.current_tags'); + this.newTags = this.widget.querySelector('.new-tags'); + this.inputElement = this.widget.querySelector('input[type="hidden"]'); + + for (const tag of this.newTags.querySelectorAll(".tag")) { + this.configureAddTag(tag); + } + for (const tag of this.currentTags.querySelectorAll(".tag")) { + this.configureRemoveTag(tag); + } + + this.updateInputList(); + this.taggingBase.addTagListUpdatedListener( + () => this.updateInputList() + ); + } + + /** + * Insert a new tag into the "current tag" list. + * + * Makes sure that no duplicates are present in the widget before adding + * the new tag. If a duplicate is found, the old tag is removed before + * the new one is added. + */ + insertNewTag(tag) { + const name = tag.getAttribute("data-value"); + + const oldTag = this.widget.querySelector(`span[data-value="${name}"]`); + if (oldTag) { + oldTag.parentNode.removeChild(oldTag); + } + + this.taggingBase.insertTag(this.currentTags, tag); + this.configureRemoveTag(tag); + } + + /** + * Registeres a click-callback for a given node. + * + * The callback chain-calls another callback "onClicked" after + * moving the clicked tag from the old tag-list to a new tag list. + */ + registerNewCallback(tag, newParent, onClicked) { + function callback(event) { + tag.parentNode.removeChild(tag); + this.taggingBase.insertTag(newParent, tag); + + tag.removeEventListener('click', callback); + onClicked(tag); + } + tag.addEventListener('click', callback.bind(this)); + } + + /** + * Updates the value of the hidden input element. + * + * Sets the value from the list of tags added to the currentTags + * DOM element. + */ + updateInputList() { + const names = []; + for (const tag of this.currentTags.querySelectorAll(".tag")) { + const name = tag.getAttribute("data-value"); + names.push(`"${name}"`); + } + this.inputElement.value = names.join(","); + } + + /** + * Configure a tag-DOM element as a "add tag" button. + */ + configureAddTag(tag) { + this.taggingBase.updateTag(tag, null, null, "+"); + this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag)); + this.updateInputList(); + } + + /** + * Configure a tag-DOM element as a "remove tag" button. + */ + configureRemoveTag(tag) { + this.taggingBase.updateTag(tag, null, null, "-"); + this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag)); + this.updateInputList(); + } + }; + + window.addEventListener('load', () => { + for (const el of document.querySelectorAll('.babybuddy-tags-editor')) { + new TagsEditor(el); + } + }); +})(); \ No newline at end of file diff --git a/babybuddy/static_src/scss/global.scss b/babybuddy/static_src/scss/global.scss index 666ab6df..d4e83835 100644 --- a/babybuddy/static_src/scss/global.scss +++ b/babybuddy/static_src/scss/global.scss @@ -52,3 +52,8 @@ .icon-2x { font-size: 1.65em; } + +// All modals +.modal-content { + color: theme-color('dark'); +} \ No newline at end of file diff --git a/babybuddy/templates/babybuddy/form.html b/babybuddy/templates/babybuddy/form.html index 8768a4a8..91ff6ce3 100644 --- a/babybuddy/templates/babybuddy/form.html +++ b/babybuddy/templates/babybuddy/form.html @@ -1,9 +1,12 @@ {% load i18n widget_tweaks %} +{# Load any form-javascript files #} +{{ form.media.js }}