Merge pull request #396 from MrApplejuice/tags

Tagging
This commit is contained in:
Christopher Charbonneau Wells 2022-03-08 07:08:32 -08:00 committed by GitHub
commit 33005b930e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2059 additions and 338 deletions

View File

@ -22,6 +22,7 @@ python-dotenv = "*"
pyyaml = "*" pyyaml = "*"
uritemplate = "*" uritemplate = "*"
whitenoise = "*" whitenoise = "*"
django-taggit = "==2.1.0"
[dev-packages] [dev-packages]
coveralls = "*" coveralls = "*"

View File

@ -6,6 +6,8 @@ from rest_framework.exceptions import ValidationError
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from taggit.serializers import TagListSerializerField, TaggitSerializer
from core import models from core import models
@ -125,10 +127,12 @@ class FeedingSerializer(CoreModelWithDurationSerializer):
) )
class NoteSerializer(CoreModelSerializer): class NoteSerializer(TaggitSerializer, CoreModelSerializer):
class Meta: class Meta:
model = models.Note model = models.Note
fields = ("id", "child", "note", "time") fields = ("id", "child", "note", "time", "tags")
tags = TagListSerializerField(required=False)
class SleepSerializer(CoreModelWithDurationSerializer): class SleepSerializer(CoreModelWithDurationSerializer):
@ -196,3 +200,14 @@ class BMISerializer(CoreModelSerializer):
class Meta: class Meta:
model = models.BMI model = models.BMI
fields = ("id", "child", "bmi", "date", "notes") 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},
}

View File

@ -251,13 +251,14 @@ class NoteAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
def test_get(self): def test_get(self):
response = self.client.get(self.endpoint) response = self.client.get(self.endpoint)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual( self.assertDictEqual(
response.data["results"][0], response.data["results"][0],
{ {
"id": 1, "id": 1,
"child": 1, "child": 1,
"note": "Fake note.", "note": "Fake note.",
"time": "2017-11-17T22:45:00-05:00", "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.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, entry) 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)

View File

@ -18,6 +18,7 @@ router.register(r"weight", views.WeightViewSet)
router.register(r"height", views.HeightViewSet) router.register(r"height", views.HeightViewSet)
router.register(r"head-circumference", views.HeadCircumferenceViewSet) router.register(r"head-circumference", views.HeadCircumferenceViewSet)
router.register(r"bmi", views.BMIViewSet) router.register(r"bmi", views.BMIViewSet)
router.register(r"tags", views.TagsViewSet)
app_name = "api" app_name = "api"

View File

@ -92,3 +92,10 @@ class BMIViewSet(viewsets.ModelViewSet):
queryset = models.BMI.objects.all() queryset = models.BMI.objects.all()
serializer_class = serializers.BMISerializer serializer_class = serializers.BMISerializer
filterset_fields = ("child", "date") 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")

View File

@ -462,5 +462,16 @@
"date": "2017-11-18", "date": "2017-11-18",
"notes": "before feed" "notes": "before feed"
} }
},
{
"model": "core.tag",
"pk": 1,
"fields":
{
"name": "a name",
"slug": "a-name",
"color": "#FF0000",
"last_used": "2017-11-18T16:00:00Z"
}
} }
] ]

View File

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

View File

@ -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);
}
});
})();

View File

@ -52,3 +52,8 @@
.icon-2x { .icon-2x {
font-size: 1.65em; font-size: 1.65em;
} }
// All modals
.modal-content {
color: theme-color('dark');
}

View File

@ -1,9 +1,12 @@
{% load i18n widget_tweaks %} {% load i18n widget_tweaks %}
{# Load any form-javascript files #}
{{ form.media.js }}
<div class="container-fluid pb-5"> <div class="container-fluid pb-5">
<form role="form" method="post" enctype="multipart/form-data"> <form role="form" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}
{{ field.widget }}
<div class="form-group row"> <div class="form-group row">
{% include 'babybuddy/form_field.html' %} {% include 'babybuddy/form_field.html' %}
</div> </div>

View File

@ -6,6 +6,7 @@ from import_export import fields, resources
from import_export.admin import ImportExportMixin, ExportActionMixin from import_export.admin import ImportExportMixin, ExportActionMixin
from core import models from core import models
from core.forms import TagAdminForm
class ImportExportResourceBase(resources.ModelResource): class ImportExportResourceBase(resources.ModelResource):
@ -177,3 +178,17 @@ class WeightAdmin(ImportExportMixin, ExportActionMixin, admin.ModelAdmin):
"weight", "weight",
) )
resource_class = WeightImportExportResource resource_class = WeightImportExportResource
class TaggedItemInline(admin.StackedInline):
model = models.Tagged
@admin.register(models.Tag)
class TagAdmin(admin.ModelAdmin):
form = TagAdminForm
inlines = [TaggedItemInline]
list_display = ["name", "slug", "color", "last_used"]
ordering = ["name", "slug"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}

View File

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django import forms from django import forms
from django.forms import widgets
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core import models from core import models
from core.widgets import TagsEditor
def set_initial_values(kwargs, form_type): def set_initial_values(kwargs, form_type):
@ -82,6 +84,7 @@ class CoreModelForm(forms.ModelForm):
timer.stop(instance.end) timer.stop(instance.end)
if commit: if commit:
instance.save() instance.save()
self.save_m2m()
return instance return instance
@ -161,7 +164,7 @@ class FeedingForm(CoreModelForm):
class NoteForm(CoreModelForm): class NoteForm(CoreModelForm):
class Meta: class Meta:
model = models.Note model = models.Note
fields = ["child", "note", "time"] fields = ["child", "note", "time", "tags"]
widgets = { widgets = {
"time": forms.DateTimeInput( "time": forms.DateTimeInput(
attrs={ attrs={
@ -169,6 +172,7 @@ class NoteForm(CoreModelForm):
"data-target": "#datetimepicker_time", "data-target": "#datetimepicker_time",
} }
), ),
"tags": TagsEditor(),
} }
@ -310,3 +314,8 @@ class BMIForm(CoreModelForm):
), ),
"notes": forms.Textarea(attrs={"rows": 5}), "notes": forms.Textarea(attrs={"rows": 5}),
} }
class TagAdminForm(forms.ModelForm):
class Meta:
widgets = {"color": widgets.TextInput(attrs={"type": "color"})}

View File

