diff --git a/.stylelintrc b/.stylelintrc index 3e9940af..4c359b72 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -1,16 +1,10 @@ { "extends": "stylelint-config-recommended-scss", - "plugins": [ - "stylelint-order", - "stylelint-scss" - ], + "plugins": ["stylelint-order", "stylelint-scss"], "rules": { "at-rule-no-vendor-prefix": true, "media-feature-name-no-vendor-prefix": true, - "order/order": [ - "declarations", - "rules" - ], + "order/order": ["declarations", "rules"], "property-no-vendor-prefix": true, "selector-no-vendor-prefix": true, "value-no-vendor-prefix": true diff --git a/babybuddy/fixtures/tests.json b/babybuddy/fixtures/tests.json index f3fc938b..ec51c443 100644 --- a/babybuddy/fixtures/tests.json +++ b/babybuddy/fixtures/tests.json @@ -12,8 +12,7 @@ { "model": "core.child", "pk": 1, - "fields": - { + "fields": { "first_name": "Fake", "last_name": "Child", "birth_date": "2017-11-11", @@ -24,8 +23,7 @@ { "model": "core.pumping", "pk": 1, - "fields": - { + "fields": { "child": 1, "amount": 5.0, "start": "2017-11-17T17:52:00Z", @@ -37,8 +35,7 @@ { "model": "core.pumping", "pk": 2, - "fields": - { + "fields": { "child": 1, "amount": 9.0, "start": "2017-11-17T20:03:00Z", @@ -50,8 +47,7 @@ { "model": "core.diaperchange", "pk": 2, - "fields": - { + "fields": { "child": 1, "time": "2017-11-18T16:00:00Z", "wet": false, @@ -63,8 +59,7 @@ { "model": "core.diaperchange", "pk": 3, - "fields": - { + "fields": { "child": 1, "time": "2017-11-18T19:00:00Z", "wet": true, @@ -77,8 +72,7 @@ { "model": "core.diaperchange", "pk": 4, - "fields": - { + "fields": { "child": 1, "time": "2017-11-17T13:00:00Z", "wet": false, @@ -90,8 +84,7 @@ { "model": "core.diaperchange", "pk": 5, - "fields": - { + "fields": { "child": 1, "time": "2017-11-17T16:00:00Z", "wet": false, @@ -103,8 +96,7 @@ { "model": "core.diaperchange", "pk": 6, - "fields": - { + "fields": { "child": 1, "time": "2017-11-16T15:00:00Z", "wet": true, @@ -116,8 +108,7 @@ { "model": "core.diaperchange", "pk": 7, - "fields": - { + "fields": { "child": 1, "time": "2017-11-16T18:00:00Z", "wet": true, @@ -129,8 +120,7 @@ { "model": "core.diaperchange", "pk": 8, - "fields": - { + "fields": { "child": 1, "time": "2017-11-15T15:00:00Z", "wet": false, @@ -142,8 +132,7 @@ { "model": "core.diaperchange", "pk": 9, - "fields": - { + "fields": { "child": 1, "time": "2017-11-15T13:00:00Z", "wet": true, @@ -155,8 +144,7 @@ { "model": "core.diaperchange", "pk": 10, - "fields": - { + "fields": { "child": 1, "time": "2017-11-15T16:00:00Z", "wet": true, @@ -168,8 +156,7 @@ { "model": "core.diaperchange", "pk": 11, - "fields": - { + "fields": { "child": 1, "time": "2017-11-15T21:00:00Z", "wet": true, @@ -181,8 +168,7 @@ { "model": "core.diaperchange", "pk": 12, - "fields": - { + "fields": { "child": 1, "time": "2017-11-14T15:00:00Z", "wet": true, @@ -194,8 +180,7 @@ { "model": "core.diaperchange", "pk": 13, - "fields": - { + "fields": { "child": 1, "time": "2017-11-13T14:00:00Z", "wet": true, @@ -207,8 +192,7 @@ { "model": "core.diaperchange", "pk": 14, - "fields": - { + "fields": { "child": 1, "time": "2017-11-13T18:00:00Z", "wet": true, @@ -220,8 +204,7 @@ { "model": "core.diaperchange", "pk": 15, - "fields": - { + "fields": { "child": 1, "time": "2017-11-12T15:00:00Z", "wet": true, @@ -233,8 +216,7 @@ { "model": "core.diaperchange", "pk": 16, - "fields": - { + "fields": { "child": 1, "time": "2017-11-11T15:00:00Z", "wet": true, @@ -246,8 +228,7 @@ { "model": "core.feeding", "pk": 1, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T09:00:00Z", "end": "2017-11-18T09:30:00Z", @@ -260,8 +241,7 @@ { "model": "core.feeding", "pk": 2, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T11:30:00Z", "end": "2017-11-18T12:00:00Z", @@ -274,8 +254,7 @@ { "model": "core.feeding", "pk": 3, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T14:00:00Z", "end": "2017-11-18T14:15:00Z", @@ -289,8 +268,7 @@ { "model": "core.feeding", "pk": 4, - "fields": - { + "fields": { "child": 1, "start": "2017-11-17T14:00:00Z", "end": "2017-11-17T14:15:00Z", @@ -304,8 +282,7 @@ { "model": "core.feeding", "pk": 5, - "fields": - { + "fields": { "child": 1, "start": "2017-11-11T14:00:00Z", "end": "2017-11-11T14:15:00Z", @@ -319,8 +296,7 @@ { "model": "core.feeding", "pk": 6, - "fields": - { + "fields": { "child": 1, "start": "2017-11-11T05:00:00Z", "end": "2017-11-11T05:15:00Z", @@ -334,8 +310,7 @@ { "model": "core.note", "pk": 1, - "fields": - { + "fields": { "child": 1, "note": "Fake note.", "time": "2017-11-18T03:45:00Z" @@ -344,8 +319,7 @@ { "model": "core.sleep", "pk": 1, - "fields": - { + "fields": { "child": 1, "start": "2017-11-17T20:30:00Z", "end": "2017-11-18T05:30:00Z", @@ -356,8 +330,7 @@ { "model": "core.sleep", "pk": 2, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T15:00:00Z", "end": "2017-11-18T17:00:00Z", @@ -368,8 +341,7 @@ { "model": "core.sleep", "pk": 3, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T19:00:00Z", "end": "2017-11-19T04:30:00Z", @@ -380,8 +352,7 @@ { "model": "core.sleep", "pk": 4, - "fields": - { + "fields": { "child": 1, "start": "2017-11-19T08:00:00Z", "end": "2017-11-19T09:30:00Z", @@ -390,11 +361,10 @@ "nap": true } }, - { + { "model": "core.temperature", "pk": 1, - "fields": - { + "fields": { "child": 1, "temperature": 98.6, "time": "2017-11-17T17:52:00Z", @@ -404,8 +374,7 @@ { "model": "core.timer", "pk": 1, - "fields": - { + "fields": { "name": "Fake timer", "start": "2017-11-18T04:30:00Z", "user": 1 @@ -414,8 +383,7 @@ { "model": "core.tummytime", "pk": 1, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T15:00:00Z", "end": "2017-11-18T15:03:00Z", @@ -426,8 +394,7 @@ { "model": "core.tummytime", "pk": 2, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T17:15:30Z", "end": "2017-11-18T17:16:45Z", @@ -438,8 +405,7 @@ { "model": "core.tummytime", "pk": 3, - "fields": - { + "fields": { "child": 1, "start": "2017-11-18T20:30:00Z", "end": "2017-11-18T20:30:45Z", @@ -450,8 +416,7 @@ { "model": "core.weight", "pk": 1, - "fields": - { + "fields": { "child": 1, "weight": 8.5, "date": "2017-11-11" @@ -460,8 +425,7 @@ { "model": "core.weight", "pk": 2, - "fields": - { + "fields": { "child": 1, "weight": 9.5, "date": "2017-11-18", @@ -471,8 +435,7 @@ { "model": "core.height", "pk": 1, - "fields": - { + "fields": { "child": 1, "height": 9.5, "date": "2017-11-11" @@ -481,8 +444,7 @@ { "model": "core.height", "pk": 2, - "fields": - { + "fields": { "child": 1, "height": 10.5, "date": "2017-11-18", @@ -492,8 +454,7 @@ { "model": "core.headcircumference", "pk": 1, - "fields": - { + "fields": { "child": 1, "head_circumference": 5.5, "date": "2017-11-11" @@ -502,8 +463,7 @@ { "model": "core.headcircumference", "pk": 2, - "fields": - { + "fields": { "child": 1, "head_circumference": 6.5, "date": "2017-11-18", @@ -513,8 +473,7 @@ { "model": "core.bmi", "pk": 1, - "fields": - { + "fields": { "child": 1, "bmi": 25.5, "date": "2017-11-11" @@ -523,8 +482,7 @@ { "model": "core.bmi", "pk": 2, - "fields": - { + "fields": { "child": 1, "bmi": 26.5, "date": "2017-11-18", @@ -534,8 +492,7 @@ { "model": "core.tag", "pk": 1, - "fields": - { + "fields": { "name": "a name", "slug": "a-name", "color": "#FF0000", diff --git a/babybuddy/static_src/fontello/config.json b/babybuddy/static_src/fontello/config.json index e415a3d8..1103428e 100644 --- a/babybuddy/static_src/fontello/config.json +++ b/babybuddy/static_src/fontello/config.json @@ -253,4 +253,4 @@ "src": "entypo" } ] -} \ No newline at end of file +} diff --git a/babybuddy/static_src/js/babybuddy.js b/babybuddy/static_src/js/babybuddy.js index b53c6989..732f69c3 100644 --- a/babybuddy/static_src/js/babybuddy.js +++ b/babybuddy/static_src/js/babybuddy.js @@ -1,5 +1,5 @@ -if (typeof jQuery === 'undefined') { - throw new Error('Baby Buddy requires jQuery.') +if (typeof jQuery === "undefined") { + throw new Error("Baby Buddy requires jQuery."); } /** @@ -9,37 +9,37 @@ if (typeof jQuery === 'undefined') { * * @type {{}} */ -var BabyBuddy = function () { - return {}; -}(); +var BabyBuddy = (function () { + return {}; +})(); /** * Pull to refresh. * * @type {{init: BabyBuddy.PullToRefresh.init, onRefresh: BabyBuddy.PullToRefresh.onRefresh}} */ -BabyBuddy.PullToRefresh = function(ptr) { - return { - init: function () { - ptr.init({ - mainElement: 'body', - onRefresh: this.onRefresh - }); - }, +BabyBuddy.PullToRefresh = (function (ptr) { + return { + init: function () { + ptr.init({ + mainElement: "body", + onRefresh: this.onRefresh, + }); + }, - onRefresh: function() { - window.location.reload(); - } - }; -}(PullToRefresh); + onRefresh: function () { + window.location.reload(); + }, + }; +})(PullToRefresh); /** * Fix for duplicate form submission from double pressing submit */ function preventDoubleSubmit() { - return false; + return false; } -$('form').off("submit", preventDoubleSubmit); -$("form").on("submit", function() { - $(this).on("submit", preventDoubleSubmit); +$("form").off("submit", preventDoubleSubmit); +$("form").on("submit", function () { + $(this).on("submit", preventDoubleSubmit); }); diff --git a/babybuddy/static_src/js/tags_editor.js b/babybuddy/static_src/js/tags_editor.js index 8038e579..4163e275 100644 --- a/babybuddy/static_src/js/tags_editor.js +++ b/babybuddy/static_src/js/tags_editor.js @@ -1,354 +1,379 @@ -(function() { - /** - * Parse a string as hexadecimal number - */ - function hexParse(x) { - return parseInt(x, 16); +(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); } - /** - * 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'; + // If a three-character hexcode, make six-character + if (hexcolor.length === 3) { + hexcolor = hexcolor + .split("") + .map(function (hex) { + return hex + hex; + }) + .join(""); } - // 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; + // 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); - function doReq(method, uri, data, success, fail) { - // TODO: prefer jQuery based requests for now + // Get YIQ ratio + let yiq = (r * 299 + g * 587 + b * 114) / 1000; - 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; + // Check contrast + return yiq >= 128 ? "#101010" : "#EFEFEF"; + } - req.open(method, uri); - req.setRequestHeader("Content-Type", "application/json"); - req.setRequestHeader("Accept", "application/json"); - req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN); - req.send(data); - } + // 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; - /** - * 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 = []; + function doReq(method, uri, data, success, fail) { + // TODO: prefer jQuery based requests for now - 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('#add-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 "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); - } + const req = new XMLHttpRequest(); + req.addEventListener("load", () => { + if (req.status >= 200 && req.status < 300) { + success(req.responseText, req); + } else { + fail(req.responseText, req); + } }); -})(); \ No newline at end of file + 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("#add-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 "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); + } + }); +})(); diff --git a/babybuddy/static_src/root/site.webmanifest b/babybuddy/static_src/root/site.webmanifest index 59223308..0d3749c5 100644 --- a/babybuddy/static_src/root/site.webmanifest +++ b/babybuddy/static_src/root/site.webmanifest @@ -1,21 +1,21 @@ { - "name": "Baby Buddy", - "short_name": "Baby Buddy", - "icons": [ - { - "src": "android-chrome-192x192.png?v=20211218", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "android-chrome-512x512.png?v=20211218", - "sizes": "512x512", - "type": "image/png" - } - ], - "lang": "en-US", - "start_url": "/", - "display": "standalone", - "theme_color": "#37abe9", - "background_color": "#212529" + "name": "Baby Buddy", + "short_name": "Baby Buddy", + "icons": [ + { + "src": "android-chrome-192x192.png?v=20211218", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "android-chrome-512x512.png?v=20211218", + "sizes": "512x512", + "type": "image/png" + } + ], + "lang": "en-US", + "start_url": "/", + "display": "standalone", + "theme_color": "#37abe9", + "background_color": "#212529" } diff --git a/core/static_src/js/timer.js b/core/static_src/js/timer.js index d3b8f598..7bdd8a75 100644 --- a/core/static_src/js/timer.js +++ b/core/static_src/js/timer.js @@ -6,98 +6,96 @@ * * timer-minutes * * timer-hours */ -BabyBuddy.Timer = function ($) { - var runIntervalId = null; - var timerId = null; - var timerElement = null; - var lastUpdate = new Date(); - var hidden = null; +BabyBuddy.Timer = (function ($) { + var runIntervalId = null; + var timerId = null; + var timerElement = null; + var lastUpdate = new Date(); + var hidden = null; - var Timer = { - run: function(timer_id, element_id) { - timerId = timer_id; - timerElement = $('#' + element_id); + var Timer = { + run: function (timer_id, element_id) { + timerId = timer_id; + timerElement = $("#" + element_id); - if (timerElement.length === 0) { - console.error('BBTimer: Timer element not found.'); - return false; - } + if (timerElement.length === 0) { + console.error("BBTimer: Timer element not found."); + return false; + } - if (timerElement.find('.timer-seconds').length === 0 - || timerElement.find('.timer-minutes').length === 0 - || timerElement.find('.timer-hours').length === 0) { - console.error('BBTimer: Element does not contain expected children.'); - return false; - } + if ( + timerElement.find(".timer-seconds").length === 0 || + timerElement.find(".timer-minutes").length === 0 || + timerElement.find(".timer-hours").length === 0 + ) { + console.error("BBTimer: Element does not contain expected children."); + return false; + } - runIntervalId = setInterval(this.tick, 1000); + runIntervalId = setInterval(this.tick, 1000); - // If the page just came in to view, update the timer data with the - // current actual duration. This will (potentially) help mobile - // phones that lock with the timer page open. - if (typeof document.hidden !== "undefined") { - hidden = "hidden"; - } - else if (typeof document.msHidden !== "undefined") { - hidden = "msHidden"; - } - else if (typeof document.webkitHidden !== "undefined") { - hidden = "webkitHidden"; - } - window.addEventListener('focus', Timer.handleVisibilityChange, false); - }, + // If the page just came in to view, update the timer data with the + // current actual duration. This will (potentially) help mobile + // phones that lock with the timer page open. + if (typeof document.hidden !== "undefined") { + hidden = "hidden"; + } else if (typeof document.msHidden !== "undefined") { + hidden = "msHidden"; + } else if (typeof document.webkitHidden !== "undefined") { + hidden = "webkitHidden"; + } + window.addEventListener("focus", Timer.handleVisibilityChange, false); + }, - handleVisibilityChange: function() { - if (!document[hidden] && (new Date()) - lastUpdate > 1) { - Timer.update(); - } - }, + handleVisibilityChange: function () { + if (!document[hidden] && new Date() - lastUpdate > 1) { + Timer.update(); + } + }, - tick: function() { - var s = timerElement.find('.timer-seconds'); - var seconds = Number(s.text()); - if (seconds < 59) { - s.text(seconds + 1); - return; - } - else { - s.text(0); - } + tick: function () { + var s = timerElement.find(".timer-seconds"); + var seconds = Number(s.text()); + if (seconds < 59) { + s.text(seconds + 1); + return; + } else { + s.text(0); + } - var m = timerElement.find('.timer-minutes'); - var minutes = Number(m.text()); - if (minutes < 59) { - m.text(minutes + 1); - return; - } - else { - m.text(0); - } + var m = timerElement.find(".timer-minutes"); + var minutes = Number(m.text()); + if (minutes < 59) { + m.text(minutes + 1); + return; + } else { + m.text(0); + } - var h = timerElement.find('.timer-hours'); - var hours = Number(h.text()); - h.text(hours + 1); - }, + var h = timerElement.find(".timer-hours"); + var hours = Number(h.text()); + h.text(hours + 1); + }, - update: function() { - $.get('/api/timers/' + timerId + '/', function(data) { - if (data && 'duration' in data) { - clearInterval(runIntervalId); - var duration = data.duration.split(/[\s:.]/) - if (duration.length === 5) { - duration[0] = parseInt(duration[0]) * 24 + parseInt(duration[1]); - duration[1] = duration[2]; - duration[2] = duration[3]; - } - timerElement.find('.timer-hours').text(parseInt(duration[0])); - timerElement.find('.timer-minutes').text(parseInt(duration[1])); - timerElement.find('.timer-seconds').text(parseInt(duration[2])); - lastUpdate = new Date() - runIntervalId = setInterval(Timer.tick, 1000); - } - }); + update: function () { + $.get("/api/timers/" + timerId + "/", function (data) { + if (data && "duration" in data) { + clearInterval(runIntervalId); + var duration = data.duration.split(/[\s:.]/); + if (duration.length === 5) { + duration[0] = parseInt(duration[0]) * 24 + parseInt(duration[1]); + duration[1] = duration[2]; + duration[2] = duration[3]; + } + timerElement.find(".timer-hours").text(parseInt(duration[0])); + timerElement.find(".timer-minutes").text(parseInt(duration[1])); + timerElement.find(".timer-seconds").text(parseInt(duration[2])); + lastUpdate = new Date(); + runIntervalId = setInterval(Timer.tick, 1000); } - }; + }); + }, + }; - return Timer; -}(jQuery); + return Timer; +})(jQuery); diff --git a/dashboard/static_src/js/dashboard.js b/dashboard/static_src/js/dashboard.js index 8039525f..54cbe6e5 100644 --- a/dashboard/static_src/js/dashboard.js +++ b/dashboard/static_src/js/dashboard.js @@ -3,54 +3,61 @@ * Provides a "watch" function to update the dashboard at one minute intervals * and/or on visibility state changes. */ -BabyBuddy.Dashboard = function ($) { - var runIntervalId = null; - var dashboardElement = null; - var hidden = null; +BabyBuddy.Dashboard = (function ($) { + var runIntervalId = null; + var dashboardElement = null; + var hidden = null; - var Dashboard = { - watch: function(element_id, refresh_rate) { - dashboardElement = $('#' + element_id); + var Dashboard = { + watch: function (element_id, refresh_rate) { + dashboardElement = $("#" + element_id); - if (dashboardElement.length == 0) { - console.error('Baby Buddy: Dashboard element not found.'); - return false; - } + if (dashboardElement.length == 0) { + console.error("Baby Buddy: Dashboard element not found."); + return false; + } - if (typeof document.hidden !== "undefined") { - hidden = "hidden"; - } - else if (typeof document.msHidden !== "undefined") { - hidden = "msHidden"; - } - else if (typeof document.webkitHidden !== "undefined") { - hidden = "webkitHidden"; - } + if (typeof document.hidden !== "undefined") { + hidden = "hidden"; + } else if (typeof document.msHidden !== "undefined") { + hidden = "msHidden"; + } else if (typeof document.webkitHidden !== "undefined") { + hidden = "webkitHidden"; + } - if (typeof window.addEventListener === "undefined" || typeof document.hidden === "undefined") { - if (refresh_rate) { - runIntervalId = setInterval(this.update, refresh_rate); - } - } - else { - window.addEventListener('focus', Dashboard.handleVisibilityChange, false); - if (refresh_rate) { - runIntervalId = setInterval(Dashboard.handleVisibilityChange, refresh_rate); - } - } - }, - - handleVisibilityChange: function() { - if (!document[hidden]) { - Dashboard.update(); - } - }, - - update: function() { - // TODO: Someday maybe update in place? - location.reload(); + if ( + typeof window.addEventListener === "undefined" || + typeof document.hidden === "undefined" + ) { + if (refresh_rate) { + runIntervalId = setInterval(this.update, refresh_rate); } - }; + } else { + window.addEventListener( + "focus", + Dashboard.handleVisibilityChange, + false, + ); + if (refresh_rate) { + runIntervalId = setInterval( + Dashboard.handleVisibilityChange, + refresh_rate, + ); + } + } + }, - return Dashboard; -}(jQuery); + handleVisibilityChange: function () { + if (!document[hidden]) { + Dashboard.update(); + } + }, + + update: function () { + // TODO: Someday maybe update in place? + location.reload(); + }, + }; + + return Dashboard; +})(jQuery); diff --git a/gulpfile.config.js b/gulpfile.config.js index 54e6bab8..0ba2e18f 100644 --- a/gulpfile.config.js +++ b/gulpfile.config.js @@ -1,89 +1,78 @@ -const basePath = 'babybuddy/static/babybuddy/'; +const basePath = "babybuddy/static/babybuddy/"; module.exports = { - basePath: basePath, - extrasConfig: { - fonts: { - dest: basePath + 'font/', - files: 'babybuddy/static_src/fontello/font/*' - }, - images: { - dest: basePath + 'img/', - files: '**/static_src/img/**/*' - }, - logo: { - dest: basePath + 'logo/', - files: 'babybuddy/static_src/logo/**/*' - }, - root: { - dest: basePath + 'root/', - files: 'babybuddy/static_src/root/*' - } + basePath: basePath, + extrasConfig: { + fonts: { + dest: basePath + "font/", + files: "babybuddy/static_src/fontello/font/*", }, - glyphFontConfig: { - configFile: 'babybuddy/static_src/fontello/config.json', - dest: 'babybuddy/static_src/fontello' + images: { + dest: basePath + "img/", + files: "**/static_src/img/**/*", }, - scriptsConfig: { - dest: basePath + 'js/', - vendor: [ - 'node_modules/pulltorefreshjs/dist/index.umd.js', - 'node_modules/jquery/dist/jquery.js', - 'node_modules/@popperjs/core/dist/umd/popper.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', - 'node_modules/masonry-layout/dist/masonry.pkgd.js' - ], - graph: [ - 'node_modules/plotly.js/dist/plotly-cartesian.js', - 'node_modules/plotly.js/dist/plotly-locale-ca.js', - 'node_modules/plotly.js/dist/plotly-locale-cs.js', - 'node_modules/plotly.js/dist/plotly-locale-de.js', - 'node_modules/plotly.js/dist/plotly-locale-da.js', - 'node_modules/plotly.js/dist/plotly-locale-es.js', - 'node_modules/plotly.js/dist/plotly-locale-fi.js', - 'node_modules/plotly.js/dist/plotly-locale-fr.js', - 'node_modules/plotly.js/dist/plotly-locale-hu.js', - 'node_modules/plotly.js/dist/plotly-locale-it.js', - 'node_modules/plotly.js/dist/plotly-locale-no.js', - 'node_modules/plotly.js/dist/plotly-locale-nl.js', - 'node_modules/plotly.js/dist/plotly-locale-pl.js', - 'node_modules/plotly.js/dist/plotly-locale-pt-br.js', - 'node_modules/plotly.js/dist/plotly-locale-pt-pt.js', - 'node_modules/plotly.js/dist/plotly-locale-ru.js', - 'node_modules/plotly.js/dist/plotly-locale-sv.js', - 'node_modules/plotly.js/dist/plotly-locale-tr.js', - 'node_modules/plotly.js/dist/plotly-locale-uk.js', - 'node_modules/plotly.js/dist/plotly-locale-zh-cn.js', - ], - app: [ - 'babybuddy/static_src/js/babybuddy.js', - 'api/static_src/js/*.js', - 'core/static_src/js/*.js', - 'dashboard/static_src/js/*.js' - ], - tags_editor: [ - 'babybuddy/static_src/js/tags_editor.js' - ] + logo: { + dest: basePath + "logo/", + files: "babybuddy/static_src/logo/**/*", }, - stylesConfig: { - dest: basePath + 'css/', - app: 'babybuddy/static_src/scss/babybuddy.scss', - ignore: [ - 'babybuddy.scss' - ] + root: { + dest: basePath + "root/", + files: "babybuddy/static_src/root/*", }, - testsConfig: { - isolated: [ - 'babybuddy.tests.tests_views.ViewsTestCase.test_password_reset' - ], - }, - watchConfig: { - scriptsGlob: [ - '*/static_src/js/**/*.js', - '!babybuddy/static/js/' - ], - stylesGlob: [ - '*/static_src/scss/**/*.scss' - ] - } + }, + glyphFontConfig: { + configFile: "babybuddy/static_src/fontello/config.json", + dest: "babybuddy/static_src/fontello", + }, + scriptsConfig: { + dest: basePath + "js/", + vendor: [ + "node_modules/pulltorefreshjs/dist/index.umd.js", + "node_modules/jquery/dist/jquery.js", + "node_modules/@popperjs/core/dist/umd/popper.js", + "node_modules/bootstrap/dist/js/bootstrap.js", + "node_modules/masonry-layout/dist/masonry.pkgd.js", + ], + graph: [ + "node_modules/plotly.js/dist/plotly-cartesian.js", + "node_modules/plotly.js/dist/plotly-locale-ca.js", + "node_modules/plotly.js/dist/plotly-locale-cs.js", + "node_modules/plotly.js/dist/plotly-locale-de.js", + "node_modules/plotly.js/dist/plotly-locale-da.js", + "node_modules/plotly.js/dist/plotly-locale-es.js", + "node_modules/plotly.js/dist/plotly-locale-fi.js", + "node_modules/plotly.js/dist/plotly-locale-fr.js", + "node_modules/plotly.js/dist/plotly-locale-hu.js", + "node_modules/plotly.js/dist/plotly-locale-it.js", + "node_modules/plotly.js/dist/plotly-locale-no.js", + "node_modules/plotly.js/dist/plotly-locale-nl.js", + "node_modules/plotly.js/dist/plotly-locale-pl.js", + "node_modules/plotly.js/dist/plotly-locale-pt-br.js", + "node_modules/plotly.js/dist/plotly-locale-pt-pt.js", + "node_modules/plotly.js/dist/plotly-locale-ru.js", + "node_modules/plotly.js/dist/plotly-locale-sv.js", + "node_modules/plotly.js/dist/plotly-locale-tr.js", + "node_modules/plotly.js/dist/plotly-locale-uk.js", + "node_modules/plotly.js/dist/plotly-locale-zh-cn.js", + ], + app: [ + "babybuddy/static_src/js/babybuddy.js", + "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: { + dest: basePath + "css/", + app: "babybuddy/static_src/scss/babybuddy.scss", + ignore: ["babybuddy.scss"], + }, + testsConfig: { + isolated: ["babybuddy.tests.tests_views.ViewsTestCase.test_password_reset"], + }, + watchConfig: { + scriptsGlob: ["*/static_src/js/**/*.js", "!babybuddy/static/js/"], + stylesGlob: ["*/static_src/scss/**/*.scss"], + }, }; diff --git a/gulpfile.js b/gulpfile.js index 9f54ef79..29f5eb69 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -345,7 +345,7 @@ gulp.task("runserver", function (cb) { if (parameters[i] === "runserver") { delete parameters[i]; } else if (parameters[i] === "--ip") { - /* "--ip" parameter to set the server IP address. */ + /* "--ip" parameter to set the server IP address. */ command.push(parameters[i + 1]); delete parameters[i]; delete parameters[i + 1];