diff --git a/client/css/forms.styl b/client/css/forms.styl
index ddfeac3..2a13d3c 100644
--- a/client/css/forms.styl
+++ b/client/css/forms.styl
@@ -172,6 +172,11 @@ input[type=password]
&:focus
border-color: $main-color
+ &[readonly]
+ border: 2px solid $input-disabled-border-color
+ background: $input-disabled-background-color
+ color: $input-disabled-text-color
+
label.color
position: relative
input[type=text]
diff --git a/client/html/tag.hbs b/client/html/tag.hbs
new file mode 100644
index 0000000..b97f4e2
--- /dev/null
+++ b/client/html/tag.hbs
@@ -0,0 +1,15 @@
+
+
<%= tag.names[0] %>
+
+
+
diff --git a/client/html/tag_delete.hbs b/client/html/tag_delete.hbs
new file mode 100644
index 0000000..c809da7
--- /dev/null
+++ b/client/html/tag_delete.hbs
@@ -0,0 +1,20 @@
+
diff --git a/client/html/tag_merge.hbs b/client/html/tag_merge.hbs
new file mode 100644
index 0000000..222b649
--- /dev/null
+++ b/client/html/tag_merge.hbs
@@ -0,0 +1,22 @@
+
diff --git a/client/html/tag_summary.hbs b/client/html/tag_summary.hbs
new file mode 100644
index 0000000..edff06f
--- /dev/null
+++ b/client/html/tag_summary.hbs
@@ -0,0 +1,26 @@
+
diff --git a/client/js/controllers/tags_controller.js b/client/js/controllers/tags_controller.js
index b49bffd..8cd261e 100644
--- a/client/js/controllers/tags_controller.js
+++ b/client/js/controllers/tags_controller.js
@@ -2,16 +2,19 @@
const page = require('page');
const api = require('../api.js');
+const tags = require('../tags.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 TagView = require('../views/tag_view.js');
const TagsHeaderView = require('../views/tags_header_view.js');
const TagsPageView = require('../views/tags_page_view.js');
const TagCategoriesView = require('../views/tag_categories_view.js');
class TagsController {
constructor() {
+ this.tagView = new TagView();
this.tagsHeaderView = new TagsHeaderView();
this.tagsPageView = new TagsPageView();
this.tagCategoriesView = new TagCategoriesView();
@@ -19,6 +22,18 @@ class TagsController {
registerRoutes() {
page('/tag-categories', () => { this.tagCategoriesRoute(); });
+ page(
+ '/tag/:name',
+ (ctx, next) => { this.loadTagRoute(ctx, next); },
+ (ctx, next) => { this.showTagRoute(ctx, next); });
+ page(
+ '/tag/:name/merge',
+ (ctx, next) => { this.loadTagRoute(ctx, next); },
+ (ctx, next) => { this.mergeTagRoute(ctx, next); });
+ page(
+ '/tag/:name/delete',
+ (ctx, next) => { this.loadTagRoute(ctx, next); },
+ (ctx, next) => { this.deleteTagRoute(ctx, next); });
page(
'/tags/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
@@ -47,6 +62,94 @@ class TagsController {
});
}
+ loadTagRoute(ctx, next) {
+ if (ctx.state.tag) {
+ next();
+ } else if (this.tag && this.tag.names == ctx.params.names) {
+ ctx.state.tag = this.tag;
+ next();
+ } else {
+ api.get('/tag/' + ctx.params.name).then(response => {
+ ctx.state.tag = response.tag;
+ ctx.save();
+ this.tag = response.tag;
+ next();
+ }, response => {
+ this.emptyView.render();
+ events.notify(events.Error, response.description);
+ });
+ }
+ }
+
+ showTagRoute(ctx, next) {
+ this._show(ctx.state.tag, 'summary');
+ }
+
+ mergeTagRoute(ctx, next) {
+ this._show(ctx.state.tag, 'merge');
+ }
+
+ deleteTagRoute(ctx, next) {
+ this._show(ctx.state.tag, 'delete');
+ }
+
+ _show(tag, section) {
+ topNavController.activate('tags');
+ const categories = {};
+ for (let [key, category] of tags.getExport().categories) {
+ categories[key] = category.name;
+ }
+ this.tagView.render({
+ tag: tag,
+ section: section,
+ canEditNames: api.hasPrivilege('tags:edit:names'),
+ canEditCategory: api.hasPrivilege('tags:edit:category'),
+ canEditImplications: api.hasPrivilege('tags:edit:implications'),
+ canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'),
+ canMerge: api.hasPrivilege('tags:delete'),
+ canDelete: api.hasPrivilege('tags:merge'),
+ categories: categories,
+ save: (...args) => { return this._saveTag(tag, ...args); },
+ mergeTo: (...args) => { return this._mergeTag(tag, ...args); },
+ delete: (...args) => { return this._deleteTag(tag, ...args); },
+ });
+ }
+
+ _saveTag(tag, input) {
+ return api.put('/tag/' + tag.names[0], input).then(response => {
+ events.notify(events.Success, 'Tag saved.');
+ return Promise.resolve();
+ }, response => {
+ events.notify(events.Error, response.description);
+ return Promise.reject();
+ });
+ }
+
+ _mergeTag(tag, targetTagName) {
+ return api.post(
+ '/tag-merge/',
+ {remove: tag.names[0], mergeTo: targetTagName}
+ ).then(response => {
+ page('/tag/' + targetTagName + '/merge');
+ events.notify(events.Success, 'Tag merged.');
+ return Promise.resolve();
+ }, response => {
+ events.notify(events.Error, response.description);
+ return Promise.reject();
+ });
+ }
+
+ _deleteTag(tag) {
+ return api.delete('/tag/' + tag.names[0]).then(response => {
+ page('/tags/');
+ events.notify(events.Success, 'Tag deleted.');
+ return Promise.resolve();
+ }, response => {
+ events.notify(events.Error, response.description);
+ return Promise.reject();
+ });
+ }
+
tagCategoriesRoute(ctx, next) {
topNavController.activate('tags');
api.get('/tag-categories/').then(response => {
diff --git a/client/js/tags.js b/client/js/tags.js
index 3098104..ceaa85a 100644
--- a/client/js/tags.js
+++ b/client/js/tags.js
@@ -7,22 +7,22 @@ const events = require('./events.js');
let _export = null;
let _stylesheet = null;
-function _tagsToDictionary(tags) {
- let dict = {};
+function _tagsToMap(tags) {
+ let map = new Map();
for (let tag of tags) {
for (let name of tag.names) {
- dict[name] = tag;
+ map.set(name, tag);
}
}
- return dict;
+ return map;
}
-function _tagCategoriesToDictionary(categories) {
- let dict = {};
+function _tagCategoriesToMap(categories) {
+ let map = new Map();
for (let category of categories) {
- dict[category.name] = category;
+ map.set(category.name, category);
}
- return dict;
+ return map;
}
function _refreshStylesheet() {
@@ -31,7 +31,7 @@ function _refreshStylesheet() {
}
_stylesheet = document.createElement('style');
document.head.appendChild(_stylesheet);
- for (let category of Object.values(_export.categories)) {
+ for (let category of _export.categories.values()) {
_stylesheet.sheet.insertRule(
'.tag-{0} { color: {1} }'.format(category.name, category.color),
_stylesheet.sheet.cssRules.length);
@@ -42,14 +42,12 @@ 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);
+ _export.tags = _tagsToMap(_export.tags);
+ _export.categories = _tagCategoriesToMap(_export.categories);
_refreshStylesheet();
resolve();
});
diff --git a/client/js/util/views.js b/client/js/util/views.js
index 5f61a49..b4eb573 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -7,6 +7,12 @@ const events = require('../events.js');
const domParser = new DOMParser();
const misc = require('./misc.js');
+function _imbueId(options) {
+ if (!options.id) {
+ options.id = 'gen-' + Math.random().toString(36).substring(7);
+ }
+}
+
function _makeLabel(options, attrs) {
if (!options.text) {
return '';
@@ -36,6 +42,7 @@ function makeThumbnail(url) {
}
function makeRadio(options) {
+ _imbueId(options);
return makeVoidElement(
'input',
{
@@ -50,6 +57,7 @@ function makeRadio(options) {
}
function makeCheckbox(options) {
+ _imbueId(options);
return makeVoidElement(
'input',
{
@@ -68,7 +76,11 @@ function makeSelect(options) {
return _makeLabel(options) +
makeNonVoidElement(
'select',
- {id: options.id, name: options.name},
+ {
+ id: options.id,
+ name: options.name,
+ disabled: options.readonly,
+ },
Object.keys(options.keyValues).map(key => {
return makeNonVoidElement(
'option',
@@ -88,6 +100,7 @@ function makeInput(options) {
required: options.required,
pattern: options.pattern,
placeholder: options.placeholder,
+ readonly: options.readonly,
});
}
@@ -128,7 +141,7 @@ function makeTagLink(name) {
const tagExport = tags.getExport();
let category = null;
try {
- category = tagExport.tags[name].category;
+ category = tagExport.tags.get(name).category;
} catch (e) {
category = 'unknown';
}
diff --git a/client/js/views/tag_delete_view.js b/client/js/views/tag_delete_view.js
new file mode 100644
index 0000000..e3771d3
--- /dev/null
+++ b/client/js/views/tag_delete_view.js
@@ -0,0 +1,31 @@
+'use strict';
+
+const views = require('../util/views.js');
+
+class TagDeleteView {
+ constructor() {
+ this.template = views.getTemplate('tag-delete');
+ }
+
+ render(ctx) {
+ const target = ctx.target;
+ const source = this.template(ctx);
+
+ const form = source.querySelector('form');
+
+ views.decorateValidator(form);
+
+ form.addEventListener('submit', e => {
+ e.preventDefault();
+ views.clearMessages(target);
+ views.disableForm(form);
+ ctx.delete(ctx.tag)
+ .catch(() => { views.enableForm(form); });
+ });
+
+ views.listenToMessages(source);
+ views.showView(target, source);
+ }
+}
+
+module.exports = TagDeleteView;
diff --git a/client/js/views/tag_merge_view.js b/client/js/views/tag_merge_view.js
new file mode 100644
index 0000000..2064680
--- /dev/null
+++ b/client/js/views/tag_merge_view.js
@@ -0,0 +1,36 @@
+'use strict';
+
+const config = require('../config.js');
+const views = require('../util/views.js');
+
+class TagMergeView {
+ constructor() {
+ this.template = views.getTemplate('tag-merge');
+ }
+
+ render(ctx) {
+ ctx.tagNamePattern = config.tagNameRegex;
+
+ const target = ctx.target;
+ const source = this.template(ctx);
+
+ const form = source.querySelector('form');
+
+ views.decorateValidator(form);
+
+ form.addEventListener('submit', e => {
+ const otherTagField = source.querySelector('.target input');
+
+ e.preventDefault();
+ views.clearMessages(target);
+ views.disableForm(form);
+ ctx.mergeTo(otherTagField.value)
+ .catch(() => { views.enableForm(form); });
+ });
+
+ views.listenToMessages(source);
+ views.showView(target, source);
+ }
+}
+
+module.exports = TagMergeView;
diff --git a/client/js/views/tag_summary_view.js b/client/js/views/tag_summary_view.js
new file mode 100644
index 0000000..1b73c74
--- /dev/null
+++ b/client/js/views/tag_summary_view.js
@@ -0,0 +1,49 @@
+'use strict';
+
+const config = require('../config.js');
+const views = require('../util/views.js');
+
+function split(str) {
+ return str.split(/\s+/).filter(s => s);
+}
+
+class TagSummaryView {
+ constructor() {
+ this.template = views.getTemplate('tag-summary');
+ }
+
+ render(ctx) {
+ const baseRegex = config.tagNameRegex.replace(/[\^\$]/g, '');
+ ctx.tagNamesPattern = '^((' + baseRegex + ')\\s+)*(' + baseRegex + ')$';
+
+ const target = ctx.target;
+ const source = this.template(ctx);
+
+ const form = source.querySelector('form');
+
+ views.decorateValidator(form);
+
+ form.addEventListener('submit', e => {
+ const namesField = source.querySelector('.names input');
+ const categoryField = source.querySelector('.category select');
+ const implicationsField =
+ source.querySelector('.implications input');
+ const suggestionsField = source.querySelector('.suggestions input');
+
+ e.preventDefault();
+ views.clearMessages(target);
+ views.disableForm(form);
+ ctx.save({
+ names: split(namesField.value),
+ category: categoryField.value,
+ implications: split(implicationsField.value),
+ suggestions: split(suggestionsField.value),
+ }).always(() => { views.enableForm(form); });
+ });
+
+ views.listenToMessages(source);
+ views.showView(target, source);
+ }
+}
+
+module.exports = TagSummaryView;
diff --git a/client/js/views/tag_view.js b/client/js/views/tag_view.js
new file mode 100644
index 0000000..e022a87
--- /dev/null
+++ b/client/js/views/tag_view.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const views = require('../util/views.js');
+const TagSummaryView = require('./tag_summary_view.js');
+const TagMergeView = require('./tag_merge_view.js');
+const TagDeleteView = require('./tag_delete_view.js');
+
+class TagView {
+ constructor() {
+ this.template = views.getTemplate('tag');
+ this.summaryView = new TagSummaryView();
+ this.mergeView = new TagMergeView();
+ this.deleteView = new TagDeleteView();
+ }
+
+ render(ctx) {
+ const target = document.getElementById('content-holder');
+ const source = this.template(ctx);
+
+ ctx.section = ctx.section || 'summary';
+
+ for (let item of source.querySelectorAll('[data-name]')) {
+ if (item.getAttribute('data-name') === ctx.section) {
+ item.className = 'active';
+ } else {
+ item.className = '';
+ }
+ }
+
+ let view = null;
+ if (ctx.section == 'merge') {
+ view = this.mergeView;
+ } else if (ctx.section == 'delete') {
+ view = this.deleteView;
+ } else {
+ view = this.summaryView;
+ }
+ ctx.target = source.querySelector('.tag-content-holder');
+ view.render(ctx);
+
+ views.listenToMessages(source);
+ views.showView(target, source);
+ }
+}
+
+module.exports = TagView;
+