From 27cce560549f9638dee64426aeb4e8033b73d529 Mon Sep 17 00:00:00 2001
From: rr- <rr-@sakuya.pl>
Date: Tue, 10 May 2016 10:57:59 +0200
Subject: [PATCH] client/tags: add tag category editing

---
 client/css/forms.styl                    |   9 ++
 client/css/main.styl                     |   2 +
 client/css/tags.styl                     |  20 +++++
 client/html/tag_categories.hbs           |  76 ++++++++++++++++
 client/html/tag_list_header.hbs          |   5 +-
 client/html/tag_list_page.hbs            |   6 +-
 client/js/controllers/tags_controller.js |  53 +++++++++++
 client/js/events.js                      |   1 +
 client/js/main.js                        |  26 +++---
 client/js/tags.js                        |  70 +++++++++++++++
 client/js/util/views.js                  |  55 +++++++++++-
 client/js/views/tag_categories_view.js   | 109 +++++++++++++++++++++++
 12 files changed, 412 insertions(+), 20 deletions(-)
 create mode 100644 client/html/tag_categories.hbs
 create mode 100644 client/js/tags.js
 create mode 100644 client/js/views/tag_categories_view.js

diff --git a/client/css/forms.styl b/client/css/forms.styl
index 404aaf6..ddfeac3 100644
--- a/client/css/forms.styl
+++ b/client/css/forms.styl
@@ -172,6 +172,15 @@ input[type=password]
     &:focus
         border-color: $main-color
 
+label.color
+    position: relative
+    input[type=text]
+        text-align: center
+        pointer-events: none
+    input[type=color]
+        position: absolute
+        opacity: 0
+
 form.show-validation .input
     input:invalid
         outline: none
diff --git a/client/css/main.styl b/client/css/main.styl
index ef57300..b27340f 100644
--- a/client/css/main.styl
+++ b/client/css/main.styl
@@ -19,6 +19,8 @@ a
     color: $main-color
     text-decoration: none
     transition: color 0.1s linear
+    &.inactive
+        color: $inactive-link-color
     &.icon
         color: $inactive-link-color
         opacity: .5
diff --git a/client/css/tags.styl b/client/css/tags.styl
index cfe750b..5f4af2e 100644
--- a/client/css/tags.styl
+++ b/client/css/tags.styl
@@ -38,3 +38,23 @@
     .append
         font-size: 0.95em
         color: $inactive-link-color
+
+.tag-categories
+    td, th
+        padding: .2em
+        &:first-child
+            padding-left: 0
+        &:last-child
+            padding-right: 0
+        &.name
+            width: 12em
+        &.color
+            text-align: center
+            width: 5em
+        &.usages
+            text-align: center
+            width: 5em
+    tfoot
+        display: none
+    .messages, form
+        width: auto
diff --git a/client/html/tag_categories.hbs b/client/html/tag_categories.hbs
new file mode 100644
index 0000000..75a00bf
--- /dev/null
+++ b/client/html/tag_categories.hbs
@@ -0,0 +1,76 @@
+<div class='content-wrapper tag-categories'>
+    <form>
+        <h1>Tag categories</h1>
+        <table>
+            <thead>
+                <tr>
+                    <th class='name'>Category name</th>
+                    <th class='color'>CSS color</th>
+                    <th class='usages'>Usages</th>
+                </tr>
+            </thead>
+            <tbody>
+                <% _.each(tagCategories, category => { %>
+                    <tr data-category='<%= category.name %>'>
+                        <td class='name'>
+                            <% if (canEditName) { %>
+                                <%= makeTextInput({value: category.name, required: true}) %>
+                            <% } else { %>
+                                <%= category.name %>
+                            <% } %>
+                        </td>
+                        <td class='color'>
+                            <% if (canEditColor) { %>
+                                <%= makeColorInput({value: category.color}) %>
+                            <% } else { %>
+                                <%= category.color %>
+                            <% } %>
+                        </td>
+                        <td class='usages'>
+                            <a href='/tags/text=category:<%= category.name %>'>
+                                <%= category.usages %>
+                            </a>
+                        </td>
+                        <% if (canDelete) { %>
+                            <td>
+                                <% if (category.usages) { %>
+                                    <a class='inactive remove' title="Can't delete category in use">Remove</a>
+                                <% } else { %>
+                                    <a href='#' class='remove'>Remove</a>
+                                <% } %>
+                            </td>
+                        <% } %>
+                    </tr>
+                <% }) %>
+            </tbody>
+            <tfoot>
+                <tr class='add-template'>
+                    <td class='name'>
+                        <%= makeTextInput({required: true}) %>
+                    </td>
+                    <td class='color'>
+                        <%= makeColorInput({value: '#000000'}) %>
+                    </td>
+                    <td class='usages'>
+                        0
+                    </td>
+                    <td>
+                        <a href='#' class='remove'>Remove</a>
+                    </td>
+                </tr>
+            </tfoot>
+        </table>
+
+        <% if (canCreate) { %>
+            <p><a href='#' class='add'>Add new category</a></p>
+        <% } %>
+
+        <div class='messages'></div>
+
+        <% if (canCreate || canEditName || canEditColor || canDelete) { %>
+            <div class='buttons'>
+                <input type='submit' class='save' value='Save changes'>
+            </div>
+        <% } %>
+    </form>
+</div>
diff --git a/client/html/tag_list_header.hbs b/client/html/tag_list_header.hbs
index 49746ce..76c3497 100644
--- a/client/html/tag_list_header.hbs
+++ b/client/html/tag_list_header.hbs
@@ -9,7 +9,10 @@
         </div>
         <div class='buttons'>
             <input type='submit' value='Search'/>