@ -0,0 +1,110 @@
# Generated by Django 4.0.3 on 2022-03-08 11:51
import core.models
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("core", "0018_bmi_headcircumference_height"),
]
operations = [
migrations.CreateModel(
name="Tag",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=100, unique=True, verbose_name="name"),
),
(
"slug",
models.SlugField(max_length=100, unique=True, verbose_name="slug"),
),
(
"color",
models.CharField(
default=core.models.random_color,
max_length=32,
validators=[
django.core.validators.RegexValidator("^#[0-9a-fA-F]{6}$")
],
verbose_name="Color",
),
),
(
"last_used",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="Last used"
),
),
],
options={
"verbose_name": "Tags",
},
),
migrations.CreateModel(
name="Tagged",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"object_id",
models.IntegerField(db_index=True, verbose_name="object ID"),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(app_label)s_%(class)s_tagged_items",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(app_label)s_%(class)s_items",
to="core.tag",
verbose_name="Tag",
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="note",
name="tags",
field=core.models.TaggableManager(
blank=True,
help_text="Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags.",
through="core.Tagged",
to="core.Tag",
verbose_name="Tags",
),
),
]

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import timedelta from datetime import timedelta
from typing import Iterable, Optional
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@ -9,6 +10,14 @@ from django.utils.text import slugify
from django.utils import timezone from django.utils import timezone
from django.utils.text import format_lazy from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.validators import RegexValidator
import random
from taggit.managers import TaggableManager as TaggitTaggableManager
from taggit.models import TagBase, GenericTaggedItemBase
random.seed()
def validate_date(date, field_name): def validate_date(date, field_name):
@ -71,6 +80,78 @@ def validate_time(time, field_name):
) )
def random_color():
TAG_COLORS = [
"#ff0000",
"#00ff00",
"#0000ff",
"#ff00ff",
"#ffff00",
"#00ffff",
"#ff7f7f",
"#7fff7f",
"#7f7fff",
"#ff7fff",
"#ffff7f",
"#7fffff",
"#7f0000",
"#007f00",
"#00007f",
"#7f007f",
"#7f7f00",
"#007f7f",
]
return TAG_COLORS[random.randrange(0, len(TAG_COLORS))]
class Tag(TagBase):
class Meta:
verbose_name = _("Tags")
color = models.CharField(
verbose_name=_("Color"),
max_length=32,
default=random_color,
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$")],
)
last_used = models.DateTimeField(
verbose_name=_("Last used"),
default=timezone.now,
blank=False,
)
class Tagged(GenericTaggedItemBase):
tag = models.ForeignKey(
Tag,
verbose_name=_("Tag"),
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s_items",
)
def save_base(self, *args, **kwargs):
"""
Update last_used of the used tag, whenever it is used in a
save-operation.
"""
self.tag.last_used = timezone.now()
self.tag.save()
return super().save_base(*args, **kwargs)
class TaggableManager(TaggitTaggableManager):
"""
Replace the default help_text with
"""
def __init__(self, *args, **kwargs):
kwargs["help_text"] = _(
"Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags."
)
super().__init__(*args, **kwargs)
class Child(models.Model): class Child(models.Model):
model_name = "child" model_name = "child"
first_name = models.CharField(max_length=255, verbose_name=_("First name")) first_name = models.CharField(max_length=255, verbose_name=_("First name"))
@ -251,6 +332,7 @@ class Note(models.Model):
time = models.DateTimeField( time = models.DateTimeField(
default=timezone.now, blank=False, verbose_name=_("Time") default=timezone.now, blank=False, verbose_name=_("Time")
) )
tags = TaggableManager(blank=True, through=Tagged)
objects = models.Manager() objects = models.Manager()

View File

@ -0,0 +1,64 @@
{% load i18n %}
<div data-tags-url="{% url 'api:api-root' %}tags/"
{% for k, v in widget.attrs.items %}
{{ k }}="{{ v }}"
{% endfor %}>
{% csrf_token %}
<span class="prototype-tag btn badge badge-pill cursor-pointer mr-1" style="display: none;">
UNINITIALIZED PROTOTYPE
<span class="add-remove-icon pl-1 pr-1">+ or -</span>
</span>
<div class="current_tags" style="min-height: 2em;">
{% for t in widget.value %}
<span data-value="{{ t.name }}" data-color="{{ t.color }}" class="tag btn badge badge-pill cursor-pointer mr-1" style="background-color: {{ t.color }};">
{{ t.name }}
<span class="add-remove-icon pl-1 pr-1">-</span>
</span>
{% endfor %}
</div>
<div class="new-tags">
<div class="create-tag-inputs input-group">
<input class="form-control" type="text" name="" placeholder="{% trans "Tag name" %}">
<div class="input-group-append">
<button class="btn btn-outline-primary bg-dark btn-add-new-tag" type="button">{% trans "Add" %}</button>
</div>
</div>
<span>{% trans "Recently used:" %}</span>
{% for t in widget.tag_suggestions.quick %}
<span data-value="{{ t.name }}" data-color="{{ t.color }}" class="tag btn badge badge-pill cursor-pointer mr-1" style="background-color: {{ t.color }};">
{{ t.name }}
<span class="add-remove-icon pl-1 pr-1">+</span>
</span>
{% endfor %}
</div>
<input
type="hidden"
name="{{ widget.name }}"
value="{% for t in widget.value %}&quot;{{ t.name }}&quot;{% if not forloop.last %},{% endif %}{% endfor %}"
>
<div class="modal fade tag-editor-error-modal">
<div class="modal-dialog modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{% trans "Error" context "Error modal" %}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
<span data-message="generic">{% trans "An error ocurred." context "Error modal" %}</span>
<span data-message="invalid-tag-name">{% trans "Invalid tag name." context "Error modal" %}</span>
<span data-message="tag-creation-failed">{% trans "Failed to create tag." context "Error modal" %}</span>
<span data-message="tag-checking-failed">{% trans "Failed to obtain tag data." context "Error modal" %}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-dismiss="modal">
{% trans "Close" context "Error modal" %}
</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase from django.test import TestCase
@ -602,3 +603,105 @@ class WeightFormsTest(FormsTestCaseBase):
page = self.c.post("/weight/{}/delete/".format(self.weight.id), follow=True) page = self.c.post("/weight/{}/delete/".format(self.weight.id), follow=True)
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)
self.assertContains(page, "Weight entry deleted") self.assertContains(page, "Weight entry deleted")
class NotesFormsTest(FormsTestCaseBase):
"""
Piggy-backs a bunch of tests for the tags-logic.
"""
@classmethod
def setUpClass(cls):
super(NotesFormsTest, cls).setUpClass()
cls.note = models.Note.objects.create(
child=cls.child,
note="Setup note",
time=timezone.now() - timezone.timedelta(days=2),
)
cls.note.tags.add("oldtag")
cls.oldtag = models.Tag.objects.filter(slug="oldtag").first()
def test_add_no_tags(self):
params = {
"child": self.child.id,
"note": "note with no tags",
"time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(),
}
page = self.c.post("/notes/add/", params, follow=True)
self.assertEqual(page.status_code, 200)
self.assertContains(page, "note with no tags")
def test_add_with_tags(self):
params = {
"child": self.child.id,
"note": "this note has tags",
"time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(),
"tags": 'A,B,"setup tag"',
}
old_notes = list(models.Note.objects.all())
page = self.c.post("/notes/add/", params, follow=True)
self.assertEqual(page.status_code, 200)
self.assertContains(page, "this note has tags")
new_notes = list(models.Note.objects.all())
# Find the new tag and extract its tags
old_pks = [n.pk for n in old_notes]
new_note = [n for n in new_notes if n.pk not in old_pks][0]
new_note_tag_names = [t.name for t in new_note.tags.all()]
self.assertSetEqual(set(new_note_tag_names), {"A", "B", "setup tag"})
def test_edit(self):
old_tag_last_used = self.oldtag.last_used
params = {
"child": self.note.child.id,
"note": "Edited note",
"time": self.localdate_string(),
"tags": "oldtag,newtag",
}
page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True)
self.assertEqual(page.status_code, 200)
self.note.refresh_from_db()
self.oldtag.refresh_from_db()
self.assertEqual(self.note.note, params["note"])
self.assertContains(
page, "Note entry for {} updated".format(str(self.note.child))
)
self.assertSetEqual(
set(t.name for t in self.note.tags.all()), {"oldtag", "newtag"}
)
# Old tag remains old, because it was not added
self.assertEqual(old_tag_last_used, self.oldtag.last_used)
# Second phase: Remove all tags then add "oldtag" through posting
# which should update the last_used tag
self.note.tags.clear()
self.note.save()
params = {
"child": self.note.child.id,
"note": "Edited note (2)",
"time": self.localdate_string(),
"tags": "oldtag",
}
page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True)
self.assertEqual(page.status_code, 200)
self.note.refresh_from_db()
self.oldtag.refresh_from_db()
self.assertLess(old_tag_last_used, self.oldtag.last_used)
def test_delete(self):
page = self.c.post("/notes/{}/delete/".format(self.note.id), follow=True)
self.assertEqual(page.status_code, 200)
self.assertContains(page, "Note entry deleted")

83
core/widgets.py Normal file
View File

