From 39ca24e1a3bddb62339fc3e2e494499fb3036c46 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Sun, 22 Mar 2020 16:30:37 +0100 Subject: [PATCH 01/14] Add Node Serializer This is the simplest serializer for a node. --- app/serializers/alchemy/node_serializer.rb | 12 +++++++++++ .../alchemy/node_serializer_spec.rb | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 app/serializers/alchemy/node_serializer.rb create mode 100644 spec/serializers/alchemy/node_serializer_spec.rb diff --git a/app/serializers/alchemy/node_serializer.rb b/app/serializers/alchemy/node_serializer.rb new file mode 100644 index 0000000000..a260fba3b9 --- /dev/null +++ b/app/serializers/alchemy/node_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Alchemy + class NodeSerializer < ActiveModel::Serializer + attributes :id, + :name, + :lft, + :rgt, + :url, + :parent_id + end +end diff --git a/spec/serializers/alchemy/node_serializer_spec.rb b/spec/serializers/alchemy/node_serializer_spec.rb new file mode 100644 index 0000000000..5c6365056d --- /dev/null +++ b/spec/serializers/alchemy/node_serializer_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Alchemy::NodeSerializer do + subject { described_class.new(node).to_json } + + let(:node) { build_stubbed(:alchemy_node) } + + it 'includes all attributes' do + json = JSON.parse(subject) + expect(json).to eq( + 'id' => node.id, + 'lft' => node.lft, + 'rgt' => node.rgt, + 'parent_id' => node.parent_id, + 'name' => node.name, + 'url' => node.url + ) + end +end From 85d113124ae60051ce30ab8c71c6ceb55c42991e Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Sun, 22 Mar 2020 16:56:20 +0100 Subject: [PATCH 02/14] Add Api::NodesController#move This moves an node to a specified parent and a specified position. --- .../alchemy/api/nodes_controller.rb | 19 +++++++++++++ config/routes.rb | 6 +++++ .../alchemy/api/nodes_controller_spec.rb | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 app/controllers/alchemy/api/nodes_controller.rb create mode 100644 spec/requests/alchemy/api/nodes_controller_spec.rb diff --git a/app/controllers/alchemy/api/nodes_controller.rb b/app/controllers/alchemy/api/nodes_controller.rb new file mode 100644 index 0000000000..351b5d200f --- /dev/null +++ b/app/controllers/alchemy/api/nodes_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Alchemy + class Api::NodesController < Api::BaseController + before_action :load_node + + def move + target_parent_node = Node.find(params[:target_parent_id]) + @node.move_to_child_with_index(target_parent_node, params[:new_position]) + render json: @node, serializer: NodeSerializer + end + + private + + def load_node + @node = Node.find(params[:id]) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 7cd725778e..f4c7a60a04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -153,6 +153,12 @@ get '/pages/*urlname(.:format)' => 'pages#show', as: 'page' get '/admin/pages/:id(.:format)' => 'pages#show', as: 'preview_page' + + resources :nodes, only: [] do + member do + patch :move + end + end end get '/:locale' => 'pages#index', diff --git a/spec/requests/alchemy/api/nodes_controller_spec.rb b/spec/requests/alchemy/api/nodes_controller_spec.rb new file mode 100644 index 0000000000..2d5cdf63ba --- /dev/null +++ b/spec/requests/alchemy/api/nodes_controller_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Alchemy + describe Api::NodesController do + describe '#move' do + let!(:root_node) { create(:alchemy_node, name: 'main_menu') } + let!(:page_node) { create(:alchemy_node, :with_page, parent: root_node) } + let!(:page_node_2) { create(:alchemy_node, :with_page, parent: root_node) } + let!(:url_node) { create(:alchemy_node, :with_url, parent: root_node) } + + it 'returns JSON and moves the node' do + expect(page_node.children).to be_empty + expect(url_node.lft).to eq(6) + patch alchemy.move_api_node_path(url_node, format: :json), params: { + target_parent_id: page_node.id, + new_position: 0 + } + expect(response.status).to eq(200) + response_json = JSON.parse(response.body) + expect(response_json['parent_id']).to eq(page_node.id) + expect(page_node.children).to include(url_node) + end + end + end +end From b6151b45847777df5ffb803ccca3ee63954e773e Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Sun, 22 Mar 2020 17:11:34 +0100 Subject: [PATCH 03/14] Add Sortable.js 1.10.2 This is a nice sorting library that works with trees. --- app/assets/javascripts/alchemy/admin.js | 1 + vendor/assets/javascripts/sortable/Sortable.min.js | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 vendor/assets/javascripts/sortable/Sortable.min.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index bc9df5d794..ba3b82937b 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -15,6 +15,7 @@ //= require requestAnimationFrame //= require select2 //= require handlebars +//= require sortable/Sortable.min //= require alchemy/templates //= require alchemy/alchemy.base //= require alchemy/alchemy.autocomplete diff --git a/vendor/assets/javascripts/sortable/Sortable.min.js b/vendor/assets/javascripts/sortable/Sortable.min.js new file mode 100644 index 0000000000..eba0614973 --- /dev/null +++ b/vendor/assets/javascripts/sortable/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.10.2 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function kt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function Rt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ot(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Rt.supportPointer&&"PointerEvent"in window,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in At(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&xt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?u(t,"pointerdown",this._onTapStart):(u(t,"mousedown",this._onTapStart),u(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(u(t,"dragover",this),u(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Xt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Yt(t){t.draggable=!1}function Bt(){Dt=!1}function Ft(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ht(t){return setTimeout(t,0)}function Lt(t){return clearTimeout(t)}Rt.prototype={constructor:Rt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled||s.isContentEditable||(l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:Rt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),Rt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Yt)}),u(l,"dragover",Pt),u(l,"mousemove",Pt),u(l,"touchmove",Pt),u(l,"mouseup",i._onDrop),u(l,"touchend",i._onDrop),u(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(Rt.eventCanceled)return void this._onDrop();u(l,"mouseup",i._disableDelayedDrag),u(l,"touchend",i._disableDelayedDrag),u(l,"touchcancel",i._disableDelayedDrag),u(l,"mousemove",i._delayedDragTouchMoveHandler),u(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&u(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Yt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;d(t,"mouseup",this._disableDelayedDrag),d(t,"touchend",this._disableDelayedDrag),d(t,"touchcancel",this._disableDelayedDrag),d(t,"mousemove",this._delayedDragTouchMoveHandler),d(t,"touchmove",this._delayedDragTouchMoveHandler),d(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?u(document,"pointermove",this._onTouchMove):u(document,e?"touchmove":"mousemove",this._onTouchMove):(u(z,"dragend",this),u(q,"dragstart",this._onDragStart));try{document.selection?Ht(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&u(document,"dragover",kt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),Rt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,Nt();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);It()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U,!0),a=U&&r&&r.a,l=U&&r&&r.d,s=Ct&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!Rt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return A(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Xt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,N(),A(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt Date: Sun, 22 Mar 2020 19:44:56 +0100 Subject: [PATCH 04/14] Use Sortable.js to sort Menus This commit allows sorting of menus. We instantiate for each element in the tree one instance of Sortable.js, but - through the `group` option - allow dragging and dropping between each element. For this to work, all `ul` elements need to be present (otherwise we cannot drop a node below a list that does not have nodes yet). A little tricky was the correct behaviour of the `folded` button: It should only be displayed if there are any pages to fold. This is now accomplished through rendering the `+` and `-` buttons in Javascript, depending on how many elements are in each list after sorting. Additionally: The full node tree is now rendered on page load, and all manipulations manipulate the DOM. There are no nodes being inserted or deleted through sorting or folding. --- app/assets/javascripts/alchemy/admin.js | 1 + .../javascripts/alchemy/alchemy.node_tree.js | 92 +++++++++++++++++++ app/assets/stylesheets/alchemy/nodes.scss | 4 + .../alchemy/admin/nodes_controller.rb | 6 +- app/views/alchemy/admin/nodes/_node.html.erb | 24 +---- app/views/alchemy/admin/nodes/index.html.erb | 18 +--- .../alchemy/admin/nodes_controller_spec.rb | 4 +- 7 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 app/assets/javascripts/alchemy/alchemy.node_tree.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index ba3b82937b..6648da0b13 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -40,6 +40,7 @@ //= require alchemy/alchemy.link_dialog //= require alchemy/alchemy.list_filter //= require alchemy/alchemy.initializer +//= require alchemy/alchemy.node_tree //= require alchemy/alchemy.page_sorter //= require alchemy/alchemy.uploader //= require alchemy/alchemy.preview_window diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js new file mode 100644 index 0000000000..0abe3d3591 --- /dev/null +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -0,0 +1,92 @@ +Alchemy.NodeTree = { + onFinishDragging: function (evt) { + var url = '/api/nodes/' + evt.item.dataset.id + '/move.json' + var xhr = new XMLHttpRequest() + var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + var data = { + target_parent_id: evt.to.dataset.nodeId, + new_position: evt.newIndex + }; + var json = JSON.stringify(data) + xhr.open("PATCH", url); + xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('X-CSRF-Token', token) + evt.to.parentElement.dataset.folded = 'false' + evt.to.classList.remove('folded') + xhr.onload = function () { + response_json = JSON.parse(xhr.responseText) + if (xhr.readyState == 4 && xhr.status == "200") { + Alchemy.NodeTree.displayNodeFolders() + } else { + Alchemy.growl(response_json.error, 'error'); + } + } + xhr.send(json) + }, + + displayNodeFolders: function () { + var generate_link = function (node_id, folded) { + var icon = folded === "true" ? 'plus' : 'minus'; + return '' + } + + document.querySelectorAll('li.menu-item').forEach(function (el) { + var leftIconArea = el.querySelector('.nodes_tree-left_images') + var list = el.querySelector('ul') + + if (list.children.length > 0 || el.dataset.folded === 'true' ) { + leftIconArea.innerHTML = generate_link(el.dataset.id, el.dataset.folded) + } else { + leftIconArea.innerHTML = ' ' + } + }); + + this.handleNodeFolders(); + }, + + handleNodeFolders: function() { + var folders = document.querySelectorAll('.nodes_tree .node_folder'); + + folders.forEach(function(folder){ + folder.addEventListener('click', function(evt) { + var nodeId = this.dataset.nodeId + var menu_item = this.closest('li.menu-item') + var url = '/admin/nodes/' + nodeId + '/toggle.html' + var list = menu_item.querySelector('.children') + var xhr = new XMLHttpRequest() + var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + + xhr.open("PATCH", url); + xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('X-CSRF-Token', token) + + xhr.onload = function () { + if (xhr.readyState == 4 && xhr.status == "200") { + list.classList.toggle('folded') + menu_item.dataset.folded = menu_item.dataset.folded == 'true' ? 'false' : 'true' + Alchemy.NodeTree.displayNodeFolders(); + } else { + Alchemy.growl('error folding'); + } + } + xhr.send() + }) + }); + }, + + init: function() { + this.displayNodeFolders() + + document.querySelectorAll('.nodes_tree ul.children').forEach(function (el) { + new Sortable(el, { + group: 'nodes', + animation: 150, + fallbackOnBody: true, + swapThreshold: 0.65, + handle: '.node_name', + invertSwap: true, + onEnd: Alchemy.NodeTree.onFinishDragging + }); + }); + } +} diff --git a/app/assets/stylesheets/alchemy/nodes.scss b/app/assets/stylesheets/alchemy/nodes.scss index 6e2d4790e8..6d2464aca7 100644 --- a/app/assets/stylesheets/alchemy/nodes.scss +++ b/app/assets/stylesheets/alchemy/nodes.scss @@ -63,6 +63,10 @@ ul { margin: 0; padding: 0; + + .folded > li { + display: none; + } } li { diff --git a/app/controllers/alchemy/admin/nodes_controller.rb b/app/controllers/alchemy/admin/nodes_controller.rb index 38957fb148..29d29ac304 100644 --- a/app/controllers/alchemy/admin/nodes_controller.rb +++ b/app/controllers/alchemy/admin/nodes_controller.rb @@ -18,11 +18,7 @@ def new def toggle node = Node.find(params[:id]) node.update(folded: !node.folded) - if node.folded? - head :ok - else - render partial: 'node', collection: node.children.includes(:page, :children) - end + head :ok end private diff --git a/app/views/alchemy/admin/nodes/_node.html.erb b/app/views/alchemy/admin/nodes/_node.html.erb index 45db0c628b..1615497ed0 100644 --- a/app/views/alchemy/admin/nodes/_node.html.erb +++ b/app/views/alchemy/admin/nodes/_node.html.erb @@ -1,21 +1,11 @@ -
  • +<%= content_tag :li, class: 'menu-item', data: { id: node.id, parent_id: node.parent_id, folded: node.folded? } do %> <%= content_tag :div, class: [ 'sitemap_node', node.external? ? 'external' : 'internal', "sitemap_node-level_#{node.depth}" ] do %> - <% if node.children.any? %> - - <% if node.folded? %> - - <% else %> - - <% end %> - - <% else %> -   - <% end %> +   <% if can?(:edit, node) %> @@ -81,11 +71,7 @@ <% end %> <% end %> - <% if node.children.any? %> -
      - <% unless node.folded? %> - <%= render partial: 'node', collection: node.children.includes(:page, :children) %> - <% end %> -
    + <%= content_tag :ul, class: "children #{' folded' if node.folded?}", data: { node_id: node.id } do %> + <%= render partial: 'node', collection: node.children.includes(:page, :children) %> <% end %> -
  • +<% end %> diff --git a/app/views/alchemy/admin/nodes/index.html.erb b/app/views/alchemy/admin/nodes/index.html.erb index 43cd246097..ac0c30bd25 100644 --- a/app/views/alchemy/admin/nodes/index.html.erb +++ b/app/views/alchemy/admin/nodes/index.html.erb @@ -43,20 +43,6 @@ diff --git a/spec/controllers/alchemy/admin/nodes_controller_spec.rb b/spec/controllers/alchemy/admin/nodes_controller_spec.rb index 0dfbb940a0..cf0ba30fdf 100644 --- a/spec/controllers/alchemy/admin/nodes_controller_spec.rb +++ b/spec/controllers/alchemy/admin/nodes_controller_spec.rb @@ -88,9 +88,9 @@ module Alchemy render_views - it "returns nodes children" do + it "returns success" do patch :toggle, params: { id: node.id } - expect(response.body).to have_selector('li .sitemap_node') + expect(response).to be_successful end end end From 1d59430740bc01c3c1ddf1d9396b4bf0ad8547ed Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 10:41:01 +0100 Subject: [PATCH 05/14] Refactor folding code to only use one Event handler I had to reimplement jQuery.on for this to work. --- .../javascripts/alchemy/alchemy.node_tree.js | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index 0abe3d3591..697ea39728 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -40,41 +40,51 @@ Alchemy.NodeTree = { leftIconArea.innerHTML = ' ' } }); - - this.handleNodeFolders(); }, handleNodeFolders: function() { - var folders = document.querySelectorAll('.nodes_tree .node_folder'); + this.registerDelegatingEventHandler('click', '.nodes_tree', '.node_folder', function(evt) { + var nodeId = this.dataset.nodeId + var menu_item = this.closest('li.menu-item') + var url = '/admin/nodes/' + nodeId + '/toggle.html' + var list = menu_item.querySelector('.children') + var xhr = new XMLHttpRequest() + var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent - folders.forEach(function(folder){ - folder.addEventListener('click', function(evt) { - var nodeId = this.dataset.nodeId - var menu_item = this.closest('li.menu-item') - var url = '/admin/nodes/' + nodeId + '/toggle.html' - var list = menu_item.querySelector('.children') - var xhr = new XMLHttpRequest() - var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + xhr.open("PATCH", url); + xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('X-CSRF-Token', token) - xhr.open("PATCH", url); - xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); - xhr.setRequestHeader('X-CSRF-Token', token) + xhr.onload = function () { + if (xhr.readyState == 4 && xhr.status == "200") { + list.classList.toggle('folded') + menu_item.dataset.folded = menu_item.dataset.folded == 'true' ? 'false' : 'true' + Alchemy.NodeTree.displayNodeFolders(); + } else { + Alchemy.growl('error folding'); + } + } + xhr.send() + }); + }, - xhr.onload = function () { - if (xhr.readyState == 4 && xhr.status == "200") { - list.classList.toggle('folded') - menu_item.dataset.folded = menu_item.dataset.folded == 'true' ? 'false' : 'true' - Alchemy.NodeTree.displayNodeFolders(); - } else { - Alchemy.growl('error folding'); - } + registerDelegatingEventHandler: function(eventName, baseSelector, targetSelector, callback) { + var baseNode = document.querySelector(baseSelector) + baseNode.addEventListener(eventName, function(evt) { + var targets = Array.from(baseNode.querySelectorAll(targetSelector)) + var currentNode = evt.target + while (currentNode !== baseNode) { + if (targets.includes(currentNode)) { + callback.call(currentNode, evt) + return } - xhr.send() - }) + currentNode = currentNode.parentElement + } }); }, init: function() { + this.handleNodeFolders() this.displayNodeFolders() document.querySelectorAll('.nodes_tree ul.children').forEach(function (el) { From 3d4f004319002d7a02976e08ac89a0ed474230ce Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 11:05:00 +0100 Subject: [PATCH 06/14] Move reimplementation of jQuery.on to Alchemy.on We'll need to use this elsewhere. --- app/assets/javascripts/alchemy/admin.js | 1 + .../javascripts/alchemy/alchemy.node_tree.js | 17 +---------------- app/assets/javascripts/alchemy/alchemy.utils.js | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/alchemy/alchemy.utils.js diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index 6648da0b13..7b728dfd24 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -18,6 +18,7 @@ //= require sortable/Sortable.min //= require alchemy/templates //= require alchemy/alchemy.base +//= require alchemy/alchemy.utils //= require alchemy/alchemy.autocomplete //= require alchemy/alchemy.browser //= require alchemy/alchemy.buttons diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index 697ea39728..a8d46828f4 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -43,7 +43,7 @@ Alchemy.NodeTree = { }, handleNodeFolders: function() { - this.registerDelegatingEventHandler('click', '.nodes_tree', '.node_folder', function(evt) { + Alchemy.on('click', '.nodes_tree', '.node_folder', function(evt) { var nodeId = this.dataset.nodeId var menu_item = this.closest('li.menu-item') var url = '/admin/nodes/' + nodeId + '/toggle.html' @@ -68,21 +68,6 @@ Alchemy.NodeTree = { }); }, - registerDelegatingEventHandler: function(eventName, baseSelector, targetSelector, callback) { - var baseNode = document.querySelector(baseSelector) - baseNode.addEventListener(eventName, function(evt) { - var targets = Array.from(baseNode.querySelectorAll(targetSelector)) - var currentNode = evt.target - while (currentNode !== baseNode) { - if (targets.includes(currentNode)) { - callback.call(currentNode, evt) - return - } - currentNode = currentNode.parentElement - } - }); - }, - init: function() { this.handleNodeFolders() this.displayNodeFolders() diff --git a/app/assets/javascripts/alchemy/alchemy.utils.js b/app/assets/javascripts/alchemy/alchemy.utils.js new file mode 100644 index 0000000000..42ebbd610c --- /dev/null +++ b/app/assets/javascripts/alchemy/alchemy.utils.js @@ -0,0 +1,14 @@ +Alchemy.on = function (eventName, baseSelector, targetSelector, callback) { + var baseNode = document.querySelector(baseSelector) + baseNode.addEventListener(eventName, function (evt) { + var targets = Array.from(baseNode.querySelectorAll(targetSelector)) + var currentNode = evt.target + while (currentNode !== baseNode) { + if (targets.includes(currentNode)) { + callback.call(currentNode, evt) + return + } + currentNode = currentNode.parentElement + } + }); +} From 36a80cf01166d47361da23a999a11c36f0f3a51f Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 11:28:22 +0100 Subject: [PATCH 07/14] Refactor Folder template to Handlebars template --- app/assets/javascripts/alchemy/alchemy.node_tree.js | 10 +++------- app/assets/javascripts/alchemy/templates/index.js | 1 + .../javascripts/alchemy/templates/node_folder.hbs | 3 +++ 3 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 app/assets/javascripts/alchemy/templates/node_folder.hbs diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index a8d46828f4..baeb0143b2 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -25,17 +25,13 @@ Alchemy.NodeTree = { }, displayNodeFolders: function () { - var generate_link = function (node_id, folded) { - var icon = folded === "true" ? 'plus' : 'minus'; - return '' - } - document.querySelectorAll('li.menu-item').forEach(function (el) { var leftIconArea = el.querySelector('.nodes_tree-left_images') var list = el.querySelector('ul') + var node = { folded: el.dataset.folded === 'true', id: el.dataset.id } - if (list.children.length > 0 || el.dataset.folded === 'true' ) { - leftIconArea.innerHTML = generate_link(el.dataset.id, el.dataset.folded) + if (list.children.length > 0 || node.folded ) { + leftIconArea.innerHTML = HandlebarsTemplates.node_folder({ node: node }) } else { leftIconArea.innerHTML = ' ' } diff --git a/app/assets/javascripts/alchemy/templates/index.js b/app/assets/javascripts/alchemy/templates/index.js index 384b6348b5..0405d22de6 100644 --- a/app/assets/javascripts/alchemy/templates/index.js +++ b/app/assets/javascripts/alchemy/templates/index.js @@ -1,2 +1,3 @@ //= require alchemy/templates/spinner //= require alchemy/templates/page +//= require alchemy/templates/node_folder diff --git a/app/assets/javascripts/alchemy/templates/node_folder.hbs b/app/assets/javascripts/alchemy/templates/node_folder.hbs new file mode 100644 index 0000000000..d4c80680f9 --- /dev/null +++ b/app/assets/javascripts/alchemy/templates/node_folder.hbs @@ -0,0 +1,3 @@ + + + From 66759de34ac27ef25ff4fbd3c300b6a30543b2d4 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 11:41:47 +0100 Subject: [PATCH 08/14] Add tiny helper function to add Alchemy stuff to XHR --- .../javascripts/alchemy/alchemy.node_tree.js | 14 +++----------- app/assets/javascripts/alchemy/alchemy.utils.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index baeb0143b2..75b0f36616 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -1,16 +1,13 @@ Alchemy.NodeTree = { onFinishDragging: function (evt) { var url = '/api/nodes/' + evt.item.dataset.id + '/move.json' - var xhr = new XMLHttpRequest() - var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + var xhr = Alchemy.xhr('PATCH', url) var data = { target_parent_id: evt.to.dataset.nodeId, new_position: evt.newIndex }; var json = JSON.stringify(data) - xhr.open("PATCH", url); - xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); - xhr.setRequestHeader('X-CSRF-Token', token) + evt.to.parentElement.dataset.folded = 'false' evt.to.classList.remove('folded') xhr.onload = function () { @@ -44,12 +41,7 @@ Alchemy.NodeTree = { var menu_item = this.closest('li.menu-item') var url = '/admin/nodes/' + nodeId + '/toggle.html' var list = menu_item.querySelector('.children') - var xhr = new XMLHttpRequest() - var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent - - xhr.open("PATCH", url); - xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); - xhr.setRequestHeader('X-CSRF-Token', token) + var xhr = Alchemy.xhr('PATCH', url) xhr.onload = function () { if (xhr.readyState == 4 && xhr.status == "200") { diff --git a/app/assets/javascripts/alchemy/alchemy.utils.js b/app/assets/javascripts/alchemy/alchemy.utils.js index 42ebbd610c..ebe86b61cf 100644 --- a/app/assets/javascripts/alchemy/alchemy.utils.js +++ b/app/assets/javascripts/alchemy/alchemy.utils.js @@ -12,3 +12,13 @@ Alchemy.on = function (eventName, baseSelector, targetSelector, callback) { } }); } + +Alchemy.xhr = function(method, url) { + var xhr = new XMLHttpRequest() + var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + xhr.open(method, url); + xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('X-CSRF-Token', token) + + return xhr; +} From f6cd9acd3250a2c249eef68822705fb9b0a5fe6e Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 11:52:16 +0100 Subject: [PATCH 09/14] Move Admin::NodesController#toggle to API Now it also returns a bit of JSON about the toggled node. --- .../javascripts/alchemy/alchemy.node_tree.js | 2 +- .../alchemy/admin/nodes_controller.rb | 6 ---- .../alchemy/api/nodes_controller.rb | 5 +++ config/routes.rb | 7 ++-- .../alchemy/admin/nodes_controller_spec.rb | 35 ------------------ .../alchemy/api/nodes_controller_spec.rb | 36 +++++++++++++++++++ 6 files changed, 44 insertions(+), 47 deletions(-) diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index 75b0f36616..b701e039e4 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -39,7 +39,7 @@ Alchemy.NodeTree = { Alchemy.on('click', '.nodes_tree', '.node_folder', function(evt) { var nodeId = this.dataset.nodeId var menu_item = this.closest('li.menu-item') - var url = '/admin/nodes/' + nodeId + '/toggle.html' + var url = '/api/nodes/' + nodeId + '/toggle_folded.json' var list = menu_item.querySelector('.children') var xhr = Alchemy.xhr('PATCH', url) diff --git a/app/controllers/alchemy/admin/nodes_controller.rb b/app/controllers/alchemy/admin/nodes_controller.rb index 29d29ac304..912c03d2a5 100644 --- a/app/controllers/alchemy/admin/nodes_controller.rb +++ b/app/controllers/alchemy/admin/nodes_controller.rb @@ -15,12 +15,6 @@ def new ) end - def toggle - node = Node.find(params[:id]) - node.update(folded: !node.folded) - head :ok - end - private def resource_params diff --git a/app/controllers/alchemy/api/nodes_controller.rb b/app/controllers/alchemy/api/nodes_controller.rb index 351b5d200f..26d89cb2fb 100644 --- a/app/controllers/alchemy/api/nodes_controller.rb +++ b/app/controllers/alchemy/api/nodes_controller.rb @@ -10,6 +10,11 @@ def move render json: @node, serializer: NodeSerializer end + def toggle_folded + @node.update(folded: !@node.folded) + render json: @node, serializer: NodeSerializer + end + private def load_node diff --git a/config/routes.rb b/config/routes.rb index f4c7a60a04..1cf0de3483 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,11 +18,7 @@ namespace :admin, {path: Alchemy.admin_path, constraints: Alchemy.admin_constraints} do resources :contents, only: [:create] - resources :nodes do - member do - patch :toggle - end - end + resources :nodes resources :pages do resources :elements @@ -157,6 +153,7 @@ resources :nodes, only: [] do member do patch :move + patch :toggle_folded end end end diff --git a/spec/controllers/alchemy/admin/nodes_controller_spec.rb b/spec/controllers/alchemy/admin/nodes_controller_spec.rb index cf0ba30fdf..862d69b8b5 100644 --- a/spec/controllers/alchemy/admin/nodes_controller_spec.rb +++ b/spec/controllers/alchemy/admin/nodes_controller_spec.rb @@ -60,40 +60,5 @@ module Alchemy end end end - - describe '#toggle' do - context 'with expanded node' do - let(:node) { create(:alchemy_node, folded: false) } - - it "folds node" do - expect { - patch :toggle, params: { id: node.id } - }.to change { node.reload.folded }.to(true) - end - end - - context 'with folded node' do - let(:node) { create(:alchemy_node, folded: true) } - - it "expands node" do - expect { - patch :toggle, params: { id: node.id } - }.to change { node.reload.folded }.to(false) - end - - context 'with node having children' do - before do - create(:alchemy_node, parent: node) - end - - render_views - - it "returns success" do - patch :toggle, params: { id: node.id } - expect(response).to be_successful - end - end - end - end end end diff --git a/spec/requests/alchemy/api/nodes_controller_spec.rb b/spec/requests/alchemy/api/nodes_controller_spec.rb index 2d5cdf63ba..956df15f2c 100644 --- a/spec/requests/alchemy/api/nodes_controller_spec.rb +++ b/spec/requests/alchemy/api/nodes_controller_spec.rb @@ -23,5 +23,41 @@ module Alchemy expect(page_node.children).to include(url_node) end end + + + describe '#toggle_folded' do + context 'with expanded node' do + let(:node) { create(:alchemy_node, folded: false) } + + it "folds node" do + expect { + patch alchemy.toggle_folded_api_node_path(node) + }.to change { node.reload.folded }.to(true) + end + end + + context 'with folded node' do + let(:node) { create(:alchemy_node, folded: true) } + + it "expands node" do + expect { + patch alchemy.toggle_folded_api_node_path(node) + }.to change { node.reload.folded }.to(false) + end + + context 'with node having children' do + before do + create(:alchemy_node, parent: node) + end + + it "returns success" do + patch alchemy.toggle_folded_api_node_path(node) + expect(response).to be_successful + response_json = JSON.parse(response.body) + expect(response_json['id']).to eq(node.id) + end + end + end + end end end From fbd5498a46e5cf4cf1b9e71a8f3c4f6ed8b78894 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 11:59:32 +0100 Subject: [PATCH 10/14] Use programmatic paths in Node Tree JS --- app/assets/javascripts/alchemy/alchemy.node_tree.js | 4 ++-- app/views/alchemy/admin/partials/_routes.html.erb | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index b701e039e4..db2b060b43 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -1,6 +1,6 @@ Alchemy.NodeTree = { onFinishDragging: function (evt) { - var url = '/api/nodes/' + evt.item.dataset.id + '/move.json' + var url = Alchemy.routes.move_api_node_path(evt.item.dataset.id) var xhr = Alchemy.xhr('PATCH', url) var data = { target_parent_id: evt.to.dataset.nodeId, @@ -39,7 +39,7 @@ Alchemy.NodeTree = { Alchemy.on('click', '.nodes_tree', '.node_folder', function(evt) { var nodeId = this.dataset.nodeId var menu_item = this.closest('li.menu-item') - var url = '/api/nodes/' + nodeId + '/toggle_folded.json' + var url = Alchemy.routes.toggle_folded_api_node_path(nodeId) var list = menu_item.querySelector('.children') var xhr = Alchemy.xhr('PATCH', url) diff --git a/app/views/alchemy/admin/partials/_routes.html.erb b/app/views/alchemy/admin/partials/_routes.html.erb index d196af0a19..b35cb08475 100644 --- a/app/views/alchemy/admin/partials/_routes.html.erb +++ b/app/views/alchemy/admin/partials/_routes.html.erb @@ -15,6 +15,14 @@ return '<%= alchemy.fold_admin_element_path(id: 1) %>'.replace(/1/, id); }, + toggle_folded_api_node_path: function(id) { + return '<%= alchemy.toggle_folded_api_node_path(id: 1) %>'.replace(/1/, id); + }, + + move_api_node_path: function(id) { + return '<%= alchemy.move_api_node_path(id: 1) %>'.replace(/1/, id); + }, + order_admin_elements_path: '<%= alchemy.order_admin_elements_path %>', order_admin_pages_path: '<%= alchemy.order_admin_pages_path %>', link_admin_pages_path: '<%= alchemy.link_admin_pages_path %>', From 3dd1d5419f7e3ddc075f14a862ca2fc05329fe4b Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Tue, 24 Mar 2020 12:00:45 +0100 Subject: [PATCH 11/14] Add Accept: application/json to Alchemy.xhr --- app/assets/javascripts/alchemy/alchemy.utils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/alchemy/alchemy.utils.js b/app/assets/javascripts/alchemy/alchemy.utils.js index ebe86b61cf..e4341dbd14 100644 --- a/app/assets/javascripts/alchemy/alchemy.utils.js +++ b/app/assets/javascripts/alchemy/alchemy.utils.js @@ -18,6 +18,7 @@ Alchemy.xhr = function(method, url) { var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent xhr.open(method, url); xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); + xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('X-CSRF-Token', token) return xhr; From 1dba3650f6ef530826c09c532d537b06592710d9 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Wed, 25 Mar 2020 10:19:33 +0100 Subject: [PATCH 12/14] Add authorization to API Nodes Controller --- .../alchemy/api/nodes_controller.rb | 5 ++ .../alchemy/api/nodes_controller_spec.rb | 78 +++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/controllers/alchemy/api/nodes_controller.rb b/app/controllers/alchemy/api/nodes_controller.rb index 26d89cb2fb..3e61059fb5 100644 --- a/app/controllers/alchemy/api/nodes_controller.rb +++ b/app/controllers/alchemy/api/nodes_controller.rb @@ -3,6 +3,7 @@ module Alchemy class Api::NodesController < Api::BaseController before_action :load_node + before_action :authorize_access, only: [:move, :toggle_folded] def move target_parent_node = Node.find(params[:target_parent_id]) @@ -20,5 +21,9 @@ def toggle_folded def load_node @node = Node.find(params[:id]) end + + def authorize_access + authorize! :update, @node + end end end diff --git a/spec/requests/alchemy/api/nodes_controller_spec.rb b/spec/requests/alchemy/api/nodes_controller_spec.rb index 956df15f2c..2f2b6d19fd 100644 --- a/spec/requests/alchemy/api/nodes_controller_spec.rb +++ b/spec/requests/alchemy/api/nodes_controller_spec.rb @@ -10,17 +10,39 @@ module Alchemy let!(:page_node_2) { create(:alchemy_node, :with_page, parent: root_node) } let!(:url_node) { create(:alchemy_node, :with_url, parent: root_node) } - it 'returns JSON and moves the node' do - expect(page_node.children).to be_empty - expect(url_node.lft).to eq(6) - patch alchemy.move_api_node_path(url_node, format: :json), params: { - target_parent_id: page_node.id, - new_position: 0 - } - expect(response.status).to eq(200) - response_json = JSON.parse(response.body) - expect(response_json['parent_id']).to eq(page_node.id) - expect(page_node.children).to include(url_node) + context 'with authorized access' do + before do + authorize_user(:as_admin) + end + + it 'returns JSON and moves the node' do + expect(page_node.children).to be_empty + expect(url_node.lft).to eq(6) + patch alchemy.move_api_node_path(url_node, format: :json), params: { + target_parent_id: page_node.id, + new_position: 0 + } + expect(response.status).to eq(200) + response_json = JSON.parse(response.body) + expect(response_json['parent_id']).to eq(page_node.id) + expect(page_node.children).to include(url_node) + end + end + + context 'with unauthorized access' do + before do + authorize_user + end + + it 'returns an unauthorized error' do + patch alchemy.move_api_node_path(url_node, format: :json), params: { + target_parent_id: page_node.id, + new_position: 0 + } + expect(response).to be_forbidden + response_json = JSON.parse(response.body) + expect(response_json['error']).to eq('Not authorized') + end end end @@ -29,16 +51,42 @@ module Alchemy context 'with expanded node' do let(:node) { create(:alchemy_node, folded: false) } - it "folds node" do - expect { - patch alchemy.toggle_folded_api_node_path(node) - }.to change { node.reload.folded }.to(true) + context 'with authorized access' do + before do + authorize_user(:as_admin) + end + + it "folds node" do + expect { + patch alchemy.toggle_folded_api_node_path(node) + }.to change { node.reload.folded }.to(true) + end + end + + context 'with unauthorized access' do + before do + authorize_user + end + + it "returns an unauthorized error" do + expect { + patch alchemy.toggle_folded_api_node_path(node) + }.not_to change { node.reload.folded } + + expect(response).to be_forbidden + response_json = JSON.parse(response.body) + expect(response_json['error']).to eq('Not authorized') + end end end context 'with folded node' do let(:node) { create(:alchemy_node, folded: true) } + before do + authorize_user(:as_admin) + end + it "expands node" do expect { patch alchemy.toggle_folded_api_node_path(node) From c9075bbead35e7a41af78674ed79c8f4e1d4720c Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Wed, 25 Mar 2020 10:56:35 +0100 Subject: [PATCH 13/14] Set Move Cursor on Node Names To make the dragging functionality more obvious. --- app/assets/stylesheets/alchemy/nodes.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/alchemy/nodes.scss b/app/assets/stylesheets/alchemy/nodes.scss index 6d2464aca7..c35731998c 100644 --- a/app/assets/stylesheets/alchemy/nodes.scss +++ b/app/assets/stylesheets/alchemy/nodes.scss @@ -110,6 +110,7 @@ text-decoration: none; overflow: hidden; background-color: $sitemap-page-background-color; + cursor: move; &.without-status { @include border-right-radius($default-border-radius); From 36fb0aab5e9848b1ddaa9c1cbad998f5ecacfbf6 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Wed, 25 Mar 2020 10:57:01 +0100 Subject: [PATCH 14/14] Refactor Alchemy.xhr to use Promises --- .../javascripts/alchemy/alchemy.node_tree.js | 39 +++++++------------ .../javascripts/alchemy/alchemy.utils.js | 24 +++++++++++- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/alchemy/alchemy.node_tree.js b/app/assets/javascripts/alchemy/alchemy.node_tree.js index db2b060b43..9213d8bb70 100644 --- a/app/assets/javascripts/alchemy/alchemy.node_tree.js +++ b/app/assets/javascripts/alchemy/alchemy.node_tree.js @@ -1,24 +1,18 @@ Alchemy.NodeTree = { onFinishDragging: function (evt) { var url = Alchemy.routes.move_api_node_path(evt.item.dataset.id) - var xhr = Alchemy.xhr('PATCH', url) var data = { target_parent_id: evt.to.dataset.nodeId, new_position: evt.newIndex }; - var json = JSON.stringify(data) + var ajax = Alchemy.ajax('PATCH', url, data) - evt.to.parentElement.dataset.folded = 'false' - evt.to.classList.remove('folded') - xhr.onload = function () { - response_json = JSON.parse(xhr.responseText) - if (xhr.readyState == 4 && xhr.status == "200") { - Alchemy.NodeTree.displayNodeFolders() - } else { - Alchemy.growl(response_json.error, 'error'); - } - } - xhr.send(json) + ajax.then(function(response) { + Alchemy.growl('Successfully moved menu item.') + Alchemy.NodeTree.displayNodeFolders() + }).catch(function() { + Alchemy.growl(error.message || error); + }) }, displayNodeFolders: function () { @@ -41,18 +35,15 @@ Alchemy.NodeTree = { var menu_item = this.closest('li.menu-item') var url = Alchemy.routes.toggle_folded_api_node_path(nodeId) var list = menu_item.querySelector('.children') - var xhr = Alchemy.xhr('PATCH', url) + var ajax = Alchemy.ajax('PATCH', url) - xhr.onload = function () { - if (xhr.readyState == 4 && xhr.status == "200") { - list.classList.toggle('folded') - menu_item.dataset.folded = menu_item.dataset.folded == 'true' ? 'false' : 'true' - Alchemy.NodeTree.displayNodeFolders(); - } else { - Alchemy.growl('error folding'); - } - } - xhr.send() + ajax.then(function() { + list.classList.toggle('folded') + menu_item.dataset.folded = menu_item.dataset.folded == 'true' ? 'false' : 'true' + Alchemy.NodeTree.displayNodeFolders(); + }).catch(function(error){ + Alchemy.growl(error.message || error); + }); }); }, diff --git a/app/assets/javascripts/alchemy/alchemy.utils.js b/app/assets/javascripts/alchemy/alchemy.utils.js index e4341dbd14..5c35fa08e0 100644 --- a/app/assets/javascripts/alchemy/alchemy.utils.js +++ b/app/assets/javascripts/alchemy/alchemy.utils.js @@ -13,13 +13,33 @@ Alchemy.on = function (eventName, baseSelector, targetSelector, callback) { }); } -Alchemy.xhr = function(method, url) { +Alchemy.ajax = function(method, url, data) { var xhr = new XMLHttpRequest() var token = document.querySelector('meta[name="csrf-token"]').attributes.content.textContent + var promise = new Promise(function (resolve, reject) { + xhr.onload = function() { + try { + resolve({ + data: JSON.parse(xhr.responseText), + status: xhr.status + }) + } catch (error) { + reject(new Error(JSON.parse(xhr.responseText).error)) + } + }; + xhr.onerror = function() { + reject(new Error(xhr.statusText)) + } + }); xhr.open(method, url); xhr.setRequestHeader('Content-type', 'application/json; charset=utf-8'); xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('X-CSRF-Token', token) + if (data) { + xhr.send(JSON.stringify(data)) + } else { + xhr.send() + } - return xhr; + return promise }