diff --git a/app/helpers/knowledgebase_helper.rb b/app/helpers/knowledgebase_helper.rb index a808ee2..4b18ae5 100644 --- a/app/helpers/knowledgebase_helper.rb +++ b/app/helpers/knowledgebase_helper.rb @@ -49,6 +49,10 @@ def sort_categories? def show_category_totals? redmine_knowledgebase_settings_value(:show_category_totals) end + + def show_article_tree_lists? + redmine_knowledgebase_settings_value(:show_article_tree_lists) + end def updated_by(updated, updater) l(:label_updated_who, :updater => link_to_user(updater), :age => time_tag(updated)).html_safe diff --git a/app/views/articles/_article_tree.erb b/app/views/articles/_article_tree.erb new file mode 100644 index 0000000..c2aedc8 --- /dev/null +++ b/app/views/articles/_article_tree.erb @@ -0,0 +1,54 @@ +
+ <%= l(:label_expand_all) %> + <%= l(:label_collapse_all) %> +
+ +
+ + \ No newline at end of file diff --git a/app/views/articles/_article_tree_row.erb b/app/views/articles/_article_tree_row.erb new file mode 100644 index 0000000..8cee77c --- /dev/null +++ b/app/views/articles/_article_tree_row.erb @@ -0,0 +1,38 @@ +node<%= category.id %> = new TreeNode('<%= link_to(category.title, {:controller => 'categories', :action => 'show', :id => category.id, :project_id => @project} ) %>', + { + icon: expandIcon + } +); + +node<%= category.id %>.on('expand', function(node){ + node.setOptions({ + icon: expandIcon + }); +}); + +node<%= category.id %>.on('collapse', function(node){ + node.setOptions({ + icon: collapseIcon + }); +}); + +<%= parent_node_id %>.addChild(node<%= category.id %>); + +<% subs = category.children %> +<% subs = subs.sort_by(&:title) if sort_categories? %> +<% subs.each do |subcat| %> + <%= render :partial => 'articles/article_tree_row', :locals => { :category => subcat, :parent_node_id => "node#{category.id}" } %> +<% end %> + +<% arts = category.articles %> +<% arts = arts.sort_by(&:title) if sort_categories? %> +<% arts.each do |article| %> + <% truncate_length = 100 if local_assigns[:truncate_length].nil? %> + + node<%= category.id %>.addChild(new TreeNode('<%= link_to truncate(article.title, :length => truncate_length), { :controller => 'articles', :action => 'show', :id => article.id, :project_id => @project}, :title => article.title %>', + { + icon: '' + } + )); +<% end %> + diff --git a/app/views/articles/_index_original.html.erb b/app/views/articles/_index_original.html.erb index 53a9c49..4ab48b2 100644 --- a/app/views/articles/_index_original.html.erb +++ b/app/views/articles/_index_original.html.erb @@ -12,6 +12,14 @@ <% end %> + + <% if show_article_tree_lists? %> +
+

<%= l(:title_article_tree_lists) %>

+ <%= render :partial => 'articles/article_tree' %> +
+ <% end %> + <% if User.current.allowed_to?(:view_recent_articles, @project) %>

<%= l(:title_recently_updated_articles) %>

diff --git a/app/views/redmine_knowledgebase/_knowledgebase_settings.html.erb b/app/views/redmine_knowledgebase/_knowledgebase_settings.html.erb index 95c802a..e9bbc98 100644 --- a/app/views/redmine_knowledgebase/_knowledgebase_settings.html.erb +++ b/app/views/redmine_knowledgebase/_knowledgebase_settings.html.erb @@ -45,6 +45,11 @@ <%= check_box_tag 'settings[show_tiled_article_lists]', 1, @settings[:show_tiled_article_lists] %>

+

+ <%= label_tag :settings_show_category_tree_lists, l(:show_article_tree_lists) %> + <%= check_box_tag 'settings[show_article_tree_lists]', 1, @settings[:show_article_tree_lists] %> +

+

