From 0eec65a43daf01b4d699f48b3bd93011ff9a0792 Mon Sep 17 00:00:00 2001 From: Martin Meyerhoff Date: Mon, 11 May 2020 09:36:14 +0200 Subject: [PATCH] Add a quick Node select (#1821) * 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. --- app/assets/javascripts/alchemy/admin.js | 1 + app/assets/javascripts/alchemy/node_select.js | 31 ++++++++++ .../javascripts/alchemy/templates/index.js | 1 + .../javascripts/alchemy/templates/node.hbs | 16 +++++ app/assets/stylesheets/alchemy/admin.scss | 1 + .../stylesheets/alchemy/node-select.scss | 43 +++++++++++++ .../alchemy/api/nodes_controller.rb | 38 +++++++++++- app/models/alchemy/essence_node.rb | 4 +- app/models/alchemy/node.rb | 6 +- app/serializers/alchemy/node_serializer.rb | 2 + app/views/alchemy/admin/nodes/_node.html.erb | 6 +- .../essences/_essence_node_editor.html.erb | 46 ++++++++------ config/routes.rb | 2 +- spec/dummy/config/alchemy/elements.yml | 5 ++ spec/dummy/config/alchemy/page_layouts.yml | 2 + spec/models/alchemy/node_spec.rb | 28 ++++++--- .../alchemy/api/nodes_controller_spec.rb | 62 +++++++++++++++++++ .../alchemy/node_serializer_spec.rb | 1 + 18 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/alchemy/node_select.js create mode 100644 app/assets/javascripts/alchemy/templates/node.hbs create mode 100644 app/assets/stylesheets/alchemy/node-select.scss diff --git a/app/assets/javascripts/alchemy/admin.js b/app/assets/javascripts/alchemy/admin.js index 61eedbf56a..32c2d118e3 100644 --- a/app/assets/javascripts/alchemy/admin.js +++ b/app/assets/javascripts/alchemy/admin.js @@ -47,3 +47,4 @@ //= require alchemy/alchemy.tooltips //= require alchemy/alchemy.trash_window //= require alchemy/page_select +//= require alchemy/node_select diff --git a/app/assets/javascripts/alchemy/node_select.js b/app/assets/javascripts/alchemy/node_select.js new file mode 100644 index 0000000000..642d606c16 --- /dev/null +++ b/app/assets/javascripts/alchemy/node_select.js @@ -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 + }) +} diff --git a/app/assets/javascripts/alchemy/templates/index.js b/app/assets/javascripts/alchemy/templates/index.js index 0405d22de6..acbaf8b3db 100644 --- a/app/assets/javascripts/alchemy/templates/index.js +++ b/app/assets/javascripts/alchemy/templates/index.js @@ -1,3 +1,4 @@ //= require alchemy/templates/spinner //= require alchemy/templates/page //= require alchemy/templates/node_folder +//= require alchemy/templates/node diff --git a/app/assets/javascripts/alchemy/templates/node.hbs b/app/assets/javascripts/alchemy/templates/node.hbs new file mode 100644 index 0000000000..50d487d912 --- /dev/null +++ b/app/assets/javascripts/alchemy/templates/node.hbs @@ -0,0 +1,16 @@ +
+ +
+ + {{#each node.ancestors}} + {{ this.name }} /  + {{/each}} + + + {{ node.name }} + +
+
+ {{ node.url }} +
+
diff --git a/app/assets/stylesheets/alchemy/admin.scss b/app/assets/stylesheets/alchemy/admin.scss index 16e2418dc8..e657f50327 100644 --- a/app/assets/stylesheets/alchemy/admin.scss +++ b/app/assets/stylesheets/alchemy/admin.scss @@ -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"; diff --git a/app/assets/stylesheets/alchemy/node-select.scss b/app/assets/stylesheets/alchemy/node-select.scss new file mode 100644 index 0000000000..ebccde55db --- /dev/null +++ b/app/assets/stylesheets/alchemy/node-select.scss @@ -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; + } +} diff --git a/app/controllers/alchemy/api/nodes_controller.rb b/app/controllers/alchemy/api/nodes_controller.rb index 3e61059fb5..766f85ce7a 100644 --- a/app/controllers/alchemy/api/nodes_controller.rb +++ b/app/controllers/alchemy/api/nodes_controller.rb @@ -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]) @@ -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 diff --git a/app/models/alchemy/essence_node.rb b/app/models/alchemy/essence_node.rb index 031f9ff1ef..8db0c474c0 100644 --- a/app/models/alchemy/essence_node.rb +++ b/app/models/alchemy/essence_node.rb @@ -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 diff --git a/app/models/alchemy/node.rb b/app/models/alchemy/node.rb index 6965b72fb4..9bf142b450 100644 --- a/app/models/alchemy/node.rb +++ b/app/models/alchemy/node.rb @@ -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 diff --git a/app/serializers/alchemy/node_serializer.rb b/app/serializers/alchemy/node_serializer.rb index a260fba3b9..95fc2f8bd1 100644 --- a/app/serializers/alchemy/node_serializer.rb +++ b/app/serializers/alchemy/node_serializer.rb @@ -8,5 +8,7 @@ class NodeSerializer < ActiveModel::Serializer :rgt, :url, :parent_id + + has_many :ancestors, record_type: :node, serializer: self end end diff --git a/app/views/alchemy/admin/nodes/_node.html.erb b/app/views/alchemy/admin/nodes/_node.html.erb index 1615497ed0..143473bc0b 100644 --- a/app/views/alchemy/admin/nodes/_node.html.erb +++ b/app/views/alchemy/admin/nodes/_node.html.erb @@ -47,11 +47,7 @@ <% end %>
- <% if node.root? %> - <%= I18n.t(node.name, scope: [:alchemy, :menu_names]) %> - <% else %> - <%= node.name || ' '.html_safe %> - <% end %> + <%= node.name || ' '.html_safe %> <% if node.page %> diff --git a/app/views/alchemy/essences/_essence_node_editor.html.erb b/app/views/alchemy/essences/_essence_node_editor.html.erb index 0dee2f0e82..7e7fc77f8e 100644 --- a/app/views/alchemy/essences/_essence_node_editor.html.erb +++ b/app/views/alchemy/essences/_essence_node_editor.html.erb @@ -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 -%> -
- <%= 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' ) %> -
+<% end %> + + diff --git a/config/routes.rb b/config/routes.rb index b3c5abe012..30aad4e77a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/dummy/config/alchemy/elements.yml b/spec/dummy/config/alchemy/elements.yml index 3d2d940ef5..e971295e1a 100644 --- a/spec/dummy/config/alchemy/elements.yml +++ b/spec/dummy/config/alchemy/elements.yml @@ -160,3 +160,8 @@ fixed: true unique: true nestable_elements: [text] + +- name: menu + contents: + - name: Menu + type: EssenceNode diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index 6ef6402b3f..df083b0712 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -39,6 +39,8 @@ autogenerate: [headline, text, contactform] - name: footer + elements: + - menu layoutpage: true - name: <%= 'erb_' + 'layout' %> diff --git a/spec/models/alchemy/node_spec.rb b/spec/models/alchemy/node_spec.rb index 00b4600a3c..1919dcc047 100644 --- a/spec/models/alchemy/node_spec.rb +++ b/spec/models/alchemy/node_spec.rb @@ -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 diff --git a/spec/requests/alchemy/api/nodes_controller_spec.rb b/spec/requests/alchemy/api/nodes_controller_spec.rb index ba79ae0eb6..f89407edb9 100644 --- a/spec/requests/alchemy/api/nodes_controller_spec.rb +++ b/spec/requests/alchemy/api/nodes_controller_spec.rb @@ -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) } diff --git a/spec/serializers/alchemy/node_serializer_spec.rb b/spec/serializers/alchemy/node_serializer_spec.rb index 13d481ca2c..517ed69fcb 100644 --- a/spec/serializers/alchemy/node_serializer_spec.rb +++ b/spec/serializers/alchemy/node_serializer_spec.rb @@ -16,6 +16,7 @@ "parent_id" => node.parent_id, "name" => node.name, "url" => node.url, + "ancestors" => [], ) end end