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 = "*"
uritemplate = "*"
whitenoise = "*"
django-taggit = "==2.1.0"
[dev-packages]
coveralls = "*"

View File

@ -6,6 +6,8 @@ from rest_framework.exceptions import ValidationError
from django.contrib.auth.models import User
from django.utils import timezone
from taggit.serializers import TagListSerializerField, TaggitSerializer
from core import models
@ -125,10 +127,12 @@ class FeedingSerializer(CoreModelWithDurationSerializer):
)
class NoteSerializer(CoreModelSerializer):
class NoteSerializer(TaggitSerializer, CoreModelSerializer):
class Meta:
model = models.Note
fields = ("id", "child", "note", "time")
fields = ("id", "child", "note", "time", "tags")
tags = TagListSerializerField(required=False)
class SleepSerializer(CoreModelWithDurationSerializer):
@ -196,3 +200,14 @@ class BMISerializer(CoreModelSerializer):
class Meta:
model = models.BMI
fields = ("id", "child", "bmi", "date", "notes")
class TagsSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Tag
fields = ("slug", "name", "color", "last_used")
extra_kwargs = {
"slug": {"required": False, "read_only": True},
"color": {"required": False},
"last_used": {"required": False, "read_only": True},
}

View File

@ -251,13 +251,14 @@ class NoteAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
def test_get(self):
response = self.client.get(self.endpoint)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
self.assertDictEqual(
response.data["results"][0],
{
"id": 1,
"child": 1,
"note": "Fake note.",
"time": "2017-11-17T22:45:00-05:00",
"tags": [],
},
)
@ -551,3 +552,56 @@ class WeightAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, entry)
class TagsAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
endpoint = reverse("api:tag-list")
model = models.Tag
def test_get(self):
response = self.client.get(self.endpoint)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictEqual(
dict(response.data["results"][0]),
{
"name": "a name",
"slug": "a-name",
"color": "#FF0000",
"last_used": "2017-11-18T11:00:00-05:00",
},
)
def test_post(self):
data = {"name": "new tag", "color": "#123456"}
response = self.client.post(self.endpoint, data, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self.client.get(self.endpoint)
results = response.json()["results"]
results_by_name = {r["name"]: r for r in results}
tag_data = results_by_name["new tag"]
self.assertDictContainsSubset(data, tag_data)
self.assertEqual(tag_data["slug"], "new-tag")
self.assertTrue(tag_data["last_used"])
def test_patch(self):
endpoint = f"{self.endpoint}a-name/"
modified_data = {
"name": "A different name",
"color": "#567890",
}
response = self.client.patch(
endpoint,
modified_data,
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictContainsSubset(modified_data, response.data)
def test_delete(self):
endpoint = f"{self.endpoint}a-name/"
response = self.client.delete(endpoint)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
response = self.client.delete(endpoint)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

View File

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

View File

@ -92,3 +92,10 @@ class BMIViewSet(viewsets.ModelViewSet):
queryset = models.BMI.objects.all()
serializer_class = serializers.BMISerializer
filterset_fields = ("child", "date")
class TagsViewSet(viewsets.ModelViewSet):
queryset = models.Tag.objects.all()
serializer_class = serializers.TagsSerializer
lookup_field = "slug"
filterset_fields = ("last_used", "name")

View File

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

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 {
font-size: 1.65em;
}
// All modals
.modal-content {
color: theme-color('dark');
}

View File

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

View File

@ -6,6 +6,7 @@ from import_export import fields, resources
from import_export.admin import ImportExportMixin, ExportActionMixin
from core import models
from core.forms import TagAdminForm
class ImportExportResourceBase(resources.ModelResource):
@ -177,3 +178,17 @@ class WeightAdmin(ImportExportMixin, ExportActionMixin, admin.ModelAdmin):
"weight",
)
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 -*-
from django import forms
from django.forms import widgets
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _
from core import models
from core.widgets import TagsEditor
def set_initial_values(kwargs, form_type):
@ -82,6 +84,7 @@ class CoreModelForm(forms.ModelForm):
timer.stop(instance.end)
if commit:
instance.save()
self.save_m2m()
return instance
@ -161,7 +164,7 @@ class FeedingForm(CoreModelForm):
class NoteForm(CoreModelForm):
class Meta:
model = models.Note
fields = ["child", "note", "time"]
fields = ["child", "note", "time", "tags"]
widgets = {
"time": forms.DateTimeInput(
attrs={
@ -169,6 +172,7 @@ class NoteForm(CoreModelForm):
"data-target": "#datetimepicker_time",
}
),
"tags": TagsEditor(),
}
@ -310,3 +314,8 @@ class BMIForm(CoreModelForm):
),
"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 -*-
from datetime import timedelta
from typing import Iterable, Optional
from django.conf import settings
from django.core.cache import cache
@ -9,6 +10,14 @@ from django.utils.text import slugify
from django.utils import timezone
from django.utils.text import format_lazy
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):
@ -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):
model_name = "child"
first_name = models.CharField(max_length=255, verbose_name=_("First name"))
@ -251,6 +332,7 @@ class Note(models.Model):
time = models.DateTimeField(
default=timezone.now, blank=False, verbose_name=_("Time")
)
tags = TaggableManager(blank=True, through=Tagged)
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 -*-
from datetime import datetime
from django.contrib.auth.models import User
from django.core.management import call_command
from django.test import TestCase
@ -602,3 +603,105 @@ class WeightFormsTest(FormsTestCaseBase):
page = self.c.post("/weight/{}/delete/".format(self.weight.id), follow=True)
self.assertEqual(page.status_code, 200)
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',
'core/static_src/js/*.js',
'dashboard/static_src/js/*.js'
],
tags_editor: [
'babybuddy/static_src/js/tags_editor.js'
]
},
stylesConfig: {

View File

@ -199,6 +199,12 @@ function scripts(cb) {
concat('app.js'),
gulp.dest(config.scriptsConfig.dest)
], 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 ""
"Project-Id-Version: Baby Buddy\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"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@ -121,59 +121,59 @@ msgstr "Zeitzone"
msgid "{user}'s Settings"
msgstr "{user} Einstellungen"
#: babybuddy/settings/base.py:166
#: babybuddy/settings/base.py:167
msgid "Chinese (simplified)"
msgstr ""
#: babybuddy/settings/base.py:167
#: babybuddy/settings/base.py:168
msgid "Dutch"
msgstr ""
#: babybuddy/settings/base.py:168
#: babybuddy/settings/base.py:169
#, fuzzy
#| msgid "English"
msgid "English (US)"
msgstr "Englisch"
#: babybuddy/settings/base.py:169
#: babybuddy/settings/base.py:170
#, fuzzy
#| msgid "English"
msgid "English (UK)"
msgstr "Englisch"
#: babybuddy/settings/base.py:170
#: babybuddy/settings/base.py:171
msgid "French"
msgstr "Französisch"
#: babybuddy/settings/base.py:171
#: babybuddy/settings/base.py:172
msgid "Finnish"
msgstr ""
#: babybuddy/settings/base.py:172
#: babybuddy/settings/base.py:173
msgid "German"
msgstr "Deutsch"
#: babybuddy/settings/base.py:173
#: babybuddy/settings/base.py:174
msgid "Italian"
msgstr ""
#: babybuddy/settings/base.py:174
#: babybuddy/settings/base.py:175
msgid "Polish"
msgstr ""
#: babybuddy/settings/base.py:175
#: babybuddy/settings/base.py:176
msgid "Portuguese"
msgstr ""
#: babybuddy/settings/base.py:176
#: babybuddy/settings/base.py:177
msgid "Spanish"
msgstr "Spanisch"
#: babybuddy/settings/base.py:177
#: babybuddy/settings/base.py:178
msgid "Swedish"
msgstr "Schwedisch"
#: babybuddy/settings/base.py:178
#: babybuddy/settings/base.py:179
msgid "Turkish"
msgstr "Türkisch"
@ -199,7 +199,7 @@ msgstr "Reset"
msgid "Filters"
msgstr "Filter"
#: babybuddy/templates/babybuddy/form.html:11
#: babybuddy/templates/babybuddy/form.html:14
#: babybuddy/templates/babybuddy/user_settings_form.html:89
msgid "Submit"
msgstr "Senden"
@ -222,27 +222,27 @@ msgstr ""
msgid "Quick Start Timer"
msgstr "Quick-Start Timer"
#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:158
#: core/models.py:162
#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:232
#: core/models.py:236
msgid "Diaper Change"
msgstr "Windeln wechseln"
#: babybuddy/templates/babybuddy/nav-dropdown.html:57
#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:227
#: core/models.py:231 core/templates/core/timer_detail.html:43
#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:301
#: core/models.py:305 core/templates/core/timer_detail.html:43
msgid "Feeding"
msgstr "Mahlzeit"
#: babybuddy/templates/babybuddy/nav-dropdown.html:63
#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:250
#: core/models.py:260 core/models.py:264 core/templates/core/note_list.html:29
#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:324
#: core/models.py:335 core/models.py:339 core/templates/core/note_list.html:29
msgid "Note"
msgstr "Notiz"
#: babybuddy/templates/babybuddy/nav-dropdown.html:69
#: babybuddy/templates/babybuddy/nav-dropdown.html:281
#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:292
#: core/models.py:293 core/models.py:296
#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:367
#: core/models.py:368 core/models.py:371
#: 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_list.html:7 core/templates/core/sleep_list.html:12
@ -251,8 +251,8 @@ msgid "Sleep"
msgstr "Schlafen"
#: babybuddy/templates/babybuddy/nav-dropdown.html:75
#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:331
#: core/models.py:341 core/models.py:342 core/models.py:345
#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:406
#: core/models.py:416 core/models.py:417 core/models.py:420
#: core/templates/core/temperature_confirm_delete.html:7
#: core/templates/core/temperature_form.html:13
#: 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:294
#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:468
#: core/models.py:469 core/models.py:472
#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:543
#: core/models.py:544 core/models.py:547
#: core/templates/core/timer_detail.html:59
#: core/templates/core/tummytime_confirm_delete.html:7
#: core/templates/core/tummytime_form.html:13
@ -276,16 +276,16 @@ msgid "Tummy Time"
msgstr "Bauchzeit"
#: babybuddy/templates/babybuddy/nav-dropdown.html:87
#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:494
#: core/models.py:503 core/models.py:504 core/models.py:507
#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:569
#: core/models.py:578 core/models.py:579 core/models.py:582
#: core/templates/core/weight_confirm_delete.html:7
#: core/templates/core/weight_form.html:13
#: core/templates/core/weight_list.html:4
#: core/templates/core/weight_list.html:7
#: core/templates/core/weight_list.html:12
#: core/templates/core/weight_list.html:29
#: dashboard/templates/dashboard/child_button_group.html:31
#: reports/graphs/weight_weight.py:19 reports/graphs/weight_weight.py:30
#: core/templates/core/weight_list.html:29 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:8
msgid "Weight"
@ -299,20 +299,20 @@ msgid "Timeline"
msgstr ""
#: 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_detail.html:7
#: 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
#: dashboard/templates/dashboard/child.html:7
#: reports/templates/reports/report_base.html:7
#: reports/templates/reports/base.html:7
msgid "Children"
msgstr "Kinder"
#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:100
#: core/models.py:134 core/models.py:190 core/models.py:248 core/models.py:276
#: core/models.py:328 core/models.py:359 core/models.py:452 core/models.py:492
#: core/models.py:519 core/models.py:546 core/models.py:572
#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:174
#: core/models.py:208 core/models.py:264 core/models.py:322 core/models.py:351
#: core/models.py:403 core/models.py:434 core/models.py:527 core/models.py:567
#: core/models.py:594 core/models.py:621 core/models.py:647
#: core/templates/core/bmi_list.html:27
#: core/templates/core/diaperchange_list.html:27
#: core/templates/core/feeding_list.html:27
@ -326,9 +326,9 @@ msgstr "Kinder"
msgid "Child"
msgstr "Kind"
#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:151
#: core/models.py:220 core/models.py:261 core/models.py:284 core/models.py:334
#: core/models.py:496 core/models.py:523 core/models.py:552 core/models.py:576
#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:225
#: core/models.py:294 core/models.py:336 core/models.py:359 core/models.py:409
#: 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_form.html:13 core/templates/core/note_list.html:4
#: core/templates/core/note_list.html:7 core/templates/core/note_list.html:12
@ -347,18 +347,18 @@ msgstr "Temperatur Messung"
msgid "Weight entry"
msgstr "Gewichtseintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:521
#: core/models.py:530 core/models.py:531 core/models.py:534
#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:596
#: core/models.py:605 core/models.py:606 core/models.py:609
#: core/templates/core/height_confirm_delete.html:7
#: core/templates/core/height_form.html:13
#: core/templates/core/height_list.html:4
#: core/templates/core/height_list.html:7
#: core/templates/core/height_list.html:12
#: core/templates/core/height_list.html:29
#: dashboard/templates/dashboard/child_button_group.html:32
#: reports/graphs/height_height.py:19 reports/graphs/height_height.py:30
#: core/templates/core/height_list.html:29 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:8
#: reports/templates/reports/report_list.html:17
#, fuzzy
#| msgid "Weight"
msgid "Height"
@ -370,38 +370,36 @@ msgstr "Gewicht"
msgid "Height entry"
msgstr "Gewichtseintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:549
#: core/models.py:559 core/models.py:560 core/models.py:563
#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:624
#: 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_form.html:13
#: core/templates/core/head_circumference_list.html:4
#: core/templates/core/head_circumference_list.html:7
#: core/templates/core/head_circumference_list.html:12
#: 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:30
#: reports/templates/reports/head_circumference_change.html:4
#: reports/templates/reports/head_circumference_change.html:8
#: reports/templates/reports/report_list.html:16
msgid "Head Circumference"
msgstr ""
msgstr "Kopfumfang"
#: babybuddy/templates/babybuddy/nav-dropdown.html:220
msgid "Head Circumference entry"
msgstr ""
msgstr "Kopfumfang Eintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:574
#: core/models.py:583 core/models.py:584 core/models.py:587
#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:649
#: core/models.py:658 core/models.py:659 core/models.py:662
#: 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_list.html:7 core/templates/core/bmi_list.html:12
#: core/templates/core/bmi_list.html:29
#: dashboard/templates/dashboard/child_button_group.html:34
#: reports/graphs/bmi_bmi.py:19 reports/graphs/bmi_bmi.py:30
#: reports/templates/reports/bmi_change.html:4
#: core/templates/core/bmi_list.html:29 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
msgid "BMI"
msgstr ""
msgstr "BMI"
#: babybuddy/templates/babybuddy/nav-dropdown.html:234
#, fuzzy
@ -423,7 +421,7 @@ msgid "Change"
msgstr "Wechsel"
#: 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_form.html:13
#: core/templates/core/feeding_list.html:4
@ -443,7 +441,7 @@ msgstr "Bauchzeit-Eintrag"
#: babybuddy/templates/babybuddy/nav-dropdown.html:325
#: babybuddy/templates/babybuddy/user_list.html:17
#: 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
msgid "User"
msgstr "Benutzer"
@ -535,7 +533,7 @@ msgstr "Benutzer löschen"
#: core/templates/core/tummytime_confirm_delete.html:17
#: core/templates/core/weight_confirm_delete.html:8
#: 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"
msgstr "löschen"
@ -631,7 +629,7 @@ msgstr "E-Mail"
msgid "Staff"
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
msgid "Active"
msgstr "Aktiv"
@ -708,7 +706,7 @@ msgstr ""
"Lerne und sehe die Bedürfnisse deines Babys voraus, ohne (<em>allzu viel</"
"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_form.html:13
#: core/templates/core/diaperchange_list.html:4
@ -894,105 +892,113 @@ msgstr "User API-Key neu generiert."
msgid "Settings saved!"
msgstr "Einstellungen gespeichert!"
#: core/forms.py:115
#: core/forms.py:117
msgid "Name does not match child name."
msgstr "Name entspricht nicht dem Kindernamen."
#: core/models.py:23
#: core/models.py:32
msgid "Date can not be in the future."
msgstr "Datum darf nicht in der Zukunft liegen."
#: core/models.py:37
#: core/models.py:46
msgid "Start time must come before end time."
msgstr "Startzeit muss vor Endzeit sein."
#: core/models.py:40
#: core/models.py:49
msgid "Duration too long."
msgstr "Dauer zu lange."
#: core/models.py:56
#: core/models.py:65
msgid "Another entry intersects the specified time period."
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."
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"
msgstr "Vorname"
#: core/models.py:78
#: core/models.py:152
msgid "Last name"
msgstr "Nachname"
#: core/models.py:80
#: core/models.py:154
msgid "Birth date"
msgstr "Geburtsdatum"
#: core/models.py:87
#: core/models.py:161
msgid "Slug"
msgstr "Slug"
#: core/models.py:90
#: core/models.py:164
msgid "Picture"
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/note_list.html:25
#: core/templates/core/temperature_list.html:25
msgid "Time"
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
msgid "Wet"
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
msgid "Solid"
msgstr "Fest"
#: core/models.py:142
#: core/models.py:216
msgid "Black"
msgstr "Schwarz"
#: core/models.py:143
#: core/models.py:217
msgid "Brown"
msgstr "Braun"
#: core/models.py:144
#: core/models.py:218
msgid "Green"
msgstr "Grün"
#: core/models.py:145
#: core/models.py:219
msgid "Yellow"
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"
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
msgid "Amount"
msgstr "Menge"
#: core/models.py:180
#: core/models.py:254
msgid "Wet and/or solid is required."
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"
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"
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/sleep_list.html:30
#: core/templates/core/timer_list.html:29
@ -1000,67 +1006,67 @@ msgstr "Endzeit"
msgid "Duration"
msgstr "Dauer"
#: core/models.py:199
#: core/models.py:273
msgid "Breast milk"
msgstr "Brustmilch"
#: core/models.py:200
#: core/models.py:274
msgid "Formula"
msgstr "Formel"
#: core/models.py:201
#: core/models.py:275
msgid "Fortified breast milk"
msgstr "Angereicherte Brustmilch"
#: core/models.py:202
#: core/models.py:276
msgid "Solid food"
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"
msgstr "Typ"
#: core/models.py:209
#: core/models.py:283
msgid "Bottle"
msgstr "Fläschchen"
#: core/models.py:210
#: core/models.py:284
msgid "Left breast"
msgstr "Linke Brust"
#: core/models.py:211
#: core/models.py:285
msgid "Right breast"
msgstr "Rechte Brust"
#: core/models.py:212
#: core/models.py:286
msgid "Both breasts"
msgstr "Beide Brüste"
#: core/models.py:213
#: core/models.py:287
msgid "Parent fed"
msgstr ""
#: core/models.py:214
#: core/models.py:288
msgid "Self fed"
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"
msgstr "Methode"
#: core/models.py:278
#: core/models.py:353
msgid "Napping"
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"
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"
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_detail.html:8
#: core/templates/core/timer_form.html:7 core/templates/core/timer_list.html:4
@ -1069,16 +1075,16 @@ msgstr "Timer"
msgid "Timers"
msgstr "Timer"
#: core/models.py:390
#: core/models.py:465
#, python-brace-format
msgid "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"
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/feeding_list.html:25
#: core/templates/core/head_circumference_list.html:25
@ -1098,24 +1104,22 @@ msgstr "Datum"
#, fuzzy
#| msgid "Delete a Sleep 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:27
#, fuzzy
#| msgid "Add a Sleep Entry"
msgid "Add a BMI Entry"
msgstr "Schlaf-Eintrag hinzufügen"
msgstr "BMI-Wert hinzufügen"
#: core/templates/core/bmi_list.html:15
msgid "Add BMI"
msgstr ""
msgstr "BMI Wert hinzufügen"
#: core/templates/core/bmi_list.html:66
#, fuzzy
#| msgid "No timer 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
msgid "Delete a Child"
@ -1165,6 +1169,7 @@ msgstr "Windelwechsel hinzufügen"
#: core/templates/core/feeding_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/widget_tag_editor.html:24
msgid "Add"
msgstr "Hinzufügen"
@ -1206,34 +1211,28 @@ msgid "No feedings found."
msgstr "Keine Mahlzeit gefunden."
#: core/templates/core/head_circumference_confirm_delete.html:4
#, fuzzy
#| msgid "Delete a Tummy Time 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:17
#: core/templates/core/head_circumference_form.html:27
#, fuzzy
#| msgid "Add a Temperature Entry"
msgid "Add a Head Circumference Entry"
msgstr "Temperaturmessung hinzufügen"
msgstr "Kopfumfang hinzufügen"
#: core/templates/core/head_circumference_list.html:15
msgid "Add Head Circumference"
msgstr ""
#: core/templates/core/head_circumference_list.html:66
#, fuzzy
#| msgid "No timer 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
#, fuzzy
#| msgid "Delete a Weight 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:17
@ -1241,19 +1240,17 @@ msgstr "Gewichts-Eintrag löschen"
#, fuzzy
#| msgid "Add a Weight Entry"
msgid "Add a Height Entry"
msgstr "Gewichts-Eintrag hinzufügen"
msgstr "Größen-Eintrag hinzufügen"
#: core/templates/core/height_list.html:15
#, fuzzy
#| msgid "Add Weight"
msgid "Add Height"
msgstr "Gewicht hinzufügen"
msgstr "Größe hinzufügen"
#: core/templates/core/height_list.html:66
#, fuzzy
#| msgid "No weight 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
msgid "Delete a Note"
@ -1450,6 +1447,44 @@ msgstr "Gewicht hinzufügen"
msgid "No weight entries found."
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
#, python-format
msgid "%(since)s ago (%(time)s)"
@ -1467,7 +1502,7 @@ msgid "%(since)s since previous"
msgstr ""
#: core/templates/timeline/_timeline.html:56
#: dashboard/templates/dashboard/child_button_group.html:41
#: dashboard/templates/dashboard/child_button_group.html:20
msgid "Edit"
msgstr ""
@ -1730,53 +1765,12 @@ msgstr "Nie"
msgid "Child actions"
msgstr "Aktionen des Kindes"
#: dashboard/templates/dashboard/child_button_group.html:17
#: reports/templates/reports/report_base.html:9
#: dashboard/templates/dashboard/child_button_group.html:12
#: reports/templates/reports/base.html:9
#: reports/templates/reports/report_list.html:4
msgid "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
msgid "Diaper change frequency"
msgstr "Frequenz Windelwechsel"
@ -1817,7 +1811,7 @@ msgstr "Gewichtsänderung pro Woche"
#, fuzzy
#| msgid "Weight change per week"
msgid "BMI change per week"
msgstr "Gewichtsänderung pro Woche"
msgstr "BMI-änderung pro Woche"
#: dashboard/templatetags/cards.py:418
msgid "Feeding frequency (past 3 days)"
@ -1835,7 +1829,7 @@ msgstr "Freuqenz Mahlzeiten"
#, fuzzy
#| msgid "<b>Weight</b>"
msgid "<b>BMI</b>"
msgstr "<b>Gewicht</b>"
msgstr "<b>BMI</b>"
#: reports/graphs/diaperchange_amounts.py:27
msgid "Diaper change amount"
@ -1955,15 +1949,61 @@ msgstr "<b>Gewicht</b>"
msgid "Diaper Amounts"
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:8
msgid "Average Feeding Durations"
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."
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:8
msgid "Total Tummy Time Durations"

View File

@ -7,8 +7,9 @@
-i https://pypi.python.org/simple
asgiref==3.5.0; python_version >= '3.7'
boto3==1.20.52
botocore==1.23.52; python_version >= '3.6'
backports.zoneinfo==0.2.1; python_version < '3.9'
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'
diff-match-patch==20200713; python_version >= '2.7'
dj-database-url==0.5.0
@ -19,11 +20,12 @@ django-imagekit==4.1.0
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-storages==1.12.3
django-taggit==2.1.0
django-widget-tweaks==1.4.12
django==4.0.2
django==4.0.3
djangorestframework==3.13.1
et-xmlfile==1.1.0; python_version >= '3.6'
faker==12.2.0
faker==13.3.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'
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
pytz==2021.3
pyyaml==6.0
s3transfer==0.5.1; python_version >= '3.6'
setuptools==60.8.2; python_version >= '3.7'
s3transfer==0.5.2; python_version >= '3.6'
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'
sqlparse==0.4.2; python_version >= '3.5'
tablib[html,ods,xls,xlsx,yaml]==3.2.0; python_version >= '3.7'

View File

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

View File

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

Binary file not shown.

View File

@ -13248,46 +13248,46 @@ function transpose(out, a) {
};
},{}],64:[function(_dereq_,module,exports){
(function (global){(function (){
'use strict'
var isBrowser = _dereq_('is-browser')
var hasHover
if (typeof global.matchMedia === 'function') {
hasHover = !global.matchMedia('(hover: none)').matches
}
else {
hasHover = isBrowser
}
module.exports = hasHover
'use strict'
var isBrowser = _dereq_('is-browser')
var hasHover
if (typeof global.matchMedia === 'function') {
hasHover = !global.matchMedia('(hover: none)').matches
}
else {
hasHover = isBrowser
}
module.exports = hasHover
}).call(this)}).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {})
},{"is-browser":68}],65:[function(_dereq_,module,exports){
'use strict'
var isBrowser = _dereq_('is-browser')
function detect() {
var supported = false
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supported = true
}
})
window.addEventListener('test', null, opts)
window.removeEventListener('test', null, opts)
} catch(e) {
supported = false
}
return supported
}
module.exports = isBrowser && detect()
'use strict'
var isBrowser = _dereq_('is-browser')
function detect() {
var supported = false
try {
var opts = Object.defineProperty({}, 'passive', {
get: function() {
supported = true
}
})
window.addEventListener('test', null, opts)
window.removeEventListener('test', null, opts)
} catch(e) {
supported = false
}
return supported
}
module.exports = isBrowser && detect()
},{"is-browser":68}],66:[function(_dereq_,module,exports){
exports.read = function (buffer, offset, isLE, mLen, nBytes) {
@ -13407,78 +13407,78 @@ if (typeof Object.create === 'function') {
},{}],68:[function(_dereq_,module,exports){
module.exports = true;
},{}],69:[function(_dereq_,module,exports){
'use strict'
module.exports = isMobile
module.exports.isMobile = isMobile
module.exports.default = isMobile
var mobileRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i
var tabletRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino|android|ipad|playbook|silk/i
function isMobile (opts) {
if (!opts) opts = {}
var ua = opts.ua
if (!ua && typeof navigator !== 'undefined') ua = navigator.userAgent
if (ua && ua.headers && typeof ua.headers['user-agent'] === 'string') {
ua = ua.headers['user-agent']
}
if (typeof ua !== 'string') return false
var result = opts.tablet ? tabletRE.test(ua) : mobileRE.test(ua)
if (
!result &&
opts.tablet &&
opts.featureDetect &&
navigator &&
navigator.maxTouchPoints > 1 &&
ua.indexOf('Macintosh') !== -1 &&
ua.indexOf('Safari') !== -1
) {
result = true
}
return result
}
'use strict'
module.exports = isMobile
module.exports.isMobile = isMobile
module.exports.default = isMobile
var mobileRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i
var tabletRE = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series[46]0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino|android|ipad|playbook|silk/i
function isMobile (opts) {
if (!opts) opts = {}
var ua = opts.ua
if (!ua && typeof navigator !== 'undefined') ua = navigator.userAgent
if (ua && ua.headers && typeof ua.headers['user-agent'] === 'string') {
ua = ua.headers['user-agent']
}
if (typeof ua !== 'string') return false
var result = opts.tablet ? tabletRE.test(ua) : mobileRE.test(ua)
if (
!result &&
opts.tablet &&
opts.featureDetect &&
navigator &&
navigator.maxTouchPoints > 1 &&
ua.indexOf('Macintosh') !== -1 &&
ua.indexOf('Safari') !== -1
) {
result = true
}
return result
}
},{}],70:[function(_dereq_,module,exports){
'use strict';
/**
* Is this string all whitespace?
* This solution kind of makes my brain hurt, but it's significantly faster
* than !str.trim() or any other solution I could find.
*
* whitespace codes from: http://en.wikipedia.org/wiki/Whitespace_character
* and verified with:
*
* for(var i = 0; i < 65536; i++) {
* var s = String.fromCharCode(i);
* if(+s===0 && !s.trim()) console.log(i, s);
* }
*
* which counts a couple of these as *not* whitespace, but finds nothing else
* that *is* whitespace. Note that charCodeAt stops at 16 bits, but it appears
* that there are no whitespace characters above this, and code points above
* this do not map onto white space characters.
*/
module.exports = function(str){
var l = str.length,
a;
for(var i = 0; i < l; i++) {
a = str.charCodeAt(i);
if((a < 9 || a > 13) && (a !== 32) && (a !== 133) && (a !== 160) &&
(a !== 5760) && (a !== 6158) && (a < 8192 || a > 8205) &&
(a !== 8232) && (a !== 8233) && (a !== 8239) && (a !== 8287) &&
(a !== 8288) && (a !== 12288) && (a !== 65279)) {
return false;
}
}
return true;
}
'use strict';
/**
* Is this string all whitespace?
* This solution kind of makes my brain hurt, but it's significantly faster
* than !str.trim() or any other solution I could find.
*
* whitespace codes from: http://en.wikipedia.org/wiki/Whitespace_character
* and verified with:
*
* for(var i = 0; i < 65536; i++) {
* var s = String.fromCharCode(i);
* if(+s===0 && !s.trim()) console.log(i, s);
* }
*
* which counts a couple of these as *not* whitespace, but finds nothing else
* that *is* whitespace. Note that charCodeAt stops at 16 bits, but it appears
* that there are no whitespace characters above this, and code points above
* this do not map onto white space characters.
*/
module.exports = function(str){
var l = str.length,
a;
for(var i = 0; i < l; i++) {
a = str.charCodeAt(i);
if((a < 9 || a > 13) && (a !== 32) && (a !== 133) && (a !== 160) &&
(a !== 5760) && (a !== 6158) && (a < 8192 || a > 8205) &&
(a !== 8232) && (a !== 8233) && (a !== 8239) && (a !== 8287) &&
(a !== 8288) && (a !== 12288) && (a !== 65279)) {
return false;
}
}
return true;
}
},{}],71:[function(_dereq_,module,exports){
var rootPosition = { left: 0, top: 0 }

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.

View File

@ -26822,35 +26822,35 @@ return Popper;
return moment;
}));
/*@preserve
* Tempus Dominus Bootstrap4 v5.1.2 (https://tempusdominus.github.io/bootstrap-4/)
* Copyright 2016-2018 Jonathan Peterson
* Licensed under MIT (https://github.com/tempusdominus/bootstrap-3/blob/master/LICENSE)
*/
if (typeof jQuery === 'undefined') {
throw new Error('Tempus Dominus Bootstrap4\'s requires jQuery. jQuery must be included before Tempus Dominus Bootstrap4\'s JavaScript.');
}
+function ($) {
var version = $.fn.jquery.split(' ')[0].split('.');
if ((version[0] < 2 && version[1] < 9) || (version[0] === 1 && version[1] === 9 && version[2] < 1) || (version[0] >= 4)) {
throw new Error('Tempus Dominus Bootstrap4\'s requires at least jQuery v3.0.0 but less than v4.0.0');
}
}(jQuery);
if (typeof moment === 'undefined') {
throw new Error('Tempus Dominus Bootstrap4\'s requires moment.js. Moment.js must be included before Tempus Dominus Bootstrap4\'s JavaScript.');
}
var version = moment.version.split('.')
if ((version[0] <= 2 && version[1] < 17) || (version[0] >= 3)) {
throw new Error('Tempus Dominus Bootstrap4\'s requires at least moment.js v2.17.0 but less than v3.0.0');
}
+function () {
/*@preserve
* Tempus Dominus Bootstrap4 v5.1.2 (https://tempusdominus.github.io/bootstrap-4/)
* Copyright 2016-2018 Jonathan Peterson
* Licensed under MIT (https://github.com/tempusdominus/bootstrap-3/blob/master/LICENSE)
*/
if (typeof jQuery === 'undefined') {
throw new Error('Tempus Dominus Bootstrap4\'s requires jQuery. jQuery must be included before Tempus Dominus Bootstrap4\'s JavaScript.');
}
+function ($) {
var version = $.fn.jquery.split(' ')[0].split('.');
if ((version[0] < 2 && version[1] < 9) || (version[0] === 1 && version[1] === 9 && version[2] < 1) || (version[0] >= 4)) {
throw new Error('Tempus Dominus Bootstrap4\'s requires at least jQuery v3.0.0 but less than v4.0.0');
}
}(jQuery);
if (typeof moment === 'undefined') {
throw new Error('Tempus Dominus Bootstrap4\'s requires moment.js. Moment.js must be included before Tempus Dominus Bootstrap4\'s JavaScript.');
}
var version = moment.version.split('.')
if ((version[0] <= 2 && version[1] < 17) || (version[0] >= 3)) {
throw new Error('Tempus Dominus Bootstrap4\'s requires at least moment.js v2.17.0 but less than v3.0.0');
}
+function () {
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
@ -27179,8 +27179,8 @@ var DateTimePicker = function ($, moment) {
this._int();
}
/**
* @return {string}
/**
* @return {string}
*/
@ -28330,8 +28330,8 @@ var DateTimePicker = function ($, moment) {
return NAME;
}
/**
* @return {string}
/**
* @return {string}
*/
}, {
@ -28340,8 +28340,8 @@ var DateTimePicker = function ($, moment) {
return DATA_KEY;
}
/**
* @return {string}
/**
* @return {string}
*/
}, {
@ -28350,8 +28350,8 @@ var DateTimePicker = function ($, moment) {
return EVENT_KEY;
}
/**
* @return {string}
/**
* @return {string}
*/
}, {
@ -29537,10 +29537,10 @@ var TempusDominusBootstrap4 = function ($) {
return TempusDominusBootstrap4;
}(DateTimePicker);
/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
*/
@ -29598,6 +29598,6 @@ var TempusDominusBootstrap4 = function ($) {
};
return TempusDominusBootstrap4;
}(jQuery);
}();
}(jQuery);
}();

File diff suppressed because one or more lines are too long