client/tags: add tag category editing

This commit is contained in:
rr- 2016-05-10 10:57:59 +02:00
parent be0a70355d
commit 27cce56054
12 changed files with 412 additions and 20 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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 { %>

View file

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

View file

@ -28,6 +28,7 @@ module.exports = {
Info: 3,
Authentication: 4,
SettingsChange: 5,
TagsChange: 6,
notify: notify,
listen: listen,

View file

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

70
client/js/tags.js Normal file
View file

@ -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,
};

View file

@ -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,

View file

@ -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;