client/tags: add tag category editing
This commit is contained in:
parent
be0a70355d
commit
27cce56054
12 changed files with 412 additions and 20 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
76
client/html/tag_categories.hbs
Normal file
76
client/html/tag_categories.hbs
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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 { %>
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ module.exports = {
|
|||
Info: 3,
|
||||
Authentication: 4,
|
||||
SettingsChange: 5,
|
||||
TagsChange: 6,
|
||||
|
||||
notify: notify,
|
||||
listen: listen,
|
||||
|
|
|
@ -27,13 +27,15 @@ 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(() => {
|
||||
Promise.all([tags.refreshExport(), api.loginFromCookies()])
|
||||
.then(() => {
|
||||
page();
|
||||
}).catch(errorMessage => {
|
||||
if (window.location.href.indexOf('login') !== -1) {
|
||||
|
|
70
client/js/tags.js
Normal file
70
client/js/tags.js
Normal 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,
|
||||
};
|
|
@ -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');
|
||||
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,
|
||||
|
|
109
client/js/views/tag_categories_view.js
Normal file
109
client/js/views/tag_categories_view.js
Normal 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;
|
Loading…
Reference in a new issue