<%= label_tag :settings_critical_tags, l(:critical_tags) %> <%= text_field_tag "settings[critical_tags]", @settings[:critical_tags] %> diff --git a/assets/images/article_tree_document.png b/assets/images/article_tree_document.png new file mode 100644 index 0000000..9718473 Binary files /dev/null and b/assets/images/article_tree_document.png differ diff --git a/assets/images/article_tree_folder_expanded.png b/assets/images/article_tree_folder_expanded.png new file mode 100644 index 0000000..4b2c946 Binary files /dev/null and b/assets/images/article_tree_folder_expanded.png differ diff --git a/assets/images/article_tree_folder_folded.png b/assets/images/article_tree_folder_folded.png new file mode 100644 index 0000000..bb5fedc Binary files /dev/null and b/assets/images/article_tree_folder_folded.png differ diff --git a/assets/javascripts/tree.js b/assets/javascripts/tree.js new file mode 100644 index 0000000..254d838 --- /dev/null +++ b/assets/javascripts/tree.js @@ -0,0 +1,663 @@ +/** +* TreeJS is a JavaScript librarie for displaying TreeViews +* on the web. +* +* @author Matthias Thalmann +*/ + +function TreeView(root, container, options){ + var self = this; + + /* + * Konstruktor + */ + if(typeof root === "undefined"){ + throw new Error("Parameter 1 must be set (root)"); + } + + if(!(root instanceof TreeNode)){ + throw new Error("Parameter 1 must be of type TreeNode"); + } + + if(container){ + if(!TreeUtil.isDOM(container)){ + container = document.querySelector(container); + + if(container instanceof Array){ + container = container[0]; + } + + if(!TreeUtil.isDOM(container)){ + throw new Error("Parameter 2 must be either DOM-Object or CSS-QuerySelector (#, .)"); + } + } + }else{ + container = null; + } + + if(!options || typeof options !== "object"){ + options = {}; + } + + /* + * Methods + */ + this.setRoot = function(_root){ + if(root instanceof TreeNode){ + root = _root; + } + } + + this.getRoot = function(){ + return root; + } + + this.expandAllNodes = function(){ + root.setExpanded(true); + + root.getChildren().forEach(function(child){ + TreeUtil.expandNode(child); + }); + } + + this.expandPath = function(path){ + if(!(path instanceof TreePath)){ + throw new Error("Parameter 1 must be of type TreePath"); + } + + path.getPath().forEach(function(node){ + node.setExpanded(true); + }); + } + + this.collapseAllNodes = function(){ + root.setExpanded(false); + + root.getChildren().forEach(function(child){ + TreeUtil.collapseNode(child); + }); + } + + this.setContainer = function(_container){ + if(TreeUtil.isDOM(_container)){ + container = _container; + }else{ + _container = document.querySelector(_container); + + if(_container instanceof Array){ + _container = _container[0]; + } + + if(!TreeUtil.isDOM(_container)){ + throw new Error("Parameter 1 must be either DOM-Object or CSS-QuerySelector (#, .)"); + } + } + } + + this.getContainer = function(){ + return container; + } + + this.setOptions = function(_options){ + if(typeof _options === "object"){ + options = _options; + } + } + + this.changeOption = function(option, value){ + options[option] = value; + } + + this.getOptions = function(){ + return options; + } + + // TODO: set selected key: up down; expand right; collapse left; enter: open; + this.getSelectedNodes = function(){ + return TreeUtil.getSelectedNodesForNode(root); + } + + this.reload = function(){ + if(container == null){ + console.warn("No container specified"); + return; + } + + container.classList.add("tj_container"); + + var cnt = document.createElement("ul"); + cnt.appendChild(renderNode(root)); + + container.innerHTML = ""; + container.appendChild(cnt); + } + + function renderNode(node){ + var li_outer = document.createElement("li"); + var span_desc = document.createElement("span"); + span_desc.className = "tj_description"; + span_desc.tj_node = node; + + if(!node.isEnabled()){ + li_outer.setAttribute("disabled", ""); + node.setExpanded(false); + node.setSelected(false); + } + + if(node.isSelected()){ + span_desc.classList.add("selected"); + } + + span_desc.addEventListener("click", function(e){ + var cur_el = e.target; + + while(typeof cur_el.tj_node === "undefined" || cur_el.classList.contains("tj_container")){ + cur_el = cur_el.parentElement; + } + + var node_cur = cur_el.tj_node; + + if(typeof node_cur === "undefined"){ + return; + } + + if(node_cur.isEnabled()){ + if(e.ctrlKey == false){ + if(!node_cur.isLeaf()){ + node_cur.toggleExpanded(); + self.reload(); + }else{ + node_cur.open(); + } + + node_cur.on("click")(e, node_cur); + } + + + if(e.ctrlKey == true){ + node_cur.toggleSelected(); + self.reload(); + }else{ + var rt = node_cur.getRoot(); + + if(rt instanceof TreeNode){ + TreeUtil.getSelectedNodesForNode(rt).forEach(function(_nd){ + _nd.setSelected(false); + }); + } + node_cur.setSelected(true); + + self.reload(); + } + } + }); + + span_desc.addEventListener("contextmenu", function(e){ + var cur_el = e.target; + + while(typeof cur_el.tj_node === "undefined" || cur_el.classList.contains("tj_container")){ + cur_el = cur_el.parentElement; + } + + var node_cur = cur_el.tj_node; + + if(typeof node_cur === "undefined"){ + return; + } + + if(typeof node_cur.getListener("contextmenu") !== "undefined"){ + node_cur.on("contextmenu")(e, node_cur); + e.preventDefault(); + }else if(typeof TreeConfig.context_menu === "function"){ + TreeConfig.context_menu(e, node_cur); + e.preventDefault(); + } + }); + + if(node.isLeaf()){ + var ret = ''; + var icon = TreeUtil.getProperty(node.getOptions(), "icon", ""); + if(icon != ""){ + ret += '' + icon + ''; + }else if((icon = TreeUtil.getProperty(options, "leaf_icon", "")) != ""){ + ret += '' + icon + ''; + }else{ + ret += '' + TreeConfig.leaf_icon + ''; + } + + span_desc.innerHTML = ret + node.toString() + ""; + span_desc.classList.add("tj_leaf"); + + li_outer.appendChild(span_desc); + }else{ + var ret = ''; + if(node.isExpanded()){ + ret += '' + TreeConfig.open_icon + ''; + }else{ + ret+= '' + TreeConfig.close_icon + ''; + } + + var icon = TreeUtil.getProperty(node.getOptions(), "icon", ""); + if(icon != ""){ + ret += '' + icon + ''; + }else if((icon = TreeUtil.getProperty(options, "parent_icon", "")) != ""){ + ret += '' + icon + ''; + }else{ + ret += '' + TreeConfig.parent_icon + ''; + } + + span_desc.innerHTML = ret + node.toString() + ''; + + li_outer.appendChild(span_desc); + + if(node.isExpanded()){ + var ul_container = document.createElement("ul"); + + node.getChildren().forEach(function(child){ + ul_container.appendChild(renderNode(child)); + }); + + li_outer.appendChild(ul_container) + } + } + + return li_outer; + } + + if(typeof container !== "undefined") + this.reload(); +} + +function TreeNode(userObject, options){ + var children = new Array(); + var self = this; + var events = new Array(); + + var expanded = true; + var enabled = true; + var selected = false; + + /* + * Konstruktor + */ + if(userObject){ + if(!(typeof userObject === "string") || typeof userObject.toString !== "function"){ + throw new Error("Parameter 1 must be of type String or Object, where it must have the function toString()"); + } + }else{ + userObject = ""; + } + + if(!options || typeof options !== "object"){ + options = {}; + }else{ + expanded = TreeUtil.getProperty(options, "expanded", true); + enabled = TreeUtil.getProperty(options, "enabled", true); + selected = TreeUtil.getProperty(options, "selected", false); + } + + /* + * Methods + */ + this.addChild = function(node){ + if(!TreeUtil.getProperty(options, "allowsChildren", true)){ + console.warn("Option allowsChildren is set to false, no child added"); + return; + } + + if(node instanceof TreeNode){ + children.push(node); + + //Konstante hinzufügen (workaround) + Object.defineProperty(node, "parent", { + value: this, + writable: false, + enumerable: true, + configurable: true + }); + }else{ + throw new Error("Parameter 1 must be of type TreeNode"); + } + } + + this.removeChildPos = function(pos){ + if(typeof children[pos] !== "undefined"){ + if(typeof children[pos] !== "undefined"){ + children.splice(pos, 1); + } + } + } + + this.removeChild = function(node){ + if(!(node instanceof TreeNode)){ + throw new Error("Parameter 1 must be of type TreeNode"); + } + + this.removeChildPos(this.getIndexOfChild(node)); + } + + this.getChildren = function(){ + return children; + } + + this.getChildCount = function(){ + return children.length; + } + + this.getIndexOfChild = function(node){ + for(var i = 0; i < children.length; i++){ + if(children[i].equals(node)){ + return i; + } + } + + return -1; + } + + this.getRoot = function(){ + var node = this; + + while(typeof node.parent !== "undefined"){ + node = node.parent; + } + + return node; + } + + this.setUserObject = function(_userObject){ + if(!(typeof _userObject === "string") || typeof _userObject.toString !== "function"){ + throw new Error("Parameter 1 must be of type String or Object, where it must have the function toString()"); + }else{ + userObject = _userObject; + } + } + + this.getUserObject = function(){ + return userObject; + } + + this.setOptions = function(_options){ + if(typeof _options === "object"){ + options = _options; + } + } + + this.changeOption = function(option, value){ + options[option] = value; + } + + this.getOptions = function(){ + return options; + } + + this.isLeaf = function(){ + return (children.length == 0); + } + + this.setExpanded = function(_expanded){ + if(this.isLeaf()){ + return; + } + + if(typeof _expanded === "boolean"){ + if(expanded == _expanded){ + return; + } + + expanded = _expanded; + + if(_expanded){ + this.on("expand")(this); + }else{ + this.on("collapse")(this); + } + + this.on("toggle_expanded")(this); + } + } + + this.toggleExpanded = function(){ + if(expanded){ + this.setExpanded(false); + }else{ + this.setExpanded(true); + } + }; + + this.isExpanded = function(){ + if(this.isLeaf()){ + return true; + }else{ + return expanded; + } + } + + this.setEnabled = function(_enabled){ + if(typeof _enabled === "boolean"){ + if(enabled == _enabled){ + return; + } + + enabled = _enabled; + + if(_enabled){ + this.on("enable")(this); + }else{ + this.on("disable")(this); + } + + this.on("toggle_enabled")(this); + } + } + + this.toggleEnabled = function(){ + if(enabled){ + this.setEnabled(false); + }else{ + this.setEnabled(true); + } + } + + this.isEnabled = function(){ + return enabled; + } + + this.setSelected = function(_selected){ + if(typeof _selected !== "boolean"){ + return; + } + + if(selected == _selected){ + return; + } + + selected = _selected; + + if(_selected){ + this.on("select")(this); + }else{ + this.on("deselect")(this); + } + + this.on("toggle_selected")(this); + } + + this.toggleSelected = function(){ + if(selected){ + this.setSelected(false); + }else{ + this.setSelected(true); + } + } + + this.isSelected = function(){ + return selected; + } + + this.open = function(){ + if(!this.isLeaf()){ + this.on("open")(this); + } + } + + this.on = function(ev, callback){ + if(typeof callback === "undefined"){ + if(typeof events[ev] !== "function"){ + return function(){}; + }else{ + return events[ev]; + } + } + + if(typeof callback !== 'function'){ + throw new Error("Argument 2 must be of type function"); + } + + events[ev] = callback; + } + + this.getListener = function(ev){ + return events[ev]; + } + + this.equals = function(node){ + if(node instanceof TreeNode){ + if(node.getUserObject() == userObject){ + return true; + } + } + + return false; + } + + this.toString = function(){ + if(typeof userObject === "string"){ + return userObject; + }else{ + return userObject.toString(); + } + } +} + +function TreePath(root, node){ + var nodes = new Array(); + + this.setPath = function(root, node){ + nodes = new Array(); + + while(typeof node !== "undefined" && !node.equals(root)){ + nodes.push(node); + node = node.parent; + } + + if(node.equals(root)){ + nodes.push(root); + }else{ + nodes = new Array(); + throw new Error("Node is not contained in the tree of root"); + } + + nodes = nodes.reverse(); + + return nodes; + } + + this.getPath = function(){ + return nodes; + } + + this.toString = function(){ + return nodes.join(" - "); + } + + if(root instanceof TreeNode && node instanceof TreeNode){ + this.setPath(root, node); + } +} + +/* +* Util-Methods +*/ +const TreeUtil = { + default_leaf_icon: "🖹", + default_parent_icon: "🗁", + default_open_icon: "", + default_close_icon: "", + + isDOM: function(obj){ + try { + return obj instanceof HTMLElement; + } + catch(e){ + return (typeof obj==="object") && + (obj.nodeType===1) && (typeof obj.style === "object") && + (typeof obj.ownerDocument ==="object"); + } + }, + + getProperty: function(options, opt, def){ + if(typeof options[opt] === "undefined"){ + return def; + } + + return options[opt]; + }, + + expandNode: function(node){ + node.setExpanded(true); + + if(!node.isLeaf()){ + node.getChildren().forEach(function(child){ + TreeUtil.expandNode(child); + }); + } + }, + + collapseNode: function(node){ + node.setExpanded(false); + + if(!node.isLeaf()){ + node.getChildren().forEach(function(child){ + TreeUtil.collapseNode(child); + }); + } + }, + + getSelectedNodesForNode: function(node){ + if(!(node instanceof TreeNode)){ + throw new Error("Parameter 1 must be of type TreeNode"); + } + + var ret = new Array(); + + if(node.isSelected()){ + ret.push(node); + } + + node.getChildren().forEach(function(child){ + if(child.isSelected()){ + if(ret.indexOf(child) == -1){ + ret.push(child); + } + } + + if(!child.isLeaf()){ + TreeUtil.getSelectedNodesForNode(child).forEach(function(_node){ + if(ret.indexOf(_node) == -1){ + ret.push(_node); + } + }); + } + }); + + return ret; + } +}; + +var TreeConfig = { + leaf_icon: TreeUtil.default_leaf_icon, + parent_icon: TreeUtil.default_parent_icon, + open_icon: TreeUtil.default_open_icon, + close_icon: TreeUtil.default_close_icon, + context_menu: undefined +}; diff --git a/assets/stylesheets/knowledgebase.css b/assets/stylesheets/knowledgebase.css index 4338d79..f5c2493 100644 --- a/assets/stylesheets/knowledgebase.css +++ b/assets/stylesheets/knowledgebase.css @@ -238,6 +238,55 @@ div.attachments { overflow: hidden; } +#article-tree.tj_container .tj_icon { + height: 13px; +} + +.icon-folder-expand:before, .icon-folder-collapse:before, .icon-article:before { + content: ""; + display: block; + background-size: contain; + background-repeat: no-repeat; +} + +.icon-folder-expand:before, .icon-folder-collapse:before { + width: 20px; + height: 20px; +} + +.icon-article:before { + width: 18px; + height: 18px; +} + +.icon-folder-expand:before { + background-image: url("../images/article_tree_folder_expanded.png"); +} + +.icon-folder-collapse:before { + background-image: url("../images/article_tree_folder_folded.png"); +} + +.icon-article:before { + background-image: url("../images/article_tree_document.png"); +} + +.tj_container span.tj_description.selected a { + color: #fff; +} + +#article-tree.tj_container li span.tj_icon { + margin-right: 4px; +} + +#article-tree.tj_container li .tj_description span.tj_icon { + height: 15px; +} + +#article-tree.tj_container li .tj_description.tj_leaf span.tj_icon { + height: 14px; +} + /* If you want custom default thumbnails by category, add them here! */ /* diff --git a/assets/stylesheets/treejs.css b/assets/stylesheets/treejs.css new file mode 100644 index 0000000..4b5ec29 --- /dev/null +++ b/assets/stylesheets/treejs.css @@ -0,0 +1,62 @@ +.tj_container *{ + position: relative; + box-sizing: border-box; +} + +.tj_container ul{ + padding-left: 2em; + list-style-type: none; +} + +.tj_container > ul:first-of-type{ + padding: 0; +} + +.tj_container li span.tj_description{ + cursor: pointer; + padding: 2px 5px; + display: block; + border-radius: 2px; + + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.tj_container li span.tj_description:hover{ + background-color: #ccc; +} + +.tj_container li span.tj_mod_icon, .tj_container li span.tj_icon{ + margin-right: 0.5em; + display: inline-block; +} + +.tj_container li span.tj_mod_icon, .tj_container li span.tj_mod_icon *{ + width: 1em; +} + +.tj_container li span.tj_description.tj_leaf{ + margin-left: 1.5em; +} + +.tj_container li[disabled=""]{ + color: #b5b5b5; +} + +.tj_container li[disabled=""]:hover span.tj_description{ + cursor: default; + background-color: inherit; +} + +.tj_container span.tj_description.selected{ + background-color: #2b2b2b; + color: #fff; +} + +.tj_container span.tj_description.selected:hover{ + background-color: #606060; +} diff --git a/config/locales/en.yml b/config/locales/en.yml index 949e64a..6a1fa8b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -67,6 +67,8 @@ en: label_unrated_article: Unrated label_updated_who: "Updated by %{updater} %{age} ago" label_unrated_article: "Unrated" + label_expand_all: "Expand All" + label_collapse_all: "Fold All" message_no_articles: "No articles have been added yet. Click Create Article to get started." message_no_categories: "No categories have been created. Click Create Category to get started." message_no_permissions: "You do not have permission to view any articles or categories. Please contact your administrator." @@ -98,6 +100,7 @@ en: show_breadcrumbs_for_article_lists: "Show breadcrumbs for articles in lists?" show_thumbnails_for_articles: "Show thumbnails for articles in lists?" show_tiled_article_lists: "Show tiled article lists?" + show_article_tree_lists: "Show article tree lists?" summary_item_limit: "Knowledgebase summary page article limit" text_confirm_versions_delete: "This will revert and delete any newer versions of this document that may exist. Are you sure?" text_current_version: Current @@ -105,6 +108,7 @@ en: title_add_comment: "Add Comment" title_article_rating: "Article Rating" title_browse_by_category: "Browse by Category" + title_article_tree_lists: "Article Tree Lists" title_browse_by_tags: "Browse by Tags" title_create_article: "Create Article" title_create_category: "Create Category" diff --git a/init.rb b/init.rb index 76e6c6c..15e6138 100644 --- a/init.rb +++ b/init.rb @@ -96,5 +96,10 @@ end class RedmineKnowledgebaseHookListener < Redmine::Hook::ViewListener - render_on :view_layouts_base_html_head, :inline => "<%= stylesheet_link_tag 'knowledgebase', :plugin => :redmine_knowledgebase %>" + render_on :view_layouts_base_html_head, + {:inline => "<%= stylesheet_link_tag 'knowledgebase', :plugin => :redmine_knowledgebase %>"}, + {:inline => "<%= stylesheet_link_tag 'treejs', :plugin => :redmine_knowledgebase %>"}, + {:inline => "<%= javascript_include_tag 'tree', :plugin => :redmine_knowledgebase %>"} + + end