@ -0,0 +1,83 @@
from django.forms import Media
from typing import Any, Dict, Optional
from django.forms import Widget
from . import models
class TagsEditor(Widget):
"""
Custom widget that provides an alternative editor for tags provided by the
taggit library.
The widget makes use of bootstrap v4 and its badge/pill feature and renders
a list of tags as badges that can be clicked to remove or add a tag to
the list of set tags. In addition, a user can dynamically add new, custom
tags, using a text editor.
"""
class Media:
js = ("babybuddy/js/tags_editor.js",)
input_type = "hidden"
template_name = "core/widget_tag_editor.html"
@staticmethod
def __unpack_tag(tag: models.Tag):
"""
Tiny utility function used to translate a tag to a serializable
dictionary of strings.
"""
return {"name": tag.name, "color": tag.color}
def format_value(self, value: Any) -> Optional[str]:
"""
Override format_value to provide a list of dictionaries rather than
a flat, comma-separated list of tags. This allows for the more
complex rendering of tags provided by this plugin.
"""
if value is not None and not isinstance(value, str):
value = [self.__unpack_tag(tag) for tag in value]
return value
def build_attrs(self, base_attrs, extra_attrs=None):
"""
Bootstrap integration adds form-control to the classes of the widget.
This works only for "plain" input-based widgets however. In addition,
we need to add a custom class "babybuddy-tags-editor" for the javascript
file to detect the widget and take control of its contents.
"""
attrs = super().build_attrs(base_attrs, extra_attrs)
class_string = attrs.get("class", "")
class_string = class_string.replace("form-control", "")
attrs["class"] = class_string + " babybuddy-tags-editor"
return attrs
def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]:
"""
Adds extra information to the payload provided to the widget's template.
Specifically:
- Query a list if "recently used" tags (max 256 to not cause
DoS issues) from the database to be used for auto-completion. ("most")
- Query a smaller list of 5 tags to be made available from a quick
selection widget ("quick").
"""
most_tags = models.Tag.objects.order_by("-last_used").all()[:256]
result = super().get_context(name, value, attrs)
tag_names = set(
x["name"] for x in (result.get("widget", {}).get("value", None) or [])
)
quick_suggestion_tags = [t for t in most_tags if t.name not in tag_names][:5]
result["widget"]["tag_suggestions"] = {
"quick": [
self.__unpack_tag(t)
for t in quick_suggestion_tags
if t.name not in tag_names
],
"most": [self.__unpack_tag(t) for t in most_tags],
}
return result

View File