-            <a class='append' href='/help/search/tags'>Syntax help</a>
+            <a class='button append' href='/help/search/tags'>Syntax help</a>
+            <% if (canEditTagCategories) { %>
+                <a class='append' href='/tag-categories'>Tag categories</a>
+            <% } %>
         </div>
     </form>
 </div>
diff --git a/client/html/tag_list_page.hbs b/client/html/tag_list_page.hbs
index b9cd21c..2243866 100644
--- a/client/html/tag_list_page.hbs
+++ b/client/html/tag_list_page.hbs
@@ -13,7 +13,7 @@
                         <td class='names'>
                             <ul>
                                 <% _.each(tag.names, name => { %>
-                                    <li><a href='/tag/<%= name %>'><%= name %></a></li>
+                                    <li><%= makeTagLink(name) %></li>
                                 <% }) %>
                             </ul>
                         </td>
@@ -21,7 +21,7 @@
                             <% if (tag.implications.length) { %>
                                 <ul>
                                     <% _.each(tag.implications, name => { %>
-                                        <li><a href='/tag/<%= name %>'><%= name %></a></li>
+                                        <li><%= makeTagLink(name) %></li>
                                     <% }) %>
                                 </ul>
                             <% } else { %>
@@ -32,7 +32,7 @@
                             <% if (tag.suggestions.length) { %>
                                 <ul>
                                     <% _.each(tag.suggestions, name => { %>
-                                        <li><a href='/tag/<%= name %>'><%= name %></a></li>
+                                        <li><%= makeTagLink(name) %></li>
                                     <% }) %>
                                 </ul>
                             <% } else { %>
diff --git a/client/js/controllers/tags_controller.js b/client/js/controllers/tags_controller.js
index e799214..0ebe0da 100644
--- a/client/js/controllers/tags_controller.js
+++ b/client/js/controllers/tags_controller.js
@@ -2,25 +2,77 @@
 
 const page = require('page');
 const api = require('../api.js');
+const events = require('../events.js');
 const misc = require('../util/misc.js');
 const topNavController = require('../controllers/top_nav_controller.js');
 const pageController = require('../controllers/page_controller.js');
 const TagListHeaderView = require('../views/tag_list_header_view.js');
 const TagListPageView = require('../views/tag_list_page_view.js');
+const TagCategoriesView = require('../views/tag_categories_view.js');
 
 class TagsController {
     constructor() {
         this.tagListHeaderView = new TagListHeaderView();
         this.tagListPageView = new TagListPageView();
+        this.tagCategoriesView = new TagCategoriesView();
     }
 
     registerRoutes() {
+        page('/tag-categories', () => { this.tagCategoriesRoute(); });
         page(
             '/tags/:query?',
             (ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
             (ctx, next) => { this.listTagsRoute(ctx, next); });
     }
 
+    _saveTagCategories(addedCategories, changedCategories, removedCategories) {
+        let promises = [];
+        for (let category of addedCategories) {
+            promises.push(api.post('/tag-categories/', category));
+        }
+        for (let category of changedCategories) {
+            promises.push(
+                api.put('/tag-category/' + category.originalName, category));
+        }
+        for (let name of removedCategories) {
+            promises.push(api.delete('/tag-category/' + name));
+        }
+        Promise.all(promises).then(
+            () => {
+                events.notify(events.TagsChange);
+                events.notify(events.Success, 'Changes saved successfully');
+            },
+            response => {
+                events.notify(events.Error, response.description);
+            });
+    }
+
+    tagCategoriesRoute(ctx, next) {
+        topNavController.activate('tags');
+        api.get('/tag-categories/').then(response => {
+            this.tagCategoriesView.render({
+                tagCategories: response.results,
+                canEditName: api.hasPrivilege('tagCategories:edit:name'),
+                canEditColor: api.hasPrivilege('tagCategories:edit:color'),
+                canDelete: api.hasPrivilege('tagCategories:delete'),
+                canCreate: api.hasPrivilege('tagCategories:create'),
+                saveChanges: (...args) => {
+                    return this._saveTagCategories(...args);
+                },
+                getCategories: () => {
+                    return api.get('/tag-categories/').then(response => {
+                        return Promise.resolve(response.results);
+                    }, response => {
+                        return Promise.reject(response);
+                    });
+                }
+            });
+        }, response => {
+            this.emptyView.render();
+            events.notify(events.Error, response.description);
+        });
+    }
+
     listTagsRoute(ctx, next) {
         topNavController.activate('tags');
 
@@ -37,6 +89,7 @@ class TagsController {
             searchQuery: ctx.searchQuery,
             headerRenderer: this.tagListHeaderView,
             pageRenderer: this.tagListPageView,
+            canEditTagCategories: api.hasPrivilege('tagCategories:edit'),
         });
     }
 }
diff --git a/client/js/events.js b/client/js/events.js
index 16f4497..0493c3b 100644
--- a/client/js/events.js
+++ b/client/js/events.js
@@ -28,6 +28,7 @@ module.exports = {
     Info: 3,
     Authentication: 4,
     SettingsChange: 5,
+    TagsChange: 6,
 
     notify: notify,
     listen: listen,
diff --git a/client/js/main.js b/client/js/main.js
index c9e0747..784db03 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -27,22 +27,24 @@ controllers.push(require('./controllers/settings_controller.js'));
 
 controllers.push(require('./controllers/home_controller.js'));
 
+const tags = require('./tags.js');
 const events = require('./events.js');
 for (let controller of controllers) {
     controller.registerRoutes();
 }
 
 const api = require('./api.js');
-api.loginFromCookies().then(() => {
-    page();
-}).catch(errorMessage => {
-    if (window.location.href.indexOf('login') !== -1) {
-        api.forget();
+Promise.all([tags.refreshExport(), api.loginFromCookies()])
+    .then(() => {
         page();
-    } else {
-        page('/');
-        events.notify(
-            events.Error,
-            'An error happened while trying to log you in: ' + errorMessage);
-    }
-});
+    }).catch(errorMessage => {
+        if (window.location.href.indexOf('login') !== -1) {
+            api.forget();
+            page();
+        } else {
+            page('/');
+            events.notify(
+                events.Error,
+                'An error happened while trying to log you in: ' + errorMessage);
+        }
+    });
diff --git a/client/js/tags.js b/client/js/tags.js
new file mode 100644
index 0000000..49f70a2
--- /dev/null
+++ b/client/js/tags.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const request = require('superagent');
+const util = require('./util/misc.js');
+const events = require('./events.js');
+
+let _export = null;
+let _stylesheet = null;
+
+function _tagsToDictionary(tags)
+{
+    let dict = {};
+    for (let tag of tags) {
+        for (let name of tag.names) {
+            dict[name] = tag;
+        }
+    }
+    return dict;
+}
+
+function _tagCategoriesToDictionary(categories)
+{
+    let dict = {};
+    for (let category of categories) {
+        dict[category.name] = category;
+    }
+    return dict;
+}
+
+function _refreshStylesheet() {
+    if (_stylesheet) {
+        document.head.removeChild(_stylesheet);
+    }
+    _stylesheet = document.createElement('style');
+    document.head.appendChild(_stylesheet);
+    for (let category of Object.values(_export.categories)) {
+        _stylesheet.sheet.insertRule(
+            '.tag-{0} { color: {1} }'.format(category.name, category.color),
+            _stylesheet.sheet.cssRules.length);
+    }
+}
+
+function refreshExport() {
+    return new Promise((resolve, reject) => {
+        request.get('/data/tags.json').end((error, response) => {
+            if (error) {
+                console.log('Error while fetching exported tags', error);
+                _export = {tags: {}, categories: {}};
+                reject(error);
+            }
+            _export = response.body;
+            _export.tags = _tagsToDictionary(_export.tags);
+            _export.categories = _tagCategoriesToDictionary(
+                _export.categories);
+            _refreshStylesheet();
+            resolve();
+        });
+    });
+}
+
+function getExport() {
+    return _export || {};
+}
+
+events.listen(events.TagsChange, refreshExport);
+
+module.exports = {
+    getExport: getExport,
+    refreshExport: refreshExport,
+};
diff --git a/client/js/util/views.js b/client/js/util/views.js
index 09f9793..51b079c 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -2,6 +2,7 @@
 
 require('../util/polyfill.js');
 const underscore = require('underscore');
+const tags = require('../tags.js');
 const events = require('../events.js');
 const domParser = new DOMParser();
 const misc = require('./misc.js');
@@ -105,6 +106,37 @@ function makeEmailInput(options) {
     return makeInput(options);
 }
 
+function makeColorInput(options) {
+    const textInput = makeVoidElement(
+        'input', {
+            type: 'text',
+            value: options.value || '',
+            required: options.required,
+            style: 'color: ' + options.value,
+            disabled: true,
+        });
+    const colorInput = makeVoidElement(
+        'input', {
+            type: 'color',
+            value: options.value || '',
+        });
+    return makeNonVoidElement('label', {class: 'color'}, colorInput + textInput);
+}
+
+function makeTagLink(name) {
+    const tagExport = tags.getExport();
+    let category = null;
+    try {
+        category = tagExport.tags[name].category;
+    } catch (e) {
+        category = 'unknown';
+    }
+    return makeNonVoidElement('a', {
+        'href': '/tag/' + name,
+        'class': 'tag-' + category,
+    }, name);
+}
+
 function makeFlexboxAlign(options) {
     return Array.from(misc.range(20))
         .map(() => '<li class="flexbox-dummy"></li>').join('');
@@ -201,6 +233,8 @@ function getTemplate(templatePath) {
             makeTextInput: makeTextInput,
             makePasswordInput: makePasswordInput,
             makeEmailInput: makeEmailInput,
+            makeColorInput: makeColorInput,
+            makeTagLink: makeTagLink,
             makeFlexboxAlign: makeFlexboxAlign,
         });
         return htmlToDom(templateFactory(ctx));
@@ -210,10 +244,15 @@ function getTemplate(templatePath) {
 function decorateValidator(form) {
     // postpone showing form fields validity until user actually tries
     // to submit it (seeing red/green form w/o doing anything breaks POLA)
-    const submitButton = form.querySelector('.buttons input');
-    submitButton.addEventListener('click', e => {
-        form.classList.add('show-validation');
-    });
+    let submitButton = form.querySelector('.buttons input');
+    if (!submitButton) {
+        submitButton = form.querySelector('input[type=submit]');
+    }
+    if (submitButton) {
+        submitButton.addEventListener('click', e => {
+            form.classList.add('show-validation');
+        });
+    }
     form.addEventListener('submit', e => {
         form.classList.remove('show-validation');
     });
@@ -259,6 +298,14 @@ function scrollToHash() {
     }, 10);
 }
 
+document.addEventListener('input', e => {
+    if (e.target.getAttribute('type').toLowerCase() === 'color') {
+        const textInput = e.target.parentNode.querySelector('input[type=text]');
+        textInput.style.color = e.target.value;
+        textInput.value = e.target.value;
+    }
+});
+
 module.exports = {
     htmlToDom: htmlToDom,
     getTemplate: getTemplate,
diff --git a/client/js/views/tag_categories_view.js b/client/js/views/tag_categories_view.js
new file mode 100644
index 0000000..93a6bb1
--- /dev/null
+++ b/client/js/views/tag_categories_view.js
@@ -0,0 +1,109 @@
+'use strict';
+
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+
+class TagListHeaderView {
+    constructor() {
+        this.template = views.getTemplate('tag-categories');
+    }
+
+    _saveButtonClickHandler(e, ctx, target) {
+        e.preventDefault();
+
+        views.clearMessages(target);
+        const tableBody = target.querySelector('tbody');
+
+        ctx.getCategories().then(categories => {
+            let existingCategories = {};
+            for (let category of categories) {
+                existingCategories[category.name] = category;
+            }
+
+            let addedCategories = [];
+            let removedCategories = [];
+            let changedCategories = [];
+            let allNames = [];
+            for (let row of tableBody.querySelectorAll('tr')) {
+                let name = row.getAttribute('data-category');
+                let category = {
+                    originalName: name,
+                    name: row.querySelector('.name input').value,
+                    color: row.querySelector('.color input').value,
+                };
+                if (!name) {
+                    if (category.name) {
+                        addedCategories.push(category);
+                    }
+                } else {
+                    const existingCategory = existingCategories[name];
+                    if (existingCategory.color !== category.color
+                    || existingCategory.name !== category.name) {
+                        changedCategories.push(category);
+                    }
+                }
+                allNames.push(name);
+            }
+            for (let name of Object.keys(existingCategories)) {
+                if (allNames.indexOf(name) === -1) {
+                    removedCategories.push(name);
+                }
+            }
+            ctx.saveChanges(
+                addedCategories, changedCategories, removedCategories);
+        });
+    }
+
+    _removeButtonClickHandler(e, row, link) {
+        e.preventDefault();
+        if (link.classList.contains('inactive')) {
+            return;
+        }
+        row.parentNode.removeChild(row);
+    }
+
+    _addRemoveButtonClickHandler(row) {
+        const link = row.querySelector('a.remove');
+        if (!link) {
+            return;
+        }
+        link.addEventListener(
+            'click', e => this._removeButtonClickHandler(e, row, link));
+    }
+
+    render(ctx) {
+        const target = document.getElementById('content-holder');
+        const source = this.template(ctx);
+
+        const form = source.querySelector('form');
+        const newRowTemplate = source.querySelector('.add-template');
+        const tableBody = source.querySelector('tbody');
+        const addLink = source.querySelector('a.add');
+        const saveButton = source.querySelector('button.save');
+
+        newRowTemplate.parentNode.removeChild(newRowTemplate);
+        views.decorateValidator(form);
+
+        for (let row of tableBody.querySelectorAll('tr')) {
+            this._addRemoveButtonClickHandler(row);
+        }
+
+        if (addLink) {
+            addLink.addEventListener('click', e => {
+                e.preventDefault();
+                let newRow = newRowTemplate.cloneNode(true);
+                tableBody.appendChild(newRow);
+                this._addRemoveButtonClickHandler(newRow);
+            });
+        }
+
+        form.addEventListener('submit', e => {
+            this._saveButtonClickHandler(e, ctx, target);
+        });
+
+        views.listenToMessages(target);
+        views.showView(target, source);
+    }
+}
+
+module.exports = TagListHeaderView;