Skip to content

Commit

Permalink
Add a quick Node select (AlchemyCMS#1821)
Browse files Browse the repository at this point in the history
* Add a quick Node select

This faster select also shows the ancestors of each node, such that one
is not confused between nodes that have similar names.

* Fix Copy-Paste error that tries assigning Pages to EssenceNodes

Prior to this commit, saving an EssenceNode by ID would never work...

* Serialize Node with Ancestors for initial selection

* Center initial selection in Alchemy::EssenceNode select

Maybe we should look at using Flexbox for this so we can center things
more easily.

* Add specs for NodesController Index Action

It's always good to have specs.
  • Loading branch information
mamhoff authored May 11, 2020
1 parent 30c2aa8 commit 0eec65a
Show file tree
Hide file tree
Showing 18 changed files with 256 additions and 39 deletions.
1 change: 1 addition & 0 deletions app/assets/javascripts/alchemy/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@
//= require alchemy/alchemy.tooltips
//= require alchemy/alchemy.trash_window
//= require alchemy/page_select
//= require alchemy/node_select
31 changes: 31 additions & 0 deletions app/assets/javascripts/alchemy/node_select.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
$.fn.alchemyNodeSelect = function(options) {
var renderNodeTemplate = (node) => HandlebarsTemplates.node({ node: node })
var queryParamsFromTerm = (term) => {
return {filter: Object.assign({ name_or_page_name_cont: term }, options.query_params)}
}
var resultsFromResponse = (response) => {
var { meta, data } = response
var more = meta.page * meta.per_page < meta.total_count
return { results: data, more: more }
}

return this.select2({
placeholder: options.placeholder,
allowClear: true,
minimumInputLength: 3,
initSelection: function (_$el, callback) {
if (options.initialSelection) {
callback(options.initialSelection)
}
},
ajax: {
url: options.url,
datatype: 'json',
quietMillis: 300,
data: queryParamsFromTerm,
results: resultsFromResponse
},
formatSelection: renderNodeTemplate,
formatResult: renderNodeTemplate
})
}
1 change: 1 addition & 0 deletions app/assets/javascripts/alchemy/templates/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//= require alchemy/templates/spinner
//= require alchemy/templates/page
//= require alchemy/templates/node_folder
//= require alchemy/templates/node
16 changes: 16 additions & 0 deletions app/assets/javascripts/alchemy/templates/node.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="node-select--node">
<i class="icon fas fa-list fa-lg"></i>
<div class="node-select--node-display_name">
<span class="node-select--node-ancestors">
{{#each node.ancestors}}
{{ this.name }} /&nbsp;
{{/each}}
</span>
<span class="node-select--node-name">
{{ node.name }}
</span>
</div>
<div class="node-select--node-url">
{{ node.url }}
</div>
</div>
1 change: 1 addition & 0 deletions app/assets/stylesheets/alchemy/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
@import "alchemy/image_library";
@import "alchemy/labels";
@import "alchemy/nodes";
@import "alchemy/node-select";
@import "alchemy/notices";
@import "alchemy/page-select";
@import "alchemy/pagination";
Expand Down
43 changes: 43 additions & 0 deletions app/assets/stylesheets/alchemy/node-select.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.node-select--node,
.node-select--node-url {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

.node-select--node {
display: flex;
align-items: center;

.icon {
margin: 0 8px 0 4px;

.select2-highlighted & {
color: $white
}
}
}

.node-select--node-name {
font-weight: bold;
}

.node-select--node-url {
margin-left: auto;
padding: $default-padding 2*$default-padding;
color: $dark-gray;
font-size: $small-font-size;

.select2-highlighted & {
color: $white
}
}

// The container of the rendered node is slightly larger than other a line of
// text, as it would be for the Alchemy::EssencePage. Reducing the padding here from 0.6em
// to 0.4em centers the content nicely.
.essence_node {
.select2-container.alchemy_selectbox .select2-choice {
padding: 0.4em 0.75em;
}
}
38 changes: 37 additions & 1 deletion app/controllers/alchemy/api/nodes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@

module Alchemy
class Api::NodesController < Api::BaseController
before_action :load_node
before_action :load_node, except: :index
before_action :authorize_access, only: [:move, :toggle_folded]

def index
@nodes = Node.all
@nodes = @nodes.includes(:parent)
@nodes = @nodes.ransack(params[:filter]).result

if params[:page]
@nodes = @nodes.page(params[:page]).per(params[:per_page])
end

render json: @nodes, adapter: :json, root: "data", meta: meta_data, include: params[:include]
end

def move
target_parent_node = Node.find(params[:target_parent_id])
@node.move_to_child_with_index(target_parent_node, params[:new_position])
Expand All @@ -25,5 +37,29 @@ def load_node
def authorize_access
authorize! :update, @node
end

def meta_data
{
total_count: total_count_value,
per_page: per_page_value,
page: page_value,
}
end

def total_count_value
params[:page] ? @nodes.total_count : @nodes.size
end

def per_page_value
if params[:page]
(params[:per_page] || Kaminari.config.default_per_page).to_i
else
@nodes.size
end
end

def page_value
params[:page] ? params[:page].to_i : 1
end
end
end
4 changes: 2 additions & 2 deletions app/models/alchemy/essence_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ class EssenceNode < BaseRecord
},
)

delegate :name, to: :node, prefix: true
delegate :name, to: :node, prefix: true, allow_nil: true

def ingredient=(node)
case node
when NODE_ID
self.node = Alchemy::Page.new(id: node)
self.node = Alchemy::Node.new(id: node)
when Alchemy::Node
self.node = node
else
Expand Down
6 changes: 5 additions & 1 deletion app/models/alchemy/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ class Node < BaseRecord
# Either the value is stored in the database
# or, if attached, the values comes from a page.
def name
read_attribute(:name).presence || page&.name
if root?
Alchemy.t(read_attribute(:name), scope: :menu_names)
else
read_attribute(:name).presence || page&.name
end
end

class << self
Expand Down
2 changes: 2 additions & 0 deletions app/serializers/alchemy/node_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ class NodeSerializer < ActiveModel::Serializer
:rgt,
:url,
:parent_id

has_many :ancestors, record_type: :node, serializer: self
end
end
6 changes: 1 addition & 5 deletions app/views/alchemy/admin/nodes/_node.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,7 @@
<% end %>
</span>
<div class="node_name">
<% if node.root? %>
<%= I18n.t(node.name, scope: [:alchemy, :menu_names]) %>
<% else %>
<%= node.name || '&nbsp;'.html_safe %>
<% end %>
<%= node.name || '&nbsp;'.html_safe %>
<span class="node_page">
<% if node.page %>
<i class="icon far fa-file"></i>
Expand Down
46 changes: 26 additions & 20 deletions app/views/alchemy/essences/_essence_node_editor.html.erb
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
<%#
Available locals:
* content (The object the essence is linked to the element)
* html_options
Please consult Alchemy::Content.rb docs for further methods on the content object
%>
<div class="content_editor essence_node" id="<%= content.dom_id %>" data-content-id="<%= content.id %>">
<%= content_label(content) %>
<%= select_tag(
content.form_field_name,
options_from_collection_for_select(
local_assigns.fetch(:options, {})[:nodes] || Alchemy::Node.where(site: Alchemy::Site.current),
:id,
:name,
content.essence.node_id
),
include_blank: t(".none"),
class: "alchemy_selectbox essence_editor_select full_width"
<%= content_tag :div,
id: essence_node_editor.dom_id,
class: essence_node_editor.css_classes,
data: essence_node_editor.data_attributes do %>
<%= content_label(essence_node_editor) %>
<%= text_field_tag(
essence_node_editor.form_field_name,
essence_node_editor.essence.node_id,
id: essence_node_editor.form_field_id,
class: 'alchemy_selectbox full_width'
) %>
</div>
<% end %>

<script>
<% query_params = essence_node_editor.settings.fetch(:query_params, {}).merge({
include: :ancestors
}) %>
$('#<%= essence_node_editor.form_field_id %>').alchemyNodeSelect({
placeholder: "<%= Alchemy.t(:search_node) %>",
url: "<%= alchemy.api_nodes_path %>",
query_params: <%== query_params.to_json %>,
<% if essence_node_editor.essence.node %>
<% serialized_node = ActiveModelSerializers::SerializableResource.new(essence_node_editor.essence.node, include: :ancestors) %>
initialSelection: <%== serialized_node.to_json %>
<% end %>
})
</script>
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
get "/pages/*urlname(.:format)" => "pages#show", as: "page"
get "/admin/pages/:id(.:format)" => "pages#show", as: "preview_page"

resources :nodes, only: [] do
resources :nodes, only: [:index] do
member do
patch :move
patch :toggle_folded
Expand Down
5 changes: 5 additions & 0 deletions spec/dummy/config/alchemy/elements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,8 @@
fixed: true
unique: true
nestable_elements: [text]

- name: menu
contents:
- name: Menu
type: EssenceNode
2 changes: 2 additions & 0 deletions spec/dummy/config/alchemy/page_layouts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
autogenerate: [headline, text, contactform]

- name: footer
elements:
- menu
layoutpage: true

- name: <%= 'erb_' + 'layout' %>
Expand Down
28 changes: 19 additions & 9 deletions spec/models/alchemy/node_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,28 @@ module Alchemy
end

describe "#name" do
context "with page attached" do
let(:node) { build_stubbed(:alchemy_node, :with_page) }
subject { node.name }
context "root node" do
let(:node) { build_stubbed(:alchemy_node, name: "main_menu") }

it "returns the name from page" do
expect(node.name).to eq(node.page.name)
end
it { is_expected.to eq("Main Menu") }
end

context "child node" do
let(:parent) { build_stubbed(:alchemy_node) }
context "with page attached" do
let(:node) { build_stubbed(:alchemy_node, :with_page, parent: parent) }

it "returns the name from page" do
expect(node.name).to eq(node.page.name)
end

context "but with name set" do
let(:node) { build_stubbed(:alchemy_node, :with_page, name: "Google") }
context "but with name set" do
let(:node) { build_stubbed(:alchemy_node, :with_page, name: "Google", parent: parent) }

it "still returns the name from name attribute" do
expect(node.name).to eq("Google")
it "still returns the name from name attribute" do
expect(node.name).to eq("Google")
end
end
end
end
Expand Down
62 changes: 62 additions & 0 deletions spec/requests/alchemy/api/nodes_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,68 @@

module Alchemy
describe Api::NodesController do
describe "#index" do
context "without a Language present" do
let(:result) { JSON.parse(response.body) }

it "returns JSON" do
get alchemy.api_nodes_path(params: {format: :json})
expect(result["data"]).to eq([])
end
end

context "with nodes present" do
let!(:node) { create(:alchemy_node, name: "lol") }
let!(:node2) { create(:alchemy_node, name: "yup") }
let(:result) { JSON.parse(response.body) }

it "returns JSON" do
get alchemy.api_nodes_path(params: {format: :json})
expect(response.status).to eq(200)
expect(response.media_type).to eq("application/json")
expect(result).to have_key("data")
end

it "returns all nodes" do
get alchemy.api_nodes_path(params: {format: :json})

expect(result["data"].size).to eq(2)
end

it "includes meta data" do
get alchemy.api_nodes_path(params: {format: :json})

expect(result["data"].size).to eq(2)
expect(result["meta"]["page"]).to eq(1)
expect(result["meta"]["per_page"]).to eq(2)
expect(result["meta"]["total_count"]).to eq(2)
end

context "with page param given" do
before do
expect(Kaminari.config).to receive(:default_per_page).at_least(:once) { 1 }
end

it "returns paginated result" do
get alchemy.api_nodes_path(params: {format: :json, page: 2})

expect(result["data"].size).to eq(1)
expect(result["meta"]["page"]).to eq(2)
expect(result["meta"]["per_page"]).to eq(1)
expect(result["meta"]["total_count"]).to eq(2)
end
end

context "with ransack query param given" do
it "returns filtered result" do
get alchemy.api_nodes_path(params: {format: :json, filter: {name_eq: "yup"}})

expect(result["data"].size).to eq(1)
end
end
end
end

describe "#move" do
let!(:root_node) { create(:alchemy_node, name: "main_menu") }
let!(:page_node) { create(:alchemy_node, :with_page, parent: root_node) }
Expand Down
Loading

0 comments on commit 0eec65a

Please sign in to comment.