@ -68,6 +68,9 @@ module.exports = {
'api/static_src/js/*.js', 'api/static_src/js/*.js',
'core/static_src/js/*.js', 'core/static_src/js/*.js',
'dashboard/static_src/js/*.js' 'dashboard/static_src/js/*.js'
],
tags_editor: [
'babybuddy/static_src/js/tags_editor.js'
] ]
}, },
stylesConfig: { stylesConfig: {

View File

@ -199,6 +199,12 @@ function scripts(cb) {
concat('app.js'), concat('app.js'),
gulp.dest(config.scriptsConfig.dest) gulp.dest(config.scriptsConfig.dest)
], cb); ], cb);
pump([
gulp.src(config.scriptsConfig.tags_editor),
concat('tags_editor.js'),
gulp.dest(config.scriptsConfig.dest)
], cb);
} }
/** /**

Binary file not shown.

View File

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Baby Buddy\n" "Project-Id-Version: Baby Buddy\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-02-25 05:14+0000\n" "POT-Creation-Date: 2022-03-02 21:15+0000\n"
"Language: de\n" "Language: de\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -121,59 +121,59 @@ msgstr "Zeitzone"
msgid "{user}'s Settings" msgid "{user}'s Settings"
msgstr "{user} Einstellungen" msgstr "{user} Einstellungen"
#: babybuddy/settings/base.py:166 #: babybuddy/settings/base.py:167
msgid "Chinese (simplified)" msgid "Chinese (simplified)"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:167 #: babybuddy/settings/base.py:168
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:168 #: babybuddy/settings/base.py:169
#, fuzzy #, fuzzy
#| msgid "English" #| msgid "English"
msgid "English (US)" msgid "English (US)"
msgstr "Englisch" msgstr "Englisch"
#: babybuddy/settings/base.py:169 #: babybuddy/settings/base.py:170
#, fuzzy #, fuzzy
#| msgid "English" #| msgid "English"
msgid "English (UK)" msgid "English (UK)"
msgstr "Englisch" msgstr "Englisch"
#: babybuddy/settings/base.py:170 #: babybuddy/settings/base.py:171
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: babybuddy/settings/base.py:171 #: babybuddy/settings/base.py:172
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:172 #: babybuddy/settings/base.py:173
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
#: babybuddy/settings/base.py:173 #: babybuddy/settings/base.py:174
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:174 #: babybuddy/settings/base.py:175
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:175 #: babybuddy/settings/base.py:176
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: babybuddy/settings/base.py:176 #: babybuddy/settings/base.py:177
msgid "Spanish" msgid "Spanish"
msgstr "Spanisch" msgstr "Spanisch"
#: babybuddy/settings/base.py:177 #: babybuddy/settings/base.py:178
msgid "Swedish" msgid "Swedish"
msgstr "Schwedisch" msgstr "Schwedisch"
#: babybuddy/settings/base.py:178 #: babybuddy/settings/base.py:179
msgid "Turkish" msgid "Turkish"
msgstr "Türkisch" msgstr "Türkisch"
@ -199,7 +199,7 @@ msgstr "Reset"
msgid "Filters" msgid "Filters"
msgstr "Filter" msgstr "Filter"
#: babybuddy/templates/babybuddy/form.html:11 #: babybuddy/templates/babybuddy/form.html:14
#: babybuddy/templates/babybuddy/user_settings_form.html:89 #: babybuddy/templates/babybuddy/user_settings_form.html:89
msgid "Submit" msgid "Submit"
msgstr "Senden" msgstr "Senden"
@ -222,27 +222,27 @@ msgstr ""
msgid "Quick Start Timer" msgid "Quick Start Timer"
msgstr "Quick-Start Timer" msgstr "Quick-Start Timer"
#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:158 #: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:232
#: core/models.py:162 #: core/models.py:236
msgid "Diaper Change" msgid "Diaper Change"
msgstr "Windeln wechseln" msgstr "Windeln wechseln"
#: babybuddy/templates/babybuddy/nav-dropdown.html:57 #: babybuddy/templates/babybuddy/nav-dropdown.html:57
#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:227 #: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:301
#: core/models.py:231 core/templates/core/timer_detail.html:43 #: core/models.py:305 core/templates/core/timer_detail.html:43
msgid "Feeding" msgid "Feeding"
msgstr "Mahlzeit" msgstr "Mahlzeit"
#: babybuddy/templates/babybuddy/nav-dropdown.html:63 #: babybuddy/templates/babybuddy/nav-dropdown.html:63
#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:250 #: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:324
#: core/models.py:260 core/models.py:264 core/templates/core/note_list.html:29 #: core/models.py:335 core/models.py:339 core/templates/core/note_list.html:29
msgid "Note" msgid "Note"
msgstr "Notiz" msgstr "Notiz"
#: babybuddy/templates/babybuddy/nav-dropdown.html:69 #: babybuddy/templates/babybuddy/nav-dropdown.html:69
#: babybuddy/templates/babybuddy/nav-dropdown.html:281 #: babybuddy/templates/babybuddy/nav-dropdown.html:281
#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:292 #: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:367
#: core/models.py:293 core/models.py:296 #: core/models.py:368 core/models.py:371
#: core/templates/core/sleep_confirm_delete.html:7 #: core/templates/core/sleep_confirm_delete.html:7
#: core/templates/core/sleep_form.html:13 core/templates/core/sleep_list.html:4 #: core/templates/core/sleep_form.html:13 core/templates/core/sleep_list.html:4
#: core/templates/core/sleep_list.html:7 core/templates/core/sleep_list.html:12 #: core/templates/core/sleep_list.html:7 core/templates/core/sleep_list.html:12
@ -251,8 +251,8 @@ msgid "Sleep"
msgstr "Schlafen" msgstr "Schlafen"
#: babybuddy/templates/babybuddy/nav-dropdown.html:75 #: babybuddy/templates/babybuddy/nav-dropdown.html:75
#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:331 #: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:406
#: core/models.py:341 core/models.py:342 core/models.py:345 #: core/models.py:416 core/models.py:417 core/models.py:420
#: core/templates/core/temperature_confirm_delete.html:7 #: core/templates/core/temperature_confirm_delete.html:7
#: core/templates/core/temperature_form.html:13 #: core/templates/core/temperature_form.html:13
#: core/templates/core/temperature_list.html:4 #: core/templates/core/temperature_list.html:4
@ -264,8 +264,8 @@ msgstr "Temperatur"
#: babybuddy/templates/babybuddy/nav-dropdown.html:81 #: babybuddy/templates/babybuddy/nav-dropdown.html:81
#: babybuddy/templates/babybuddy/nav-dropdown.html:294 #: babybuddy/templates/babybuddy/nav-dropdown.html:294
#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:468 #: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:543
#: core/models.py:469 core/models.py:472 #: core/models.py:544 core/models.py:547
#: core/templates/core/timer_detail.html:59 #: core/templates/core/timer_detail.html:59
#: core/templates/core/tummytime_confirm_delete.html:7 #: core/templates/core/tummytime_confirm_delete.html:7
#: core/templates/core/tummytime_form.html:13 #: core/templates/core/tummytime_form.html:13
@ -276,16 +276,16 @@ msgid "Tummy Time"
msgstr "Bauchzeit" msgstr "Bauchzeit"
#: babybuddy/templates/babybuddy/nav-dropdown.html:87 #: babybuddy/templates/babybuddy/nav-dropdown.html:87
#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:494 #: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:569
#: core/models.py:503 core/models.py:504 core/models.py:507 #: core/models.py:578 core/models.py:579 core/models.py:582
#: core/templates/core/weight_confirm_delete.html:7 #: core/templates/core/weight_confirm_delete.html:7
#: core/templates/core/weight_form.html:13 #: core/templates/core/weight_form.html:13
#: core/templates/core/weight_list.html:4 #: core/templates/core/weight_list.html:4
#: core/templates/core/weight_list.html:7 #: core/templates/core/weight_list.html:7
#: core/templates/core/weight_list.html:12 #: core/templates/core/weight_list.html:12
#: core/templates/core/weight_list.html:29 #: core/templates/core/weight_list.html:29 reports/graphs/weight_weight.py:19
#: dashboard/templates/dashboard/child_button_group.html:31 #: reports/graphs/weight_weight.py:30
#: reports/graphs/weight_weight.py:19 reports/graphs/weight_weight.py:30 #: reports/templates/reports/report_list.html:21
#: reports/templates/reports/weight_change.html:4 #: reports/templates/reports/weight_change.html:4
#: reports/templates/reports/weight_change.html:8 #: reports/templates/reports/weight_change.html:8
msgid "Weight" msgid "Weight"
@ -299,20 +299,20 @@ msgid "Timeline"
msgstr "" msgstr ""
#: babybuddy/templates/babybuddy/nav-dropdown.html:122 #: babybuddy/templates/babybuddy/nav-dropdown.html:122
#: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:101 #: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:175
#: core/templates/core/child_confirm_delete.html:7 #: core/templates/core/child_confirm_delete.html:7
#: core/templates/core/child_detail.html:7 #: core/templates/core/child_detail.html:7
#: core/templates/core/child_form.html:13 core/templates/core/child_list.html:4 #: core/templates/core/child_form.html:13 core/templates/core/child_list.html:4
#: core/templates/core/child_list.html:7 core/templates/core/child_list.html:12 #: core/templates/core/child_list.html:7 core/templates/core/child_list.html:12
#: dashboard/templates/dashboard/child.html:7 #: dashboard/templates/dashboard/child.html:7
#: reports/templates/reports/report_base.html:7 #: reports/templates/reports/base.html:7
msgid "Children" msgid "Children"
msgstr "Kinder" msgstr "Kinder"
#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:100 #: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:174
#: core/models.py:134 core/models.py:190 core/models.py:248 core/models.py:276 #: core/models.py:208 core/models.py:264 core/models.py:322 core/models.py:351
#: core/models.py:328 core/models.py:359 core/models.py:452 core/models.py:492 #: core/models.py:403 core/models.py:434 core/models.py:527 core/models.py:567
#: core/models.py:519 core/models.py:546 core/models.py:572 #: core/models.py:594 core/models.py:621 core/models.py:647
#: core/templates/core/bmi_list.html:27 #: core/templates/core/bmi_list.html:27
#: core/templates/core/diaperchange_list.html:27 #: core/templates/core/diaperchange_list.html:27
#: core/templates/core/feeding_list.html:27 #: core/templates/core/feeding_list.html:27
@ -326,9 +326,9 @@ msgstr "Kinder"
msgid "Child" msgid "Child"
msgstr "Kind" msgstr "Kind"
#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:151 #: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:225
#: core/models.py:220 core/models.py:261 core/models.py:284 core/models.py:334 #: core/models.py:294 core/models.py:336 core/models.py:359 core/models.py:409
#: core/models.py:496 core/models.py:523 core/models.py:552 core/models.py:576 #: core/models.py:571 core/models.py:598 core/models.py:627 core/models.py:651
#: core/templates/core/note_confirm_delete.html:7 #: core/templates/core/note_confirm_delete.html:7
#: core/templates/core/note_form.html:13 core/templates/core/note_list.html:4 #: core/templates/core/note_form.html:13 core/templates/core/note_list.html:4
#: core/templates/core/note_list.html:7 core/templates/core/note_list.html:12 #: core/templates/core/note_list.html:7 core/templates/core/note_list.html:12
@ -347,18 +347,18 @@ msgstr "Temperatur Messung"
msgid "Weight entry" msgid "Weight entry"
msgstr "Gewichtseintrag" msgstr "Gewichtseintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:521 #: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:596
#: core/models.py:530 core/models.py:531 core/models.py:534 #: core/models.py:605 core/models.py:606 core/models.py:609
#: core/templates/core/height_confirm_delete.html:7 #: core/templates/core/height_confirm_delete.html:7
#: core/templates/core/height_form.html:13 #: core/templates/core/height_form.html:13
#: core/templates/core/height_list.html:4 #: core/templates/core/height_list.html:4
#: core/templates/core/height_list.html:7 #: core/templates/core/height_list.html:7
#: core/templates/core/height_list.html:12 #: core/templates/core/height_list.html:12
#: core/templates/core/height_list.html:29 #: core/templates/core/height_list.html:29 reports/graphs/height_height.py:19
#: dashboard/templates/dashboard/child_button_group.html:32 #: reports/graphs/height_height.py:30
#: reports/graphs/height_height.py:19 reports/graphs/height_height.py:30
#: reports/templates/reports/height_change.html:4 #: reports/templates/reports/height_change.html:4
#: reports/templates/reports/height_change.html:8 #: reports/templates/reports/height_change.html:8
#: reports/templates/reports/report_list.html:17
#, fuzzy #, fuzzy
#| msgid "Weight" #| msgid "Weight"
msgid "Height" msgid "Height"
@ -370,38 +370,36 @@ msgstr "Gewicht"
msgid "Height entry" msgid "Height entry"
msgstr "Gewichtseintrag" msgstr "Gewichtseintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:549 #: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:624
#: core/models.py:559 core/models.py:560 core/models.py:563 #: core/models.py:634 core/models.py:635 core/models.py:638
#: core/templates/core/head_circumference_confirm_delete.html:7 #: core/templates/core/head_circumference_confirm_delete.html:7
#: core/templates/core/head_circumference_form.html:13 #: core/templates/core/head_circumference_form.html:13
#: core/templates/core/head_circumference_list.html:4 #: core/templates/core/head_circumference_list.html:4
#: core/templates/core/head_circumference_list.html:7 #: core/templates/core/head_circumference_list.html:7
#: core/templates/core/head_circumference_list.html:12 #: core/templates/core/head_circumference_list.html:12
#: core/templates/core/head_circumference_list.html:29 #: core/templates/core/head_circumference_list.html:29
#: dashboard/templates/dashboard/child_button_group.html:33
#: reports/graphs/head_circumference_head_circumference.py:19 #: reports/graphs/head_circumference_head_circumference.py:19
#: reports/graphs/head_circumference_head_circumference.py:30 #: reports/graphs/head_circumference_head_circumference.py:30
#: reports/templates/reports/head_circumference_change.html:4 #: reports/templates/reports/head_circumference_change.html:4
#: reports/templates/reports/head_circumference_change.html:8 #: reports/templates/reports/head_circumference_change.html:8
#: reports/templates/reports/report_list.html:16
msgid "Head Circumference" msgid "Head Circumference"
msgstr "" msgstr "Kopfumfang"
#: babybuddy/templates/babybuddy/nav-dropdown.html:220 #: babybuddy/templates/babybuddy/nav-dropdown.html:220
msgid "Head Circumference entry" msgid "Head Circumference entry"
msgstr "" msgstr "Kopfumfang Eintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:574 #: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:649
#: core/models.py:583 core/models.py:584 core/models.py:587 #: core/models.py:658 core/models.py:659 core/models.py:662
#: core/templates/core/bmi_confirm_delete.html:7 #: core/templates/core/bmi_confirm_delete.html:7
#: core/templates/core/bmi_form.html:13 core/templates/core/bmi_list.html:4 #: core/templates/core/bmi_form.html:13 core/templates/core/bmi_list.html:4
#: core/templates/core/bmi_list.html:7 core/templates/core/bmi_list.html:12 #: core/templates/core/bmi_list.html:7 core/templates/core/bmi_list.html:12
#: core/templates/core/bmi_list.html:29 #: core/templates/core/bmi_list.html:29 reports/graphs/bmi_bmi.py:19
#: dashboard/templates/dashboard/child_button_group.html:34 #: reports/graphs/bmi_bmi.py:30 reports/templates/reports/bmi_change.html:4
#: reports/graphs/bmi_bmi.py:19 reports/graphs/bmi_bmi.py:30
#: reports/templates/reports/bmi_change.html:4
#: reports/templates/reports/bmi_change.html:8 #: reports/templates/reports/bmi_change.html:8
msgid "BMI" msgid "BMI"
msgstr "" msgstr "BMI"
#: babybuddy/templates/babybuddy/nav-dropdown.html:234 #: babybuddy/templates/babybuddy/nav-dropdown.html:234
#, fuzzy #, fuzzy
@ -423,7 +421,7 @@ msgid "Change"
msgstr "Wechsel" msgstr "Wechsel"
#: babybuddy/templates/babybuddy/nav-dropdown.html:268 #: babybuddy/templates/babybuddy/nav-dropdown.html:268
#: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:228 #: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:302
#: core/templates/core/feeding_confirm_delete.html:7 #: core/templates/core/feeding_confirm_delete.html:7
#: core/templates/core/feeding_form.html:13 #: core/templates/core/feeding_form.html:13
#: core/templates/core/feeding_list.html:4 #: core/templates/core/feeding_list.html:4
@ -443,7 +441,7 @@ msgstr "Bauchzeit-Eintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:325 #: babybuddy/templates/babybuddy/nav-dropdown.html:325
#: babybuddy/templates/babybuddy/user_list.html:17 #: babybuddy/templates/babybuddy/user_list.html:17
#: babybuddy/templates/babybuddy/user_password_form.html:7 #: babybuddy/templates/babybuddy/user_password_form.html:7
#: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:378 #: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:453
#: core/templates/core/timer_list.html:32 #: core/templates/core/timer_list.html:32
msgid "User" msgid "User"
msgstr "Benutzer" msgstr "Benutzer"
@ -535,7 +533,7 @@ msgstr "Benutzer löschen"
#: core/templates/core/tummytime_confirm_delete.html:17 #: core/templates/core/tummytime_confirm_delete.html:17
#: core/templates/core/weight_confirm_delete.html:8 #: core/templates/core/weight_confirm_delete.html:8
#: core/templates/core/weight_confirm_delete.html:17 #: core/templates/core/weight_confirm_delete.html:17
#: dashboard/templates/dashboard/child_button_group.html:48 #: dashboard/templates/dashboard/child_button_group.html:27
msgid "Delete" msgid "Delete"
msgstr "löschen" msgstr "löschen"
@ -631,7 +629,7 @@ msgstr "E-Mail"
msgid "Staff" msgid "Staff"
msgstr "Angestellte" msgstr "Angestellte"
#: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:373 #: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:448
#: core/templates/core/timer_list.html:31 #: core/templates/core/timer_list.html:31
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
@ -708,7 +706,7 @@ msgstr ""
"Lerne und sehe die Bedürfnisse deines Babys voraus, ohne (<em>allzu viel</" "Lerne und sehe die Bedürfnisse deines Babys voraus, ohne (<em>allzu viel</"
"em>)Spekulation indem du Baby Buddy verwendest &mdash;" "em>)Spekulation indem du Baby Buddy verwendest &mdash;"
#: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:159 #: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:233
#: core/templates/core/diaperchange_confirm_delete.html:7 #: core/templates/core/diaperchange_confirm_delete.html:7
#: core/templates/core/diaperchange_form.html:13 #: core/templates/core/diaperchange_form.html:13
#: core/templates/core/diaperchange_list.html:4 #: core/templates/core/diaperchange_list.html:4
@ -894,105 +892,113 @@ msgstr "User API-Key neu generiert."
msgid "Settings saved!" msgid "Settings saved!"
msgstr "Einstellungen gespeichert!" msgstr "Einstellungen gespeichert!"
#: core/forms.py:115 #: core/forms.py:117
msgid "Name does not match child name." msgid "Name does not match child name."
msgstr "Name entspricht nicht dem Kindernamen." msgstr "Name entspricht nicht dem Kindernamen."
#: core/models.py:23 #: core/models.py:32
msgid "Date can not be in the future." msgid "Date can not be in the future."
msgstr "Datum darf nicht in der Zukunft liegen." msgstr "Datum darf nicht in der Zukunft liegen."
#: core/models.py:37 #: core/models.py:46
msgid "Start time must come before end time." msgid "Start time must come before end time."
msgstr "Startzeit muss vor Endzeit sein." msgstr "Startzeit muss vor Endzeit sein."
#: core/models.py:40 #: core/models.py:49
msgid "Duration too long." msgid "Duration too long."
msgstr "Dauer zu lange." msgstr "Dauer zu lange."
#: core/models.py:56 #: core/models.py:65
msgid "Another entry intersects the specified time period." msgid "Another entry intersects the specified time period."
msgstr "Ein anderer Eintrag schneidet sich mit der angegebenen Zeitperiode." msgstr "Ein anderer Eintrag schneidet sich mit der angegebenen Zeitperiode."
#: core/models.py:70 #: core/models.py:79
msgid "Date/time can not be in the future." msgid "Date/time can not be in the future."
msgstr "Datum/Zeit darf nicht in der Zukunft liegen." msgstr "Datum/Zeit darf nicht in der Zukunft liegen."
#: core/models.py:76 #: core/models.py:109
msgid "Tag"
msgstr ""
#: core/models.py:110
msgid "Tags"
msgstr ""
#: core/models.py:150
msgid "First name" msgid "First name"
msgstr "Vorname" msgstr "Vorname"
#: core/models.py:78 #: core/models.py:152
msgid "Last name" msgid "Last name"
msgstr "Nachname" msgstr "Nachname"
#: core/models.py:80 #: core/models.py:154
msgid "Birth date" msgid "Birth date"
msgstr "Geburtsdatum" msgstr "Geburtsdatum"
#: core/models.py:87 #: core/models.py:161
msgid "Slug" msgid "Slug"
msgstr "Slug" msgstr "Slug"
#: core/models.py:90 #: core/models.py:164
msgid "Picture" msgid "Picture"
msgstr "Bild" msgstr "Bild"
#: core/models.py:136 core/models.py:252 core/models.py:333 #: core/models.py:210 core/models.py:326 core/models.py:408
#: core/templates/core/diaperchange_list.html:25 #: core/templates/core/diaperchange_list.html:25
#: core/templates/core/note_list.html:25 #: core/templates/core/note_list.html:25
#: core/templates/core/temperature_list.html:25 #: core/templates/core/temperature_list.html:25
msgid "Time" msgid "Time"
msgstr "Zeit" msgstr "Zeit"
#: core/models.py:137 core/templates/core/diaperchange_list.html:60 #: core/models.py:211 core/templates/core/diaperchange_list.html:60
#: reports/graphs/diaperchange_types.py:36 #: reports/graphs/diaperchange_types.py:36
msgid "Wet" msgid "Wet"
msgstr "Nass" msgstr "Nass"
#: core/models.py:138 core/templates/core/diaperchange_list.html:61 #: core/models.py:212 core/templates/core/diaperchange_list.html:61
#: reports/graphs/diaperchange_types.py:30 #: reports/graphs/diaperchange_types.py:30
msgid "Solid" msgid "Solid"
msgstr "Fest" msgstr "Fest"
#: core/models.py:142 #: core/models.py:216
msgid "Black" msgid "Black"
msgstr "Schwarz" msgstr "Schwarz"
#: core/models.py:143 #: core/models.py:217
msgid "Brown" msgid "Brown"
msgstr "Braun" msgstr "Braun"
#: core/models.py:144 #: core/models.py:218
msgid "Green" msgid "Green"
msgstr "Grün" msgstr "Grün"
#: core/models.py:145 #: core/models.py:219
msgid "Yellow" msgid "Yellow"
msgstr "Gelb" msgstr "Gelb"
#: core/models.py:148 core/templates/core/diaperchange_list.html:30 #: core/models.py:222 core/templates/core/diaperchange_list.html:30
msgid "Color" msgid "Color"
msgstr "Farbe" msgstr "Farbe"
#: core/models.py:150 core/models.py:219 #: core/models.py:224 core/models.py:293
#: core/templates/core/diaperchange_list.html:31 #: core/templates/core/diaperchange_list.html:31
msgid "Amount" msgid "Amount"
msgstr "Menge" msgstr "Menge"
#: core/models.py:180 #: core/models.py:254
msgid "Wet and/or solid is required." msgid "Wet and/or solid is required."
msgstr "Nass und/oder fest wird benötigt." msgstr "Nass und/oder fest wird benötigt."
#: core/models.py:192 core/models.py:279 core/models.py:365 core/models.py:454 #: core/models.py:266 core/models.py:354 core/models.py:440 core/models.py:529
msgid "Start time" msgid "Start time"
msgstr "Startzeit" msgstr "Startzeit"
#: core/models.py:193 core/models.py:280 core/models.py:368 core/models.py:455 #: core/models.py:267 core/models.py:355 core/models.py:443 core/models.py:530
msgid "End time" msgid "End time"
msgstr "Endzeit" msgstr "Endzeit"
#: core/models.py:195 core/models.py:282 core/models.py:371 core/models.py:457 #: core/models.py:269 core/models.py:357 core/models.py:446 core/models.py:532
#: core/templates/core/feeding_list.html:34 #: core/templates/core/feeding_list.html:34
#: core/templates/core/sleep_list.html:30 #: core/templates/core/sleep_list.html:30
#: core/templates/core/timer_list.html:29 #: core/templates/core/timer_list.html:29
@ -1000,67 +1006,67 @@ msgstr "Endzeit"
msgid "Duration" msgid "Duration"
msgstr "Dauer" msgstr "Dauer"
#: core/models.py:199 #: core/models.py:273
msgid "Breast milk" msgid "Breast milk"
msgstr "Brustmilch" msgstr "Brustmilch"
#: core/models.py:200 #: core/models.py:274
msgid "Formula" msgid "Formula"
msgstr "Formel" msgstr "Formel"
#: core/models.py:201 #: core/models.py:275
msgid "Fortified breast milk" msgid "Fortified breast milk"
msgstr "Angereicherte Brustmilch" msgstr "Angereicherte Brustmilch"
#: core/models.py:202 #: core/models.py:276
msgid "Solid food" msgid "Solid food"
msgstr "" msgstr ""
#: core/models.py:205 core/templates/core/feeding_list.html:30 #: core/models.py:279 core/templates/core/feeding_list.html:30
msgid "Type" msgid "Type"
msgstr "Typ" msgstr "Typ"
#: core/models.py:209 #: core/models.py:283
msgid "Bottle" msgid "Bottle"
msgstr "Fläschchen" msgstr "Fläschchen"
#: core/models.py:210 #: core/models.py:284
msgid "Left breast" msgid "Left breast"
msgstr "Linke Brust" msgstr "Linke Brust"
#: core/models.py:211 #: core/models.py:285
msgid "Right breast" msgid "Right breast"
msgstr "Rechte Brust" msgstr "Rechte Brust"
#: core/models.py:212 #: core/models.py:286
msgid "Both breasts" msgid "Both breasts"
msgstr "Beide Brüste" msgstr "Beide Brüste"
#: core/models.py:213 #: core/models.py:287
msgid "Parent fed" msgid "Parent fed"
msgstr "" msgstr ""
#: core/models.py:214 #: core/models.py:288
msgid "Self fed" msgid "Self fed"
msgstr "" msgstr ""
#: core/models.py:217 core/templates/core/feeding_list.html:29 #: core/models.py:291 core/templates/core/feeding_list.html:29
msgid "Method" msgid "Method"
msgstr "Methode" msgstr "Methode"
#: core/models.py:278 #: core/models.py:353
msgid "Napping" msgid "Napping"
msgstr "" msgstr ""
#: core/models.py:362 core/templates/core/timer_list.html:25 #: core/models.py:437 core/templates/core/timer_list.html:25
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: core/models.py:386 core/templates/core/timer_form.html:4 #: core/models.py:461 core/templates/core/timer_form.html:4
msgid "Timer" msgid "Timer"
msgstr "Timer" msgstr "Timer"
#: core/models.py:387 core/templates/core/timer_confirm_delete.html:9 #: core/models.py:462 core/templates/core/timer_confirm_delete.html:9
#: core/templates/core/timer_confirm_delete_inactive.html:9 #: core/templates/core/timer_confirm_delete_inactive.html:9
#: core/templates/core/timer_detail.html:8 #: core/templates/core/timer_detail.html:8
#: core/templates/core/timer_form.html:7 core/templates/core/timer_list.html:4 #: core/templates/core/timer_form.html:7 core/templates/core/timer_list.html:4
@ -1069,16 +1075,16 @@ msgstr "Timer"
msgid "Timers" msgid "Timers"
msgstr "Timer" msgstr "Timer"
#: core/models.py:390 #: core/models.py:465
#, python-brace-format #, python-brace-format
msgid "Timer #{id}" msgid "Timer #{id}"
msgstr "Timer #{id}" msgstr "Timer #{id}"
#: core/models.py:460 core/templates/core/tummytime_list.html:30 #: core/models.py:535 core/templates/core/tummytime_list.html:30
msgid "Milestone" msgid "Milestone"
msgstr "Meilenstein" msgstr "Meilenstein"
#: core/models.py:495 core/models.py:522 core/models.py:551 core/models.py:575 #: core/models.py:570 core/models.py:597 core/models.py:626 core/models.py:650
#: core/templates/core/bmi_list.html:25 #: core/templates/core/bmi_list.html:25
#: core/templates/core/feeding_list.html:25 #: core/templates/core/feeding_list.html:25
#: core/templates/core/head_circumference_list.html:25 #: core/templates/core/head_circumference_list.html:25
@ -1098,24 +1104,22 @@ msgstr "Datum"
#, fuzzy #, fuzzy
#| msgid "Delete a Sleep Entry" #| msgid "Delete a Sleep Entry"
msgid "Delete a BMI Entry" msgid "Delete a BMI Entry"
msgstr "Einen Schlaf-Eintrag löschen" msgstr "Einen BMI-Wert löschen"
#: core/templates/core/bmi_form.html:8 core/templates/core/bmi_form.html:17 #: core/templates/core/bmi_form.html:8 core/templates/core/bmi_form.html:17
#: core/templates/core/bmi_form.html:27 #: core/templates/core/bmi_form.html:27
#, fuzzy
#| msgid "Add a Sleep Entry"
msgid "Add a BMI Entry" msgid "Add a BMI Entry"
msgstr "Schlaf-Eintrag hinzufügen" msgstr "BMI-Wert hinzufügen"
#: core/templates/core/bmi_list.html:15 #: core/templates/core/bmi_list.html:15
msgid "Add BMI" msgid "Add BMI"
msgstr "" msgstr "BMI Wert hinzufügen"
#: core/templates/core/bmi_list.html:66 #: core/templates/core/bmi_list.html:66
#, fuzzy #, fuzzy
#| msgid "No timer entries found." #| msgid "No timer entries found."
msgid "No bmi entries found." msgid "No bmi entries found."
msgstr "Keine Timer-Einträge gefunden." msgstr "Keine BMI-Einträge gefunden."
#: core/templates/core/child_confirm_delete.html:4 #: core/templates/core/child_confirm_delete.html:4
msgid "Delete a Child" msgid "Delete a Child"
@ -1165,6 +1169,7 @@ msgstr "Windelwechsel hinzufügen"
#: core/templates/core/feeding_form.html:17 #: core/templates/core/feeding_form.html:17
#: core/templates/core/note_form.html:17 core/templates/core/sleep_form.html:17 #: core/templates/core/note_form.html:17 core/templates/core/sleep_form.html:17
#: core/templates/core/tummytime_form.html:17 #: core/templates/core/tummytime_form.html:17
#: core/templates/core/widget_tag_editor.html:24
msgid "Add" msgid "Add"
msgstr "Hinzufügen" msgstr "Hinzufügen"
@ -1206,34 +1211,28 @@ msgid "No feedings found."
msgstr "Keine Mahlzeit gefunden." msgstr "Keine Mahlzeit gefunden."
#: core/templates/core/head_circumference_confirm_delete.html:4 #: core/templates/core/head_circumference_confirm_delete.html:4
#, fuzzy
#| msgid "Delete a Tummy Time Entry"
msgid "Delete a Head Circumference Entry" msgid "Delete a Head Circumference Entry"
msgstr "Bauchzeit-Eintrag löschen" msgstr "Kopfumfang löschen"
#: core/templates/core/head_circumference_form.html:8 #: core/templates/core/head_circumference_form.html:8
#: core/templates/core/head_circumference_form.html:17 #: core/templates/core/head_circumference_form.html:17
#: core/templates/core/head_circumference_form.html:27 #: core/templates/core/head_circumference_form.html:27
#, fuzzy
#| msgid "Add a Temperature Entry"
msgid "Add a Head Circumference Entry" msgid "Add a Head Circumference Entry"
msgstr "Temperaturmessung hinzufügen" msgstr "Kopfumfang hinzufügen"
#: core/templates/core/head_circumference_list.html:15 #: core/templates/core/head_circumference_list.html:15
msgid "Add Head Circumference" msgid "Add Head Circumference"
msgstr "" msgstr ""
#: core/templates/core/head_circumference_list.html:66 #: core/templates/core/head_circumference_list.html:66
#, fuzzy
#| msgid "No timer entries found."
msgid "No head circumference entries found." msgid "No head circumference entries found."
msgstr "Keine Timer-Einträge gefunden." msgstr "Keine Kopfumfang-Einträge gefunden."
#: core/templates/core/height_confirm_delete.html:4 #: core/templates/core/height_confirm_delete.html:4
#, fuzzy #, fuzzy
#| msgid "Delete a Weight Entry" #| msgid "Delete a Weight Entry"
msgid "Delete a Height Entry" msgid "Delete a Height Entry"
msgstr "Gewichts-Eintrag löschen" msgstr "Größen-Eintrag löschen"
#: core/templates/core/height_form.html:8 #: core/templates/core/height_form.html:8
#: core/templates/core/height_form.html:17 #: core/templates/core/height_form.html:17
@ -1241,19 +1240,17 @@ msgstr "Gewichts-Eintrag löschen"
#, fuzzy #, fuzzy
#| msgid "Add a Weight Entry" #| msgid "Add a Weight Entry"
msgid "Add a Height Entry" msgid "Add a Height Entry"
msgstr "Gewichts-Eintrag hinzufügen" msgstr "Größen-Eintrag hinzufügen"
#: core/templates/core/height_list.html:15 #: core/templates/core/height_list.html:15
#, fuzzy #, fuzzy
#| msgid "Add Weight" #| msgid "Add Weight"
msgid "Add Height" msgid "Add Height"
msgstr "Gewicht hinzufügen" msgstr "Größe hinzufügen"
#: core/templates/core/height_list.html:66 #: core/templates/core/height_list.html:66
#, fuzzy
#| msgid "No weight entries found."
msgid "No height entries found." msgid "No height entries found."
msgstr "Keine Gewichts-Einträge gefunden." msgstr "Keine Größen-Einträge gefunden."
#: core/templates/core/note_confirm_delete.html:4 #: core/templates/core/note_confirm_delete.html:4
msgid "Delete a Note" msgid "Delete a Note"
@ -1450,6 +1447,44 @@ msgstr "Gewicht hinzufügen"
msgid "No weight entries found." msgid "No weight entries found."
msgstr "Keine Gewichts-Einträge gefunden." msgstr "Keine Gewichts-Einträge gefunden."
#: core/templates/core/widget_tag_editor.html:22
msgid "Tag name"
msgstr "Tag-Name"
#: core/templates/core/widget_tag_editor.html:27
msgid "Recently used:"
msgstr "Kürzlich verwendet:"
#: core/templates/core/widget_tag_editor.html:45
msgctxt "Error modal"
msgid "Error"
msgstr "Fehler"
#: core/templates/core/widget_tag_editor.html:50
msgctxt "Error modal"
msgid "An error ocurred."
msgstr "Ein Fehler ist aufgetreten."
#: core/templates/core/widget_tag_editor.html:51
msgctxt "Error modal"
msgid "Invalid tag name."
msgstr "Ungültiger Tag-Name."
#: core/templates/core/widget_tag_editor.html:52
msgctxt "Error modal"
msgid "Failed to create tag."
msgstr "Fehler bein erzeugen des tags."
#: core/templates/core/widget_tag_editor.html:53
msgctxt "Error modal"
msgid "Failed to obtain tag data."
msgstr "Konnte Tag nicht laden."
#: core/templates/core/widget_tag_editor.html:58
msgctxt "Error modal"
msgid "Close"
msgstr "Schließen"
#: core/templates/timeline/_timeline.html:33 #: core/templates/timeline/_timeline.html:33
#, python-format #, python-format
msgid "%(since)s ago (%(time)s)" msgid "%(since)s ago (%(time)s)"
@ -1467,7 +1502,7 @@ msgid "%(since)s since previous"
msgstr "" msgstr ""
#: core/templates/timeline/_timeline.html:56 #: core/templates/timeline/_timeline.html:56
#: dashboard/templates/dashboard/child_button_group.html:41 #: dashboard/templates/dashboard/child_button_group.html:20
msgid "Edit" msgid "Edit"
msgstr "" msgstr ""
@ -1730,53 +1765,12 @@ msgstr "Nie"
msgid "Child actions" msgid "Child actions"
msgstr "Aktionen des Kindes" msgstr "Aktionen des Kindes"
#: dashboard/templates/dashboard/child_button_group.html:17 #: dashboard/templates/dashboard/child_button_group.html:12
#: reports/templates/reports/report_base.html:9 #: reports/templates/reports/base.html:9
#: reports/templates/reports/report_list.html:4
msgid "Reports" msgid "Reports"
msgstr "Reports" msgstr "Reports"
#: dashboard/templates/dashboard/child_button_group.html:23
msgid "Diaper Change Amounts"
msgstr "Windelwechsel Mengen"
#: dashboard/templates/dashboard/child_button_group.html:24
#: reports/templates/reports/diaperchange_types.html:4
#: reports/templates/reports/diaperchange_types.html:8
msgid "Diaper Change Types"
msgstr "Windewechsel Typen"
#: dashboard/templates/dashboard/child_button_group.html:25
#: reports/templates/reports/diaperchange_lifetimes.html:4
#: reports/templates/reports/diaperchange_lifetimes.html:8
msgid "Diaper Lifetimes"
msgstr "Windel-Lebensdauer"
#: dashboard/templates/dashboard/child_button_group.html:26
#: reports/templates/reports/feeding_amounts.html:4
#: reports/templates/reports/feeding_amounts.html:8
msgid "Feeding Amounts"
msgstr "Mahlzeiten"
#: dashboard/templates/dashboard/child_button_group.html:27
msgid "Feeding Durations (Average)"
msgstr "Mahlzeit Dauer (Durschschnitt)"
#: dashboard/templates/dashboard/child_button_group.html:28
#: reports/templates/reports/sleep_pattern.html:4
#: reports/templates/reports/sleep_pattern.html:8
msgid "Sleep Pattern"
msgstr "Schlafrhythmus"
#: dashboard/templates/dashboard/child_button_group.html:29
#: reports/templates/reports/sleep_totals.html:4
#: reports/templates/reports/sleep_totals.html:8
msgid "Sleep Totals"
msgstr "Schlaf Total"
#: dashboard/templates/dashboard/child_button_group.html:30
msgid "Tummy Time Durations (Sum)"
msgstr ""
#: dashboard/templatetags/cards.py:288 #: dashboard/templatetags/cards.py:288
msgid "Diaper change frequency" msgid "Diaper change frequency"
msgstr "Frequenz Windelwechsel" msgstr "Frequenz Windelwechsel"
@ -1817,7 +1811,7 @@ msgstr "Gewichtsänderung pro Woche"
#, fuzzy #, fuzzy
#| msgid "Weight change per week" #| msgid "Weight change per week"
msgid "BMI change per week" msgid "BMI change per week"
msgstr "Gewichtsänderung pro Woche" msgstr "BMI-änderung pro Woche"
#: dashboard/templatetags/cards.py:418 #: dashboard/templatetags/cards.py:418
msgid "Feeding frequency (past 3 days)" msgid "Feeding frequency (past 3 days)"
@ -1835,7 +1829,7 @@ msgstr "Freuqenz Mahlzeiten"
#, fuzzy #, fuzzy
#| msgid "<b>Weight</b>" #| msgid "<b>Weight</b>"
msgid "<b>BMI</b>" msgid "<b>BMI</b>"
msgstr "<b>Gewicht</b>" msgstr "<b>BMI</b>"
#: reports/graphs/diaperchange_amounts.py:27 #: reports/graphs/diaperchange_amounts.py:27
msgid "Diaper change amount" msgid "Diaper change amount"
@ -1955,15 +1949,61 @@ msgstr "<b>Gewicht</b>"
msgid "Diaper Amounts" msgid "Diaper Amounts"
msgstr "Windel Mengen" msgstr "Windel Mengen"
#: reports/templates/reports/diaperchange_lifetimes.html:4
#: reports/templates/reports/diaperchange_lifetimes.html:8
#: reports/templates/reports/report_list.html:13
msgid "Diaper Lifetimes"
msgstr "Windel-Lebensdauer"
#: reports/templates/reports/diaperchange_types.html:4
#: reports/templates/reports/diaperchange_types.html:8
#: reports/templates/reports/report_list.html:12
msgid "Diaper Change Types"
msgstr "Windewechsel Typen"
#: reports/templates/reports/feeding_amounts.html:4
#: reports/templates/reports/feeding_amounts.html:8
#: reports/templates/reports/report_list.html:14
msgid "Feeding Amounts"
msgstr "Mahlzeiten"
#: reports/templates/reports/feeding_duration.html:4 #: reports/templates/reports/feeding_duration.html:4
#: reports/templates/reports/feeding_duration.html:8 #: reports/templates/reports/feeding_duration.html:8
msgid "Average Feeding Durations" msgid "Average Feeding Durations"
msgstr "Durchschnittliche Mahlzeitendauer" msgstr "Durchschnittliche Mahlzeitendauer"
#: reports/templates/reports/report_base.html:19 #: reports/templates/reports/report_base.html:17
msgid "There is not enough data to generate this report." msgid "There is not enough data to generate this report."
msgstr "Es gibt nicht genügend Daten um diesen Report zu generieren." msgstr "Es gibt nicht genügend Daten um diesen Report zu generieren."
#: reports/templates/reports/report_list.html:10
msgid "Body Mass Index (BMI)"
msgstr "Body Mass Index (BMI)"
#: reports/templates/reports/report_list.html:11
msgid "Diaper Change Amounts"
msgstr "Windelwechsel Mengen"
#: reports/templates/reports/report_list.html:15
msgid "Feeding Durations (Average)"
msgstr "Mahlzeit Dauer (Durschschnitt)"
#: reports/templates/reports/report_list.html:18
#: reports/templates/reports/sleep_pattern.html:4
#: reports/templates/reports/sleep_pattern.html:8
msgid "Sleep Pattern"
msgstr "Schlafrhythmus"
#: reports/templates/reports/report_list.html:19
#: reports/templates/reports/sleep_totals.html:4
#: reports/templates/reports/sleep_totals.html:8
msgid "Sleep Totals"
msgstr "Schlaf Total"
#: reports/templates/reports/report_list.html:20
msgid "Tummy Time Durations (Sum)"
msgstr ""
#: reports/templates/reports/tummytime_duration.html:4 #: reports/templates/reports/tummytime_duration.html:4
#: reports/templates/reports/tummytime_duration.html:8 #: reports/templates/reports/tummytime_duration.html:8
msgid "Total Tummy Time Durations" msgid "Total Tummy Time Durations"

View File

@ -7,8 +7,9 @@
-i https://pypi.python.org/simple -i https://pypi.python.org/simple
asgiref==3.5.0; python_version >= '3.7' asgiref==3.5.0; python_version >= '3.7'
boto3==1.20.52 backports.zoneinfo==0.2.1; python_version < '3.9'
botocore==1.23.52; python_version >= '3.6' boto3==1.21.11
botocore==1.24.11; python_version >= '3.6'
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
diff-match-patch==20200713; python_version >= '2.7' diff-match-patch==20200713; python_version >= '2.7'
dj-database-url==0.5.0 dj-database-url==0.5.0
@ -19,11 +20,12 @@ django-imagekit==4.1.0
django-import-export==2.7.1 django-import-export==2.7.1
django-ipware==4.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' django-ipware==4.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
django-storages==1.12.3 django-storages==1.12.3
django-taggit==2.1.0
django-widget-tweaks==1.4.12 django-widget-tweaks==1.4.12
django==4.0.2 django==4.0.3
djangorestframework==3.13.1 djangorestframework==3.13.1
et-xmlfile==1.1.0; python_version >= '3.6' et-xmlfile==1.1.0; python_version >= '3.6'
faker==12.2.0 faker==13.3.0
gunicorn==20.1.0 gunicorn==20.1.0
jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
markuppy==1.14 markuppy==1.14
@ -37,8 +39,8 @@ python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0,
python-dotenv==0.19.2 python-dotenv==0.19.2
pytz==2021.3 pytz==2021.3
pyyaml==6.0 pyyaml==6.0
s3transfer==0.5.1; python_version >= '3.6' s3transfer==0.5.2; python_version >= '3.6'
setuptools==60.8.2; python_version >= '3.7' setuptools==60.9.3; python_version >= '3.7'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
sqlparse==0.4.2; python_version >= '3.5' sqlparse==0.4.2; python_version >= '3.5'
tablib[html,ods,xls,xlsx,yaml]==3.2.0; python_version >= '3.7' tablib[html,ods,xls,xlsx,yaml]==3.2.0; python_version >= '3.7'

View File

@ -10586,6 +10586,10 @@ h3 {
font-size: 1.65em; font-size: 1.65em;
} }
.modal-content {
color: #343a40;
}
#view-core\:child .child-photo { #view-core\:child .child-photo {
max-width: 150px; max-width: 150px;
} }

View File

@ -10586,6 +10586,10 @@ h3 {
font-size: 1.65em; font-size: 1.65em;
} }
.modal-content {
color: #343a40;
}
#view-core\:child .child-photo { #view-core\:child .child-photo {
max-width: 150px; max-width: 150px;
} }

Binary file not shown.

View File

@ -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);
}
});
})();

Binary file not shown.

354
static/babybuddy/js/tags_editor.js generated Normal file
View File

@ -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);
}
});
})();

BIN
static/babybuddy/js/tags_editor.js.gz generated Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long