diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 6cc8faf126..9775883bbd 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -12,6 +12,7 @@ from build.urls import build_urls from part.api import part_api_urls from company.api import company_api_urls +from stock.api import stock_api_urls from django.conf import settings from django.conf.urls.static import static @@ -25,6 +26,7 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ url(r'^part/', include(part_api_urls)), url(r'^company/', include(company_api_urls)), + url(r'^stock/', include(stock_api_urls)), # User URLs url(r'^user/', include(user_urls)), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index ee09b6698a..2cbefdfb6c 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -5,6 +5,48 @@ from django.template.loader import render_to_string from django.http import JsonResponse from django.views.generic import UpdateView, CreateView, DeleteView +from rest_framework import views +from django.http import JsonResponse + + +class TreeSerializer(views.APIView): + + def itemToJson(self, item): + + data = { + 'text': item.name, + 'href': item.get_absolute_url(), + } + + if item.has_children: + nodes = [] + + for child in item.children.all().order_by('name'): + nodes.append(self.itemToJson(child)) + + data['nodes'] = nodes + + return data + + def get(self, request, *args, **kwargs): + + top_items = self.model.objects.filter(parent=None).order_by('name') + + nodes = [] + + for item in top_items: + nodes.append(self.itemToJson(item)) + + top = { + 'text': self.title, + 'nodes': nodes, + } + + response = { + 'tree': [top] + } + + return JsonResponse(response, safe=False) class AjaxView(object): diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index a6de975b55..008523364d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -7,9 +7,17 @@ from rest_framework import generics, permissions from django.conf.urls import url -from .models import Part +from .models import Part, PartCategory from .serializers import PartSerializer +from InvenTree.views import TreeSerializer + + +class PartCategoryTree(TreeSerializer): + + title = "Parts" + model = PartCategory + class PartList(generics.ListCreateAPIView): @@ -44,5 +52,7 @@ class PartList(generics.ListCreateAPIView): part_api_urls = [ + url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), + url(r'^.*$', PartList.as_view(), name='api-part-list'), ] diff --git a/InvenTree/part/templates/part/index.html b/InvenTree/part/templates/part/index.html index 27d169bcce..9265ef52ac 100644 --- a/InvenTree/part/templates/part/index.html +++ b/InvenTree/part/templates/part/index.html @@ -31,7 +31,6 @@ - {% endblock %} {% block js_ready %} $('#part-list').footable(); @@ -44,7 +43,36 @@ }); }); + function loadTree() { + var requestData = {}; + + {% if category %} + requestData.category = {{ category.id }}; + {% endif %} + + $.ajax({ + url: "{% url 'api-part-tree' %}", + type: 'get', + dataType: 'json', + data: requestData, + success: function (response) { + if (response.tree) { + $("#part-tree").treeview({ + data: response.tree, + enableLinks: true + }); + } + }, + error: function (xhr, ajaxOptions, thrownError) { + alert('Error retrieving part tree:\n' + thrownError); + } + }); + } + $("#create-part").click(function() { launchModalForm("#modal-form", "{% url 'part-create' %}"); }); + + loadTree(); + {% endblock %} diff --git a/InvenTree/static/css/bootstrap-treeview.css b/InvenTree/static/css/bootstrap-treeview.css new file mode 100644 index 0000000000..23c6cf0668 --- /dev/null +++ b/InvenTree/static/css/bootstrap-treeview.css @@ -0,0 +1,37 @@ +/* ========================================================= + * bootstrap-treeview.css v1.2.0 + * ========================================================= + * Copyright 2013 Jonathan Miles + * Project URL : http://www.jondmiles.com/bootstrap-treeview + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + +.treeview .list-group-item { + cursor: pointer; +} + +.treeview span.indent { + margin-left: 10px; + margin-right: 10px; +} + +.treeview span.icon { + width: 12px; + margin-right: 5px; +} + +.treeview .node-disabled { + color: silver; + cursor: not-allowed; +} \ No newline at end of file diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css index 475fbc7cd3..229ee025e6 100644 --- a/InvenTree/static/css/inventree.css +++ b/InvenTree/static/css/inventree.css @@ -41,8 +41,17 @@ } .inventree-content { - padding-left: 15px; - padding-right: 15px; + padding-left: 5px; + padding-right: 5px; + padding-top: 15px; + margin-right: 50px; + margin-left: 50px; + width: 100%; + transition: 0.5s; +} + +.body { + padding-top: 70px; } .modal { @@ -61,4 +70,19 @@ max-height: calc(100vh - 200px) !important; overflow-y: scroll; padding: 10px; +} + +/* The side navigation menu */ +.sidenav { + height: 100%; /* 100% Full-height */ + width: 0px; /* 0 width - change this with JavaScript */ + position: fixed; /* Stay in place */ + background-color: #fff; /* Black*/ + overflow-x: hidden; /* Disable horizontal scroll */ + transition: 0.5s; /* 0.5 second transition effect to slide in the sidenav */ +} + +.wrapper { + align-items: stretch; + display: flex; } \ No newline at end of file diff --git a/InvenTree/static/script/bootstrap-treeview.js b/InvenTree/static/script/bootstrap-treeview.js new file mode 100644 index 0000000000..7a82a2eeb5 --- /dev/null +++ b/InvenTree/static/script/bootstrap-treeview.js @@ -0,0 +1,1249 @@ +/* ========================================================= + * bootstrap-treeview.js v1.2.0 + * ========================================================= + * Copyright 2013 Jonathan Miles + * Project URL : http://www.jondmiles.com/bootstrap-treeview + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + +;(function ($, window, document, undefined) { + + /*global jQuery, console*/ + + 'use strict'; + + var pluginName = 'treeview'; + + var _default = {}; + + _default.settings = { + + injectStyle: true, + + levels: 2, + + expandIcon: 'glyphicon glyphicon-plus', + collapseIcon: 'glyphicon glyphicon-minus', + emptyIcon: 'glyphicon', + nodeIcon: '', + selectedIcon: '', + checkedIcon: 'glyphicon glyphicon-check', + uncheckedIcon: 'glyphicon glyphicon-unchecked', + + color: undefined, // '#000000', + backColor: undefined, // '#FFFFFF', + borderColor: undefined, // '#dddddd', + onhoverColor: '#F5F5F5', + selectedColor: '#FFFFFF', + selectedBackColor: '#428bca', + searchResultColor: '#D9534F', + searchResultBackColor: undefined, //'#FFFFFF', + + enableLinks: false, + highlightSelected: true, + highlightSearchResults: true, + showBorder: true, + showIcon: true, + showCheckbox: false, + showTags: false, + multiSelect: false, + + // Event handlers + onNodeChecked: undefined, + onNodeCollapsed: undefined, + onNodeDisabled: undefined, + onNodeEnabled: undefined, + onNodeExpanded: undefined, + onNodeSelected: undefined, + onNodeUnchecked: undefined, + onNodeUnselected: undefined, + onSearchComplete: undefined, + onSearchCleared: undefined + }; + + _default.options = { + silent: false, + ignoreChildren: false + }; + + _default.searchOptions = { + ignoreCase: true, + exactMatch: false, + revealResults: true + }; + + var Tree = function (element, options) { + + this.$element = $(element); + this.elementId = element.id; + this.styleId = this.elementId + '-style'; + + this.init(options); + + return { + + // Options (public access) + options: this.options, + + // Initialize / destroy methods + init: $.proxy(this.init, this), + remove: $.proxy(this.remove, this), + + // Get methods + getNode: $.proxy(this.getNode, this), + getParent: $.proxy(this.getParent, this), + getSiblings: $.proxy(this.getSiblings, this), + getSelected: $.proxy(this.getSelected, this), + getUnselected: $.proxy(this.getUnselected, this), + getExpanded: $.proxy(this.getExpanded, this), + getCollapsed: $.proxy(this.getCollapsed, this), + getChecked: $.proxy(this.getChecked, this), + getUnchecked: $.proxy(this.getUnchecked, this), + getDisabled: $.proxy(this.getDisabled, this), + getEnabled: $.proxy(this.getEnabled, this), + + // Select methods + selectNode: $.proxy(this.selectNode, this), + unselectNode: $.proxy(this.unselectNode, this), + toggleNodeSelected: $.proxy(this.toggleNodeSelected, this), + + // Expand / collapse methods + collapseAll: $.proxy(this.collapseAll, this), + collapseNode: $.proxy(this.collapseNode, this), + expandAll: $.proxy(this.expandAll, this), + expandNode: $.proxy(this.expandNode, this), + toggleNodeExpanded: $.proxy(this.toggleNodeExpanded, this), + revealNode: $.proxy(this.revealNode, this), + + // Expand / collapse methods + checkAll: $.proxy(this.checkAll, this), + checkNode: $.proxy(this.checkNode, this), + uncheckAll: $.proxy(this.uncheckAll, this), + uncheckNode: $.proxy(this.uncheckNode, this), + toggleNodeChecked: $.proxy(this.toggleNodeChecked, this), + + // Disable / enable methods + disableAll: $.proxy(this.disableAll, this), + disableNode: $.proxy(this.disableNode, this), + enableAll: $.proxy(this.enableAll, this), + enableNode: $.proxy(this.enableNode, this), + toggleNodeDisabled: $.proxy(this.toggleNodeDisabled, this), + + // Search methods + search: $.proxy(this.search, this), + clearSearch: $.proxy(this.clearSearch, this) + }; + }; + + Tree.prototype.init = function (options) { + + this.tree = []; + this.nodes = []; + + if (options.data) { + if (typeof options.data === 'string') { + options.data = $.parseJSON(options.data); + } + this.tree = $.extend(true, [], options.data); + delete options.data; + } + this.options = $.extend({}, _default.settings, options); + + this.destroy(); + this.subscribeEvents(); + this.setInitialStates({ nodes: this.tree }, 0); + this.render(); + }; + + Tree.prototype.remove = function () { + this.destroy(); + $.removeData(this, pluginName); + $('#' + this.styleId).remove(); + }; + + Tree.prototype.destroy = function () { + + if (!this.initialized) return; + + this.$wrapper.remove(); + this.$wrapper = null; + + // Switch off events + this.unsubscribeEvents(); + + // Reset this.initialized flag + this.initialized = false; + }; + + Tree.prototype.unsubscribeEvents = function () { + + this.$element.off('click'); + this.$element.off('nodeChecked'); + this.$element.off('nodeCollapsed'); + this.$element.off('nodeDisabled'); + this.$element.off('nodeEnabled'); + this.$element.off('nodeExpanded'); + this.$element.off('nodeSelected'); + this.$element.off('nodeUnchecked'); + this.$element.off('nodeUnselected'); + this.$element.off('searchComplete'); + this.$element.off('searchCleared'); + }; + + Tree.prototype.subscribeEvents = function () { + + this.unsubscribeEvents(); + + this.$element.on('click', $.proxy(this.clickHandler, this)); + + if (typeof (this.options.onNodeChecked) === 'function') { + this.$element.on('nodeChecked', this.options.onNodeChecked); + } + + if (typeof (this.options.onNodeCollapsed) === 'function') { + this.$element.on('nodeCollapsed', this.options.onNodeCollapsed); + } + + if (typeof (this.options.onNodeDisabled) === 'function') { + this.$element.on('nodeDisabled', this.options.onNodeDisabled); + } + + if (typeof (this.options.onNodeEnabled) === 'function') { + this.$element.on('nodeEnabled', this.options.onNodeEnabled); + } + + if (typeof (this.options.onNodeExpanded) === 'function') { + this.$element.on('nodeExpanded', this.options.onNodeExpanded); + } + + if (typeof (this.options.onNodeSelected) === 'function') { + this.$element.on('nodeSelected', this.options.onNodeSelected); + } + + if (typeof (this.options.onNodeUnchecked) === 'function') { + this.$element.on('nodeUnchecked', this.options.onNodeUnchecked); + } + + if (typeof (this.options.onNodeUnselected) === 'function') { + this.$element.on('nodeUnselected', this.options.onNodeUnselected); + } + + if (typeof (this.options.onSearchComplete) === 'function') { + this.$element.on('searchComplete', this.options.onSearchComplete); + } + + if (typeof (this.options.onSearchCleared) === 'function') { + this.$element.on('searchCleared', this.options.onSearchCleared); + } + }; + + /* + Recurse the tree structure and ensure all nodes have + valid initial states. User defined states will be preserved. + For performance we also take this opportunity to + index nodes in a flattened structure + */ + Tree.prototype.setInitialStates = function (node, level) { + + if (!node.nodes) return; + level += 1; + + var parent = node; + var _this = this; + $.each(node.nodes, function checkStates(index, node) { + + // nodeId : unique, incremental identifier + node.nodeId = _this.nodes.length; + + // parentId : transversing up the tree + node.parentId = parent.nodeId; + + // if not provided set selectable default value + if (!node.hasOwnProperty('selectable')) { + node.selectable = true; + } + + // where provided we should preserve states + node.state = node.state || {}; + + // set checked state; unless set always false + if (!node.state.hasOwnProperty('checked')) { + node.state.checked = false; + } + + // set enabled state; unless set always false + if (!node.state.hasOwnProperty('disabled')) { + node.state.disabled = false; + } + + // set expanded state; if not provided based on levels + if (!node.state.hasOwnProperty('expanded')) { + if (!node.state.disabled && + (level < _this.options.levels) && + (node.nodes && node.nodes.length > 0)) { + node.state.expanded = true; + } + else { + node.state.expanded = false; + } + } + + // set selected state; unless set always false + if (!node.state.hasOwnProperty('selected')) { + node.state.selected = false; + } + + // index nodes in a flattened structure for use later + _this.nodes.push(node); + + // recurse child nodes and transverse the tree + if (node.nodes) { + _this.setInitialStates(node, level); + } + }); + }; + + Tree.prototype.clickHandler = function (event) { + + if (!this.options.enableLinks) event.preventDefault(); + + var target = $(event.target); + var node = this.findNode(target); + if (!node || node.state.disabled) return; + + var classList = target.attr('class') ? target.attr('class').split(' ') : []; + if ((classList.indexOf('expand-icon') !== -1)) { + + this.toggleExpandedState(node, _default.options); + this.render(); + } + else if ((classList.indexOf('check-icon') !== -1)) { + + this.toggleCheckedState(node, _default.options); + this.render(); + } + else { + + if (node.selectable) { + this.toggleSelectedState(node, _default.options); + } else { + this.toggleExpandedState(node, _default.options); + } + + this.render(); + } + }; + + // Looks up the DOM for the closest parent list item to retrieve the + // data attribute nodeid, which is used to lookup the node in the flattened structure. + Tree.prototype.findNode = function (target) { + + var nodeId = target.closest('li.list-group-item').attr('data-nodeid'); + var node = this.nodes[nodeId]; + + if (!node) { + console.log('Error: node does not exist'); + } + return node; + }; + + Tree.prototype.toggleExpandedState = function (node, options) { + if (!node) return; + this.setExpandedState(node, !node.state.expanded, options); + }; + + Tree.prototype.setExpandedState = function (node, state, options) { + + if (state === node.state.expanded) return; + + if (state && node.nodes) { + + // Expand a node + node.state.expanded = true; + if (!options.silent) { + this.$element.trigger('nodeExpanded', $.extend(true, {}, node)); + } + } + else if (!state) { + + // Collapse a node + node.state.expanded = false; + if (!options.silent) { + this.$element.trigger('nodeCollapsed', $.extend(true, {}, node)); + } + + // Collapse child nodes + if (node.nodes && !options.ignoreChildren) { + $.each(node.nodes, $.proxy(function (index, node) { + this.setExpandedState(node, false, options); + }, this)); + } + } + }; + + Tree.prototype.toggleSelectedState = function (node, options) { + if (!node) return; + this.setSelectedState(node, !node.state.selected, options); + }; + + Tree.prototype.setSelectedState = function (node, state, options) { + + if (state === node.state.selected) return; + + if (state) { + + // If multiSelect false, unselect previously selected + if (!this.options.multiSelect) { + $.each(this.findNodes('true', 'g', 'state.selected'), $.proxy(function (index, node) { + this.setSelectedState(node, false, options); + }, this)); + } + + // Continue selecting node + node.state.selected = true; + if (!options.silent) { + this.$element.trigger('nodeSelected', $.extend(true, {}, node)); + } + } + else { + + // Unselect node + node.state.selected = false; + if (!options.silent) { + this.$element.trigger('nodeUnselected', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.toggleCheckedState = function (node, options) { + if (!node) return; + this.setCheckedState(node, !node.state.checked, options); + }; + + Tree.prototype.setCheckedState = function (node, state, options) { + + if (state === node.state.checked) return; + + if (state) { + + // Check node + node.state.checked = true; + + if (!options.silent) { + this.$element.trigger('nodeChecked', $.extend(true, {}, node)); + } + } + else { + + // Uncheck node + node.state.checked = false; + if (!options.silent) { + this.$element.trigger('nodeUnchecked', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.setDisabledState = function (node, state, options) { + + if (state === node.state.disabled) return; + + if (state) { + + // Disable node + node.state.disabled = true; + + // Disable all other states + this.setExpandedState(node, false, options); + this.setSelectedState(node, false, options); + this.setCheckedState(node, false, options); + + if (!options.silent) { + this.$element.trigger('nodeDisabled', $.extend(true, {}, node)); + } + } + else { + + // Enabled node + node.state.disabled = false; + if (!options.silent) { + this.$element.trigger('nodeEnabled', $.extend(true, {}, node)); + } + } + }; + + Tree.prototype.render = function () { + + if (!this.initialized) { + + // Setup first time only components + this.$element.addClass(pluginName); + this.$wrapper = $(this.template.list); + + this.injectStyle(); + + this.initialized = true; + } + + this.$element.empty().append(this.$wrapper.empty()); + + // Build tree + this.buildTree(this.tree, 0); + }; + + // Starting from the root node, and recursing down the + // structure we build the tree one node at a time + Tree.prototype.buildTree = function (nodes, level) { + + if (!nodes) return; + level += 1; + + var _this = this; + $.each(nodes, function addNodes(id, node) { + + var treeItem = $(_this.template.item) + .addClass('node-' + _this.elementId) + .addClass(node.state.checked ? 'node-checked' : '') + .addClass(node.state.disabled ? 'node-disabled': '') + .addClass(node.state.selected ? 'node-selected' : '') + .addClass(node.searchResult ? 'search-result' : '') + .attr('data-nodeid', node.nodeId) + .attr('style', _this.buildStyleOverride(node)); + + // Add indent/spacer to mimic tree structure + for (var i = 0; i < (level - 1); i++) { + treeItem.append(_this.template.indent); + } + + // Add expand, collapse or empty spacer icons + var classList = []; + if (node.nodes) { + classList.push('expand-icon'); + if (node.state.expanded) { + classList.push(_this.options.collapseIcon); + } + else { + classList.push(_this.options.expandIcon); + } + } + else { + classList.push(_this.options.emptyIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + + + // Add node icon + if (_this.options.showIcon) { + + var classList = ['node-icon']; + + classList.push(node.icon || _this.options.nodeIcon); + if (node.state.selected) { + classList.pop(); + classList.push(node.selectedIcon || _this.options.selectedIcon || + node.icon || _this.options.nodeIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + } + + // Add check / unchecked icon + if (_this.options.showCheckbox) { + + var classList = ['check-icon']; + if (node.state.checked) { + classList.push(_this.options.checkedIcon); + } + else { + classList.push(_this.options.uncheckedIcon); + } + + treeItem + .append($(_this.template.icon) + .addClass(classList.join(' ')) + ); + } + + // Add text + if (_this.options.enableLinks) { + // Add hyperlink + treeItem + .append($(_this.template.link) + .attr('href', node.href) + .append(node.text) + ); + } + else { + // otherwise just text + treeItem + .append(node.text); + } + + // Add tags as badges + if (_this.options.showTags && node.tags) { + $.each(node.tags, function addTag(id, tag) { + treeItem + .append($(_this.template.badge) + .append(tag) + ); + }); + } + + // Add item to the tree + _this.$wrapper.append(treeItem); + + // Recursively add child ndoes + if (node.nodes && node.state.expanded && !node.state.disabled) { + return _this.buildTree(node.nodes, level); + } + }); + }; + + // Define any node level style override for + // 1. selectedNode + // 2. node|data assigned color overrides + Tree.prototype.buildStyleOverride = function (node) { + + if (node.state.disabled) return ''; + + var color = node.color; + var backColor = node.backColor; + + if (this.options.highlightSelected && node.state.selected) { + if (this.options.selectedColor) { + color = this.options.selectedColor; + } + if (this.options.selectedBackColor) { + backColor = this.options.selectedBackColor; + } + } + + if (this.options.highlightSearchResults && node.searchResult && !node.state.disabled) { + if (this.options.searchResultColor) { + color = this.options.searchResultColor; + } + if (this.options.searchResultBackColor) { + backColor = this.options.searchResultBackColor; + } + } + + return 'color:' + color + + ';background-color:' + backColor + ';'; + }; + + // Add inline style into head + Tree.prototype.injectStyle = function () { + + if (this.options.injectStyle && !document.getElementById(this.styleId)) { + $('').appendTo('head'); + } + }; + + // Construct trees style based on user options + Tree.prototype.buildStyle = function () { + + var style = '.node-' + this.elementId + '{'; + + if (this.options.color) { + style += 'color:' + this.options.color + ';'; + } + + if (this.options.backColor) { + style += 'background-color:' + this.options.backColor + ';'; + } + + if (!this.options.showBorder) { + style += 'border:none;'; + } + else if (this.options.borderColor) { + style += 'border:1px solid ' + this.options.borderColor + ';'; + } + style += '}'; + + if (this.options.onhoverColor) { + style += '.node-' + this.elementId + ':not(.node-disabled):hover{' + + 'background-color:' + this.options.onhoverColor + ';' + + '}'; + } + + return this.css + style; + }; + + Tree.prototype.template = { + list: '