From 41e3c41b31d3c108b1aa50fd46c90874b47246c7 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 29 Oct 2019 22:09:20 +0100 Subject: [PATCH 1/9] Add nodes --- app/models/alchemy/node.rb | 39 +++++++ config/locales/alchemy.en.yml | 7 ++ .../20191029212236_create_alchemy_nodes.rb | 24 ++++ .../test_support/factories/node_factory.rb | 23 ++++ .../20191029212236_create_alchemy_nodes.rb | 1 + spec/dummy/db/schema.rb | 30 ++++- spec/models/alchemy/node_spec.rb | 108 ++++++++++++++++++ 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 app/models/alchemy/node.rb create mode 100644 db/migrate/20191029212236_create_alchemy_nodes.rb create mode 100644 lib/alchemy/test_support/factories/node_factory.rb create mode 120000 spec/dummy/db/migrate/20191029212236_create_alchemy_nodes.rb create mode 100644 spec/models/alchemy/node_spec.rb diff --git a/app/models/alchemy/node.rb b/app/models/alchemy/node.rb new file mode 100644 index 0000000000..d07e12a785 --- /dev/null +++ b/app/models/alchemy/node.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Alchemy + class Node < BaseRecord + VALID_URL_REGEX = /\A(\/|\D[a-z\+\d\.\-]+:)/ + + acts_as_nested_set scope: 'language_id', touch: true + stampable stamper_class_name: Alchemy.user_class_name + + belongs_to :language, class_name: 'Alchemy::Language' + belongs_to :page, class_name: 'Alchemy::Page', optional: true + + validates :url, format: { with: VALID_URL_REGEX }, unless: -> { url.nil? } + + # Returns the name + # + # 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 + end + + # Returns the url + # + # Either the value is stored in the database, aka. an external url. + # Or, if attached, the values comes from a page. + def url + page && "/#{page.urlname}" || read_attribute(:url).presence + end + + def to_partial_path + "#{view_folder_name}/wrapper" + end + + def view_folder_name + "alchemy/menus/#{name.parameterize.underscore}" + end + end +end diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index 9582c3e451..ae464191f3 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -762,6 +762,13 @@ en: code: ISO Code alchemy/legacy_page_url: urlname: "URL path" + alchemy/node: + name: "Name" + title: "Title" + nofollow: "Search engine must not follow" + url: "URL" + page: "Page" + external: "Open link in new tab" alchemy/page: created_at: "Created at" language: "Language" diff --git a/db/migrate/20191029212236_create_alchemy_nodes.rb b/db/migrate/20191029212236_create_alchemy_nodes.rb new file mode 100644 index 0000000000..70ba5332ee --- /dev/null +++ b/db/migrate/20191029212236_create_alchemy_nodes.rb @@ -0,0 +1,24 @@ +class CreateAlchemyNodes < ActiveRecord::Migration[5.0] + def change + create_table :alchemy_nodes do |t| + t.string :name + t.string :title + t.string :url + t.boolean :nofollow, null: false, default: false + t.boolean :external, null: false, default: false + t.boolean :folded, null: false, default: false + + t.integer :parent_id, index: true + t.integer :lft, null: false, index: true + t.integer :rgt, null: false, index: true + t.integer :depth, null: false, default: 0 + + t.references :page, foreign_key: { to_table: :alchemy_pages, on_delete: :cascade } + t.references :language, null: false, foreign_key: { to_table: :alchemy_languages } + t.references :creator, index: true + t.references :updater, index: true + + t.timestamps + end + end +end diff --git a/lib/alchemy/test_support/factories/node_factory.rb b/lib/alchemy/test_support/factories/node_factory.rb new file mode 100644 index 0000000000..e031e43688 --- /dev/null +++ b/lib/alchemy/test_support/factories/node_factory.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'factory_bot' +require 'alchemy/test_support/factories/language_factory' +require 'alchemy/test_support/factories/page_factory' + +FactoryBot.define do + factory :alchemy_node, class: 'Alchemy::Node' do + language { Alchemy::Language.default } + + trait :with_name do + name { 'A Node' } + end + + trait :with_page do + association :page, factory: :alchemy_page + end + + trait :with_url do + url { 'https://example.com' } + end + end +end diff --git a/spec/dummy/db/migrate/20191029212236_create_alchemy_nodes.rb b/spec/dummy/db/migrate/20191029212236_create_alchemy_nodes.rb new file mode 120000 index 0000000000..8acd47089e --- /dev/null +++ b/spec/dummy/db/migrate/20191029212236_create_alchemy_nodes.rb @@ -0,0 +1 @@ +../../../../db/migrate/20191029212236_create_alchemy_nodes.rb \ No newline at end of file diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index e46c70ecb7..14cf121075 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_10_16_073858) do +ActiveRecord::Schema.define(version: 2019_10_29_212236) do create_table "alchemy_attachments", force: :cascade do |t| t.string "name" @@ -204,6 +204,32 @@ t.index ["urlname"], name: "index_alchemy_legacy_page_urls_on_urlname" end + create_table "alchemy_nodes", force: :cascade do |t| + t.string "name" + t.string "title" + t.string "url" + t.boolean "nofollow", default: false, null: false + t.boolean "external", default: false, null: false + t.boolean "folded", default: false, null: false + t.integer "parent_id" + t.integer "lft", null: false + t.integer "rgt", null: false + t.integer "depth", default: 0, null: false + t.integer "page_id" + t.integer "language_id", null: false + t.integer "creator_id" + t.integer "updater_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_alchemy_nodes_on_creator_id" + t.index ["language_id"], name: "index_alchemy_nodes_on_language_id" + t.index ["lft"], name: "index_alchemy_nodes_on_lft" + t.index ["page_id"], name: "index_alchemy_nodes_on_page_id" + t.index ["parent_id"], name: "index_alchemy_nodes_on_parent_id" + t.index ["rgt"], name: "index_alchemy_nodes_on_rgt" + t.index ["updater_id"], name: "index_alchemy_nodes_on_updater_id" + end + create_table "alchemy_pages", force: :cascade do |t| t.string "name" t.string "urlname" @@ -331,4 +357,6 @@ add_foreign_key "alchemy_contents", "alchemy_elements", column: "element_id", on_update: :cascade, on_delete: :cascade add_foreign_key "alchemy_elements", "alchemy_pages", column: "page_id", on_update: :cascade, on_delete: :cascade add_foreign_key "alchemy_essence_pages", "alchemy_pages", column: "page_id" + add_foreign_key "alchemy_nodes", "alchemy_languages", column: "language_id" + add_foreign_key "alchemy_nodes", "alchemy_pages", column: "page_id", on_delete: :cascade end diff --git a/spec/models/alchemy/node_spec.rb b/spec/models/alchemy/node_spec.rb new file mode 100644 index 0000000000..07cfe33d7e --- /dev/null +++ b/spec/models/alchemy/node_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Alchemy + describe Node do + it "is only valid with language given" do + expect(Node.new).to be_invalid + expect(build(:alchemy_node)).to be_valid + end + + describe '#url' do + it 'is valid with leading slash' do + expect(build(:alchemy_node, url: '/something')).to be_valid + end + + it 'is invalid without leading slash' do + expect(build(:alchemy_node, url: 'something')).to be_invalid + end + + it 'is valid with leading protocol scheme' do + expect(build(:alchemy_node, url: 'i2+ts-z.app:widget.io')).to be_valid + end + + context 'with page attached' do + let(:node) { create(:alchemy_node, :with_page) } + + it "returns the url from page" do + expect(node.url).to eq("/#{node.page.urlname}") + end + + context 'and with url set' do + let(:node) { build(:alchemy_node, :with_page, url: 'http://google.com') } + + it "still returns the url from the page" do + expect(node.url).to eq("/#{node.page.urlname}") + end + end + end + + context 'without page attached' do + let(:node) { build(:alchemy_node, url: 'http://google.com') } + + it "returns the url from url attribute" do + expect(node.url).to eq('http://google.com') + end + + context 'and without url set' do + let(:node) { build(:alchemy_node) } + + it do + expect(node.url).to be_nil + end + end + end + end + + describe '#name' do + context 'with page attached' do + let(:node) { build_stubbed(:alchemy_node, :with_page) } + + 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') } + + it "still returns the name from name attribute" do + expect(node.name).to eq('Google') + end + end + end + + context 'without page attached' do + let(:node) { build_stubbed(:alchemy_node, name: 'Google') } + + it "returns the name from name attribute" do + expect(node.name).to eq('Google') + end + + context 'and without name set' do + let(:node) { build_stubbed(:alchemy_node) } + + it do + expect(node.name).to be_nil + end + end + end + end + + describe '#to_partial_path' do + let(:node) { build(:alchemy_node, name: 'Main Menu') } + + it 'returns the path to the menu wrapper partial' do + expect(node.to_partial_path).to eq('alchemy/menus/main_menu/wrapper') + end + end + + describe '#view_folder_name' do + let(:node) { build(:alchemy_node, name: 'Main Menu') } + + it 'returns the path to the menu view folder' do + expect(node.view_folder_name).to eq('alchemy/menus/main_menu') + end + end + end +end From c75ec862743ff3d553b3af2d2f6cb87d603f9bd0 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 1 Jul 2014 21:48:10 +0200 Subject: [PATCH 2/9] Adds nodes controller and admin views. --- .../alchemy/alchemy.base.js.coffee | 2 +- app/assets/stylesheets/alchemy/admin.scss | 1 + app/assets/stylesheets/alchemy/forms.scss | 7 +- app/assets/stylesheets/alchemy/nodes.scss | 154 ++++++++++++++++++ app/assets/stylesheets/alchemy/selects.scss | 4 + .../alchemy/admin/nodes_controller.rb | 43 +++++ app/models/alchemy/node.rb | 8 + app/views/alchemy/admin/nodes/_form.html.erb | 41 +++++ app/views/alchemy/admin/nodes/_node.html.erb | 87 ++++++++++ app/views/alchemy/admin/nodes/edit.html.erb | 1 + app/views/alchemy/admin/nodes/index.html.erb | 58 +++++++ app/views/alchemy/admin/nodes/new.html.erb | 1 + config/alchemy/modules.yml | 17 +- config/locales/alchemy.en.yml | 16 +- config/routes.rb | 6 + lib/alchemy/permissions.rb | 2 + .../alchemy/admin/nodes_controller_spec.rb | 99 +++++++++++ spec/models/alchemy/node_spec.rb | 20 +++ 18 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 app/assets/stylesheets/alchemy/nodes.scss create mode 100644 app/controllers/alchemy/admin/nodes_controller.rb create mode 100644 app/views/alchemy/admin/nodes/_form.html.erb create mode 100644 app/views/alchemy/admin/nodes/_node.html.erb create mode 100644 app/views/alchemy/admin/nodes/edit.html.erb create mode 100644 app/views/alchemy/admin/nodes/index.html.erb create mode 100644 app/views/alchemy/admin/nodes/new.html.erb create mode 100644 spec/controllers/alchemy/admin/nodes_controller_spec.rb diff --git a/app/assets/javascripts/alchemy/alchemy.base.js.coffee b/app/assets/javascripts/alchemy/alchemy.base.js.coffee index 6458d2de0c..def8c9c364 100644 --- a/app/assets/javascripts/alchemy/alchemy.base.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.base.js.coffee @@ -68,7 +68,7 @@ $.extend Alchemy, Alchemy.setElementDirty $element false - # Initializes all select tag with .alchemy_selectbox class as selectBoxIt instance + # Initializes all select tag with .alchemy_selectbox class as select2 instance # Pass a jQuery scope to only init a subset of selectboxes. SelectBox: (scope) -> $("select.alchemy_selectbox", scope).select2 diff --git a/app/assets/stylesheets/alchemy/admin.scss b/app/assets/stylesheets/alchemy/admin.scss index e63b96f752..013e889b4d 100644 --- a/app/assets/stylesheets/alchemy/admin.scss +++ b/app/assets/stylesheets/alchemy/admin.scss @@ -30,6 +30,7 @@ @import "alchemy/icons"; @import "alchemy/image_library"; @import "alchemy/labels"; +@import "alchemy/nodes"; @import "alchemy/notices"; @import "alchemy/pagination"; @import "alchemy/preview_window"; diff --git a/app/assets/stylesheets/alchemy/forms.scss b/app/assets/stylesheets/alchemy/forms.scss index 9970a0ab44..70fd44458f 100644 --- a/app/assets/stylesheets/alchemy/forms.scss +++ b/app/assets/stylesheets/alchemy/forms.scss @@ -49,11 +49,8 @@ form { line-height: 16px; } - &.select, &.grouped_select { - - .select2-container { - margin: 4px 0; - } + .select2-container { + margin: 4px 0; } &.boolean { diff --git a/app/assets/stylesheets/alchemy/nodes.scss b/app/assets/stylesheets/alchemy/nodes.scss new file mode 100644 index 0000000000..6e2d4790e8 --- /dev/null +++ b/app/assets/stylesheets/alchemy/nodes.scss @@ -0,0 +1,154 @@ +.nodes_tree.list { + margin: 2em 0; + + &.sorting { + padding-top: 100px; + + .page_icon { + cursor: move + } + } + + .sitemap_node-level_0 { + + > .node_name { + font-weight: bold; + } + } + + .node_page, + .node_url { + width: 200px; + max-width: 45%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + > a { + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + .external & { + max-width: 90%; + } + } + } + + .node_page { + padding: 0 8px; + margin-left: auto; + } + + .node_url { + display: flex; + align-items: center; + padding: 0 2*$default-padding; + white-space: nowrap; + background-color: $sitemap-info-background-color; + line-height: $sitemap-line-height; + font-size: $small-font-size; + @include border-right-radius($default-border-radius); + + > i { + margin-left: auto; + padding-left: $default-padding; + } + } + + .node_folder { + cursor: pointer; + } + + ul { + margin: 0; + padding: 0; + } + + li { + line-height: $sitemap-line-height; + padding-left: $default-padding; + + li { + padding-left: $sitemap-line-height; + } + } +} + +#node_filter_result { + display: none; + margin-left: 2*$default-margin; +} + +.sitemap_node { + margin: 3*$default-margin 0; + transition: background-color $transition-duration; + + &.highlight { + background-color: $sitemap-highlight-color; + } + + &.no-match .sitemap_pagename_link { + color: $medium-gray; + } + + &:hover { + background-color: $sitemap-page-hover-color; + border-radius: $default-border-radius; + } + + .node_name { + display: flex; + justify-content: space-between; + @include border-left-radius($default-border-radius); + padding: 0 0 0 10px; + margin: 2px; + text-decoration: none; + overflow: hidden; + background-color: $sitemap-page-background-color; + + &.without-status { + @include border-right-radius($default-border-radius); + } + + &.inactive { + color: #656565; + } + } +} + +.nodes_tree-left_images { + position: relative; + width: 32px; + line-height: $sitemap-line-height; + float: left; + padding: 0 2*$default-padding; + text-align: center; +} + +.nodes_tree-right_tools { + height: $sitemap-line-height; + padding: 0 2*$default-padding; + float: right; + + > a { + float: left; + width: $sitemap-line-height; + height: $sitemap-line-height; + line-height: $sitemap-line-height; + text-align: center; + margin: 0; + + &.disabled .icon { + opacity: 0.25; + filter: grayscale(100%); + } + } + + .icon.blank { + margin-left: 2px; + float: left; + margin-top: 3px; + margin-right: 3px; + } +} diff --git a/app/assets/stylesheets/alchemy/selects.scss b/app/assets/stylesheets/alchemy/selects.scss index 326403a8db..8a2cf97bb6 100644 --- a/app/assets/stylesheets/alchemy/selects.scss +++ b/app/assets/stylesheets/alchemy/selects.scss @@ -40,6 +40,10 @@ select { font-weight: normal; text-align: left; + .select2-chosen { + overflow: visible; + } + .select2-arrow { top: 0; width: $form-field-height; diff --git a/app/controllers/alchemy/admin/nodes_controller.rb b/app/controllers/alchemy/admin/nodes_controller.rb new file mode 100644 index 0000000000..6de40115a3 --- /dev/null +++ b/app/controllers/alchemy/admin/nodes_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Alchemy + module Admin + class NodesController < Admin::ResourcesController + def index + @root_nodes = Node.language_root_nodes + end + + def new + @node = Node.new( + parent_id: params[:parent_id], + language: Language.current + ) + end + + 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 + end + + private + + def resource_params + params.require(:node).permit( + :parent_id, + :language_id, + :page_id, + :name, + :url, + :title, + :nofollow, + :external + ) + end + end + end +end diff --git a/app/models/alchemy/node.rb b/app/models/alchemy/node.rb index d07e12a785..e5682547ec 100644 --- a/app/models/alchemy/node.rb +++ b/app/models/alchemy/node.rb @@ -20,6 +20,14 @@ def name read_attribute(:name).presence || page&.name end + class << self + # Returns all root nodes for current language + def language_root_nodes + raise 'No language found' if Language.current.nil? + roots.where(language_id: Language.current.id) + end + end + # Returns the url # # Either the value is stored in the database, aka. an external url. diff --git a/app/views/alchemy/admin/nodes/_form.html.erb b/app/views/alchemy/admin/nodes/_form.html.erb new file mode 100644 index 0000000000..bd4f79f805 --- /dev/null +++ b/app/views/alchemy/admin/nodes/_form.html.erb @@ -0,0 +1,41 @@ +<%= alchemy_form_for([:admin, node]) do |f| %> + <%= f.input :name, input_html: { + autofocus: true, + value: node.page && node.read_attribute(:name).blank? ? nil : node.name, + placeholder: node.page ? node.page.name : nil + } %> + <% unless node.root? %> + <%= f.input :page_id, label: Alchemy::Page.model_name.human, input_html: { class: 'alchemy_selectbox' } %> + <%= f.input :url, input_html: { disabled: node.page }, hint: Alchemy.t(:node_url_hint) %> + <%= f.input :title %> + <%= f.input :nofollow %> + <%= f.input :external %> + <%= f.hidden_field :parent_id %> + <% end %> + <%= f.hidden_field :language_id %> + <%= f.submit button_label %> +<% end %> + + diff --git a/app/views/alchemy/admin/nodes/_node.html.erb b/app/views/alchemy/admin/nodes/_node.html.erb new file mode 100644 index 0000000000..341ec8c8e6 --- /dev/null +++ b/app/views/alchemy/admin/nodes/_node.html.erb @@ -0,0 +1,87 @@ +
  • + <%= 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) %> + <%= link_to_dialog( + render_icon(:edit), + alchemy.edit_admin_node_path(node), + { + title: node.root? ? Alchemy.t(:edit_menu) : Alchemy.t(:edit_node), + size: node.root? ? '450x120' : '450x360' + }, + title: node.root? ? Alchemy.t(:edit_menu) : Alchemy.t(:edit_node) + ) %> + <% end %> + <% if can?(:destroy, node) %> + <%= link_to_confirm_dialog( + render_icon(:minus), + node.root? ? Alchemy.t(:confirm_to_delete_menu) : Alchemy.t(:confirm_to_delete_node), + url_for( + controller: 'nodes', + action: 'destroy', + id: node.id + ), + { + title: node.root? ? Alchemy.t(:delete_menu) : Alchemy.t(:delete_node) + } + ) %> + <% end %> + <% if can?(:create, Alchemy::Node) %> + <%= link_to_dialog( + render_icon(:plus), + alchemy.new_admin_node_path(parent_id: node.id), + { + title: Alchemy.t(:create_node), + size: '450x360', + overflow: true + }, + title: Alchemy.t(:create_node) + ) %> + <% end %> + +
    + <%= node.name || ' '.html_safe %> + + <% if node.page %> + +   + <%= link_to [:edit, :admin, node.page], title: Alchemy.t(:edit_page) do %> + <%= node.page.name %> + <% end %> + <% end %> + + <% if node.url %> + + <%= link_to node.url, node.url, target: '_blank', title: node.url %> + <% if node.external? %> + + <% end %> + + <% end %> +
    + <% end %> + <% if node.children.any? %> +
      + <% unless node.folded? %> + <%= render partial: 'node', collection: node.children.includes(:page, :children) %> + <% end %> +
    + <% end %> +
  • diff --git a/app/views/alchemy/admin/nodes/edit.html.erb b/app/views/alchemy/admin/nodes/edit.html.erb new file mode 100644 index 0000000000..ca1a427c9e --- /dev/null +++ b/app/views/alchemy/admin/nodes/edit.html.erb @@ -0,0 +1 @@ +<%= render 'form', node: @node, button_label: Alchemy.t(:save) %> \ No newline at end of file diff --git a/app/views/alchemy/admin/nodes/index.html.erb b/app/views/alchemy/admin/nodes/index.html.erb new file mode 100644 index 0000000000..dfbcaccbb1 --- /dev/null +++ b/app/views/alchemy/admin/nodes/index.html.erb @@ -0,0 +1,58 @@ +<% content_for(:title) do %> + <%= Alchemy.t(:menus, scope: 'modules') %> +<% end %> + +<% content_for(:toolbar) do %> +
    + <%= render 'alchemy/admin/partials/site_select' %> + <%= render 'alchemy/admin/partials/language_tree_select' %> + <%= toolbar_button( + icon: 'plus', + label: Alchemy.t(:create_menu), + url: alchemy.new_admin_node_path, + hotkey: 'alt+n', + dialog_options: { + title: Alchemy.t(:create_menu), + size: '450x120' + }, + if_permitted_to: [:create, Alchemy::Node] + ) %> +
    +<% end %> + +
    +

    + <% if @root_nodes.any? %> + <% @root_nodes.each do |root_node| %> +
      + <%= render 'node', node: root_node %> +
    + <% end %> + <% else %> +
    + <%= render_message do %> + <%= Alchemy.t(:no_resource_found) % { resource: Alchemy.t(:menu) } %> + <% end %> + <%= render 'form', node: Alchemy::Node.new(language: Alchemy::Language.current), button_label: Alchemy.t(:create) %> +
    + <% end %> +
    + + diff --git a/app/views/alchemy/admin/nodes/new.html.erb b/app/views/alchemy/admin/nodes/new.html.erb new file mode 100644 index 0000000000..6c4bf359e7 --- /dev/null +++ b/app/views/alchemy/admin/nodes/new.html.erb @@ -0,0 +1 @@ +<%= render 'form', node: @node, button_label: Alchemy.t(:create) %> \ No newline at end of file diff --git a/config/alchemy/modules.yml b/config/alchemy/modules.yml index d48c3eba04..dc8414a3b4 100644 --- a/config/alchemy/modules.yml +++ b/config/alchemy/modules.yml @@ -28,9 +28,18 @@ - controller: 'alchemy/admin/pages' action: edit -- name: languages +- name: menus engine_name: alchemy position: 3 + navigation: + name: 'modules.menus' + controller: 'alchemy/admin/nodes' + action: index + icon: list-ul + +- name: languages + engine_name: alchemy + position: 4 navigation: name: 'modules.languages' controller: 'alchemy/admin/languages' @@ -39,7 +48,7 @@ - name: sites engine_name: alchemy - position: 4 + position: 5 navigation: name: 'modules.sites' controller: 'alchemy/admin/sites' @@ -48,7 +57,7 @@ - name: tags engine_name: alchemy - position: 5 + position: 6 navigation: name: 'modules.tags' controller: 'alchemy/admin/tags' @@ -57,7 +66,7 @@ - name: archive engine_name: alchemy - position: 6 + position: 7 navigation: controller: 'alchemy/admin/pictures' action: index diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index ae464191f3..73b6aea1ab 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -320,6 +320,8 @@ en: confirm_to_delete_image: "Do you really want to delete this image from server?" confirm_to_delete_image_from_server: "Do you really want to delete this image from the server?" confirm_to_delete_images_from_server: "Do you really want to delete these images from the server?" + confirm_to_delete_menu: "Do you really want to delete this menu?" + confirm_to_delete_node: "Do you really want to delete this menu node?" confirm_to_delete_page: "Do you really want to delete this page? All its elements (even trashed ones) will get lost!" content_essence_not_found: "Content essence not found" content_not_found: "Field for content not present." @@ -335,12 +337,16 @@ en: "Create language": "Create a new language" "Create site": "Create a new site" create_language_tree_heading: "Create empty language tree" + create_menu: "Add a menu" + create_node: "Add a menu node" create_page: "Create a new subpage" currently_edited_by: "This page is locked by" cut_element: "Cut this element." delete_file: "Delete this file from server." delete_image: "Remove this image" delete_language: "Delete this language" + delete_menu: "Delete this menu" + delete_node: "Delete this menu node" delete_page: "Delete this page" delete_tag: 'Delete tag' document: "File" @@ -351,6 +357,8 @@ en: edit_file_properties: "Edit file properties." edit_image_properties: "Edit image properties." edit_language: "Edit language" + edit_menu: "Edit menu" + edit_node: "Edit menu node" edit_page: "Edit this page" edit_page_properties: "Edit page properties" edit_tag: 'Edit tag' @@ -418,6 +426,7 @@ en: male: "Male" me: "Me" medium_thumbnails: "Medium thumbnails" + menu: Menu meta_data: "Meta-Data" meta_description: "Meta-Description" meta_keywords: "Meta-Keywords" @@ -428,6 +437,7 @@ en: languages: "Languages" layoutpages: "Global Pages" library: "Library" + menus: "Menus" pages: "Pages" tags: "Tags" sites: "Sites" @@ -435,7 +445,7 @@ en: users: "Users" name: "Name" names: "Names" - navigation_name: "Navigation name" + node_url_hint: "Please use either a leading slash (/) or an url with protocol (ie. https:)" no_image_for_cropper_found: "No image found. Please save the element first." no: "No" "no pages": "no pages" @@ -445,6 +455,7 @@ en: no_files_in_archive: "You do not have any files in your archive." no_images_in_archive: "You don't have any images in your archive." no_more_elements_to_add: "No more elements available." + no_resource_found: "No %{resource} found. Please add your first one below." no_search_results: "Your search did not return any results." "not a valid image": "This is not an valid image." "or": 'or' @@ -704,6 +715,9 @@ en: alchemy/language: one: "Language" other: "Languages" + alchemy/node: + one: "Menu node" + other: "Menu nodes" alchemy/page: one: "Page" other: "Pages" diff --git a/config/routes.rb b/config/routes.rb index b4a1479228..2353d5b4c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,12 @@ 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 :pages do resources :elements collection do diff --git a/lib/alchemy/permissions.rb b/lib/alchemy/permissions.rb index 0e86878355..951a6c118a 100644 --- a/lib/alchemy/permissions.rb +++ b/lib/alchemy/permissions.rb @@ -95,6 +95,7 @@ def alchemy_author_rules :alchemy_admin_attachments, :alchemy_admin_dashboard, :alchemy_admin_layoutpages, + :alchemy_admin_nodes, :alchemy_admin_pages, :alchemy_admin_pictures, :alchemy_admin_tags, @@ -116,6 +117,7 @@ def alchemy_author_rules can :manage, Alchemy::EssenceFile can :manage, Alchemy::EssencePicture can :manage, Alchemy::LegacyPageUrl + can :manage, Alchemy::Node can :read, Alchemy::Picture can [:read, :autocomplete], Alchemy::Tag can(:edit_content, Alchemy::Page) { |p| p.editable_by?(@user) } diff --git a/spec/controllers/alchemy/admin/nodes_controller_spec.rb b/spec/controllers/alchemy/admin/nodes_controller_spec.rb new file mode 100644 index 0000000000..83bf187953 --- /dev/null +++ b/spec/controllers/alchemy/admin/nodes_controller_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module Alchemy + describe Admin::NodesController do + routes { Alchemy::Engine.routes } + + before do + authorize_user(:as_admin) + end + + describe '#index' do + context 'if root nodes present' do + let!(:root_node) { create(:alchemy_node) } + let!(:child_node) { create(:alchemy_node, parent_id: root_node.id) } + + it "loads only root nodes from current language" do + get :index + expect(assigns('root_nodes').to_a).to eq([root_node]) + expect(assigns('root_nodes').to_a).to_not eq([child_node]) + end + end + end + + describe '#new' do + it "sets the current language on new node" do + get :new + expect(assigns('node').language).to eq(Language.current) + end + + context 'with parent id in params' do + it "sets it to new node" do + get :new, params: { parent_id: 1 } + expect(assigns('node').parent_id).to eq(1) + end + end + end + + describe '#create' do + context 'with valid params' do + let(:language) { create(:alchemy_language) } + + it "creates node and redirects to index" do + expect { + post :create, params: { node: { name: 'Node', language_id: language.id } } + }.to change { Alchemy::Node.count }.by(1) + expect(response).to redirect_to(admin_nodes_path) + end + end + end + + describe '#update' do + let(:node) { create(:alchemy_node) } + + context 'with valid params' do + it "redirects to nodes path" do + put :update, params: { id: node.id, node: { name: 'Node'} } + expect(response).to redirect_to(admin_nodes_path) + 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 nodes children" do + patch :toggle, params: { id: node.id } + expect(response.body).to have_selector('li .sitemap_node') + end + end + end + end + end +end diff --git a/spec/models/alchemy/node_spec.rb b/spec/models/alchemy/node_spec.rb index 07cfe33d7e..05e396b078 100644 --- a/spec/models/alchemy/node_spec.rb +++ b/spec/models/alchemy/node_spec.rb @@ -9,6 +9,26 @@ module Alchemy expect(build(:alchemy_node)).to be_valid end + describe '.language_root_nodes' do + context 'with no current language present' do + before { expect(Language).to receive(:current) { nil } } + + it "raises error if no current language is set" do + expect { Node.language_root_nodes }.to raise_error('No language found') + end + end + + context 'with current language present' do + let(:root_node) { create(:alchemy_node) } + let(:child_node) { create(:alchemy_node, parent_id: root_node.id) } + + it "returns root nodes from current language" do + expect(Node.language_root_nodes).to include(root_node) + expect(Node.language_root_nodes).to_not include(child_node) + end + end + end + describe '#url' do it 'is valid with leading slash' do expect(build(:alchemy_node, url: '/something')).to be_valid From 39c690be1e08becb655b739aad91d1077dee0c99 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Wed, 30 Oct 2019 18:45:16 +0100 Subject: [PATCH 3/9] Implement attach page to menu feature A page can now be attached to a menu. This works with the "visible in navigation" checkbox, but only if Menus are already present - it will fall back to old behavior instead. Once attached to a menu node it you cannot detach it from the page, you need to remove the menu node first. --- app/assets/stylesheets/alchemy/forms.scss | 4 ++ app/models/alchemy/node.rb | 2 +- app/models/alchemy/page.rb | 29 ++++++++- app/views/alchemy/admin/pages/_form.html.erb | 2 +- .../alchemy/admin/pages/_menu_fields.html.erb | 33 ++++++++++ config/locales/alchemy.en.yml | 1 + spec/models/alchemy/page_spec.rb | 62 +++++++++++++++++++ 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 app/views/alchemy/admin/pages/_menu_fields.html.erb diff --git a/app/assets/stylesheets/alchemy/forms.scss b/app/assets/stylesheets/alchemy/forms.scss index 70fd44458f..c07e9119c7 100644 --- a/app/assets/stylesheets/alchemy/forms.scss +++ b/app/assets/stylesheets/alchemy/forms.scss @@ -38,6 +38,10 @@ form { float: right; } + .input > .select2-container { + width: 100%; + } + > .autocomplete_tag_list { .select2-container, .select2-choices { diff --git a/app/models/alchemy/node.rb b/app/models/alchemy/node.rb index e5682547ec..125705a881 100644 --- a/app/models/alchemy/node.rb +++ b/app/models/alchemy/node.rb @@ -8,7 +8,7 @@ class Node < BaseRecord stampable stamper_class_name: Alchemy.user_class_name belongs_to :language, class_name: 'Alchemy::Language' - belongs_to :page, class_name: 'Alchemy::Page', optional: true + belongs_to :page, class_name: 'Alchemy::Page', optional: true, inverse_of: :nodes validates :url, format: { with: VALID_URL_REGEX }, unless: -> { url.nil? } diff --git a/app/models/alchemy/page.rb b/app/models/alchemy/page.rb index eb9a708482..cb4602eff4 100644 --- a/app/models/alchemy/page.rb +++ b/app/models/alchemy/page.rb @@ -79,7 +79,8 @@ class Page < BaseRecord :title, :urlname, :visible, - :layoutpage + :layoutpage, + :menu_id ] acts_as_nested_set(dependent: :destroy) @@ -110,6 +111,7 @@ class Page < BaseRecord has_many :site_languages, through: :site, source: :languages has_many :folded_pages has_many :legacy_urls, class_name: 'Alchemy::LegacyPageUrl' + has_many :nodes, class_name: 'Alchemy::Node', inverse_of: :page validates_presence_of :language, on: :create, unless: :root validates_presence_of :page_layout, unless: :systempage? @@ -143,6 +145,11 @@ class Page < BaseRecord if: :should_create_legacy_url?, unless: :redirects_to_external? + after_update :attach_to_menu!, + if: :should_attach_to_menu? + + after_update -> { nodes.update_all(updated_at: Time.current) } + # Concerns include Alchemy::Page::PageScopes include Alchemy::Page::PageNatures @@ -152,6 +159,8 @@ class Page < BaseRecord # site_name accessor delegate :name, to: :site, prefix: true, allow_nil: true + attr_accessor :menu_id + # Class methods # class << self @@ -532,6 +541,12 @@ def locker_name locker.try(:name) || Alchemy.t('unknown') end + # Menus (aka. root nodes) this page is attached to + # + def menus + @_menus ||= nodes.map(&:root) + end + private def set_fixed_attributes @@ -577,5 +592,17 @@ def create_legacy_url def set_published_at self.published_at = Time.current end + + def attach_to_menu! + Alchemy::Node.find(menu_id).children.create!( + language_id: language_id, + page_id: id, + name: name + ) + end + + def should_attach_to_menu? + menu_id && nodes.none? + end end end diff --git a/app/views/alchemy/admin/pages/_form.html.erb b/app/views/alchemy/admin/pages/_form.html.erb index 3bd6eefe8a..a0b0a66e5e 100644 --- a/app/views/alchemy/admin/pages/_form.html.erb +++ b/app/views/alchemy/admin/pages/_form.html.erb @@ -9,8 +9,8 @@
    <%= render 'alchemy/admin/pages/publication_fields' %> - <%= page_status_checkbox(@page, :visible) %> <%= page_status_checkbox(@page, :restricted) %> + <%= render 'alchemy/admin/pages/menu_fields', f: f %> <% if configuration(:sitemap)['show_flag'] %> <%= page_status_checkbox(@page, :sitemap) %> <% end %> diff --git a/app/views/alchemy/admin/pages/_menu_fields.html.erb b/app/views/alchemy/admin/pages/_menu_fields.html.erb new file mode 100644 index 0000000000..dadb0f2641 --- /dev/null +++ b/app/views/alchemy/admin/pages/_menu_fields.html.erb @@ -0,0 +1,33 @@ +<% if @page.menus.any? %> + + <% @page.menus.each do |menu| %> + + <%= menu.name %> + + <% end %> +<% elsif Alchemy::Node.roots.any? %> + <%= page_status_checkbox(@page, :visible) %> + <%= f.input :menu_id, collection: Alchemy::Node.roots.map { |n| [n.name, n.id] }, + prompt: Alchemy.t('Please choose a menu'), + input_html: { class: 'alchemy_selectbox' }, + wrapper_html: { style: @page.visible? ? 'display: block' : 'display: none' }, + label: false %> + +<% else %> + <%= page_status_checkbox(@page, :visible) %> +<% end %> diff --git a/config/locales/alchemy.en.yml b/config/locales/alchemy.en.yml index 73b6aea1ab..d7593224c1 100644 --- a/config/locales/alchemy.en.yml +++ b/config/locales/alchemy.en.yml @@ -305,6 +305,7 @@ en: assign_file: "Assign a file" assign_file_from_archive: "assign a file from your archive" assign_image: "Assign an image" + attached_to: "attached to" attachment_filename_notice: "* Please do not use any special characters for the filename." auto_play: "Play movie after load" big_thumbnails: "Big thumbnails" diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index df854e9120..1bbfbeefdd 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -2325,5 +2325,67 @@ module Alchemy end end end + + describe '#attach_to_menu!' do + let(:page) { create(:alchemy_page) } + + context 'if menu_id is set' do + let(:root_node) { create(:alchemy_node) } + + before do + page.menu_id = root_node.id + end + + context 'and no nodes are present yet' do + it 'attaches to menu' do + expect { page.save }.to change { page.nodes.count }.from(0).to(1) + end + end + + context 'and nodes are already present' do + let!(:page_node) { create(:alchemy_node, page: page) } + + it 'does not attach to menu' do + expect { page.save }.not_to change { page.nodes.count } + end + end + end + + context 'if menu_id is not set' do + it 'does not attach to menu' do + expect { page.save }.not_to change { page.nodes.count } + end + end + end + + describe '#nodes' do + let(:page) { create(:alchemy_page) } + let(:node) { create(:alchemy_node, page: page, updated_at: 1.hour.ago) } + + it 'returns all nodes the page is attached to' do + expect(page.nodes).to include(node) + end + + describe 'after page updates' do + it 'touches all nodes' do + expect { + page.update(name: 'foo') + }.to change { node.reload.updated_at } + end + end + end + + describe '#menus' do + let(:page) { create(:alchemy_page) } + let(:root_node) { create(:alchemy_node) } + + let!(:child_node) do + create(:alchemy_node, page: page, parent: root_node) + end + + it 'returns all root nodes the page is attached to' do + expect(page.menus).to include(root_node) + end + end end end From c854fd8b5a6c94652a30cc5b3e85f128c17bdcff Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Wed, 30 Oct 2019 14:43:19 +0100 Subject: [PATCH 4/9] Deprecate external and controller redirects on page Instead use a menu node with either the external url or your controller path. --- app/models/alchemy/page/page_natures.rb | 6 ++++++ spec/models/alchemy/page_spec.rb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/alchemy/page/page_natures.rb b/app/models/alchemy/page/page_natures.rb index bd6445d18e..8f796104d7 100644 --- a/app/models/alchemy/page/page_natures.rb +++ b/app/models/alchemy/page/page_natures.rb @@ -55,19 +55,24 @@ def editor_roles end # Returns true or false if the pages definition for config/alchemy/page_layouts.yml contains redirects_to_external: true + # @deprecated Please use a menu node with an external url instead. def redirects_to_external? !!definition["redirects_to_external"] end + deprecate redirects_to_external?: 'Please use a menu node with an external url instead.', deprecator: Alchemy::Deprecation + # @deprecated def has_controller? !PageLayout.get(page_layout).nil? && !PageLayout.get(page_layout)["controller"].blank? end + deprecate :has_controller?, deprecator: Alchemy::Deprecation # True if page locked_at timestamp and locked_by id are set def locked? locked_by? && locked_at? end + # @deprecated Please use a menu node with an url pointing to your controller path instead. def controller_and_action if has_controller? { @@ -76,6 +81,7 @@ def controller_and_action } end end + deprecate controller_and_action: 'Please use a menu node with an url pointing to your controller path instead.', deprecator: Alchemy::Deprecation # Returns a Hash describing the status of the Page. # diff --git a/spec/models/alchemy/page_spec.rb b/spec/models/alchemy/page_spec.rb index 1bbfbeefdd..e9e8506be5 100644 --- a/spec/models/alchemy/page_spec.rb +++ b/spec/models/alchemy/page_spec.rb @@ -1171,7 +1171,7 @@ module Alchemy subject { page.find_elements(options, true) } it 'warns about removal of second argument' do - expect(Alchemy::Deprecation).to receive(:warn) + expect(Alchemy::Deprecation).to receive(:warn).at_least(:once) subject end end From 2988807fc97c96dcf93af7ad7637cbb5b5b3be9b Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 31 Oct 2019 21:43:06 +0100 Subject: [PATCH 5/9] Deprecate render_navigation helpers Create a menu and use render_menu helper instead. --- app/helpers/alchemy/pages_helper.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/helpers/alchemy/pages_helper.rb b/app/helpers/alchemy/pages_helper.rb index 1cd4197caa..e0db828dea 100644 --- a/app/helpers/alchemy/pages_helper.rb +++ b/app/helpers/alchemy/pages_helper.rb @@ -73,6 +73,7 @@ def render_site_layout end # Renders the navigation. + # @deprecated # # It produces a html
    structure with all necessary classes so you can produce every navigation the web uses today. # I.E. dropdown-navigations, simple mainnavigations or even complex nested ones. @@ -177,6 +178,7 @@ def render_navigation(options = {}, html_options = {}) pages: pages, html_options: html_options end + deprecate render_navigation: 'Create a menu and use render_menu instead', deprecator: Alchemy::Deprecation # Renders navigation the children and all siblings of the given page (standard is the current page). # @@ -206,6 +208,7 @@ def render_subnavigation(options = {}, html_options = {}) return nil end end + deprecate :render_subnavigation, deprecator: Alchemy::Deprecation # Returns true if page is in the active branch def page_active?(page) From 1cf3fc2881f0ef45630284e96f70b8b79f24a8df Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 31 Oct 2019 21:45:28 +0100 Subject: [PATCH 6/9] Do not generate the navigation views any more Create a menu and generate menu views instead. --- lib/rails/generators/alchemy/views/views_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rails/generators/alchemy/views/views_generator.rb b/lib/rails/generators/alchemy/views/views_generator.rb index d193ceffcf..93e263781b 100644 --- a/lib/rails/generators/alchemy/views/views_generator.rb +++ b/lib/rails/generators/alchemy/views/views_generator.rb @@ -3,7 +3,7 @@ module Alchemy module Generators class ViewsGenerator < ::Rails::Generators::Base - ALCHEMY_VIEWS = %w(breadcrumb language_links messages_mailer navigation) + ALCHEMY_VIEWS = %w(breadcrumb language_links messages_mailer) desc "Generates Alchemy views for #{ALCHEMY_VIEWS.to_sentence}." From 8419df3acb3bc1c4d2b77ce3a5ee30a25585ce30 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 31 Oct 2019 22:46:50 +0100 Subject: [PATCH 7/9] Add a menus generator Generates view partials for menus rails g alchemy:menus --- .../alchemy/menus/menus_generator.rb | 24 +++++++++++++++++++ .../alchemy/menus/templates/node.html.erb | 17 +++++++++++++ .../alchemy/menus/templates/node.html.haml | 15 ++++++++++++ .../alchemy/menus/templates/node.html.slim | 15 ++++++++++++ .../alchemy/menus/templates/wrapper.html.erb | 6 +++++ .../alchemy/menus/templates/wrapper.html.haml | 5 ++++ .../alchemy/menus/templates/wrapper.html.slim | 5 ++++ .../menus/footer_navigation/_node.html.erb | 17 +++++++++++++ .../menus/footer_navigation/_wrapper.html.erb | 6 +++++ .../menus/main_navigation/_node.html.erb | 17 +++++++++++++ .../menus/main_navigation/_wrapper.html.erb | 6 +++++ 11 files changed, 133 insertions(+) create mode 100644 lib/rails/generators/alchemy/menus/menus_generator.rb create mode 100644 lib/rails/generators/alchemy/menus/templates/node.html.erb create mode 100644 lib/rails/generators/alchemy/menus/templates/node.html.haml create mode 100644 lib/rails/generators/alchemy/menus/templates/node.html.slim create mode 100644 lib/rails/generators/alchemy/menus/templates/wrapper.html.erb create mode 100644 lib/rails/generators/alchemy/menus/templates/wrapper.html.haml create mode 100644 lib/rails/generators/alchemy/menus/templates/wrapper.html.slim create mode 100644 spec/dummy/app/views/alchemy/menus/footer_navigation/_node.html.erb create mode 100644 spec/dummy/app/views/alchemy/menus/footer_navigation/_wrapper.html.erb create mode 100644 spec/dummy/app/views/alchemy/menus/main_navigation/_node.html.erb create mode 100644 spec/dummy/app/views/alchemy/menus/main_navigation/_wrapper.html.erb diff --git a/lib/rails/generators/alchemy/menus/menus_generator.rb b/lib/rails/generators/alchemy/menus/menus_generator.rb new file mode 100644 index 0000000000..40b4d91f65 --- /dev/null +++ b/lib/rails/generators/alchemy/menus/menus_generator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative '../base' + +module Alchemy + module Generators + class MenusGenerator < Base + desc "This generator generates Alchemy menu partials." + source_root File.expand_path('templates', __dir__) + + def create_partials + menus = Alchemy::Node.roots + return unless menus + + menus.each do |menu| + conditional_template "wrapper.html.#{template_engine}", + "app/views/#{menu.view_folder_name}/_wrapper.html.#{template_engine}" + conditional_template "node.html.#{template_engine}", + "app/views/#{menu.view_folder_name}/_node.html.#{template_engine}" + end + end + end + end +end diff --git a/lib/rails/generators/alchemy/menus/templates/node.html.erb b/lib/rails/generators/alchemy/menus/templates/node.html.erb new file mode 100644 index 0000000000..f3dce4c726 --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/node.html.erb @@ -0,0 +1,17 @@ +<%%= content_tag :li, class: ['nav-item', node.children.any? ? 'dropdown' : nil].compact do %> + <%%= link_to_if node.url, + node.name, + @preview_mode ? 'javascript: void(0)' : node.url, + class: ['nav-link', current_page?(node.url) ? 'active' : nil].compact, + title: node.title, + target: node.external? ? '_blank' : nil, + rel: node.nofollow? ? 'nofollow' : nil %> + <%% if node.children.any? %> + + <%% end %> +<%% end %> diff --git a/lib/rails/generators/alchemy/menus/templates/node.html.haml b/lib/rails/generators/alchemy/menus/templates/node.html.haml new file mode 100644 index 0000000000..058d6f74d2 --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/node.html.haml @@ -0,0 +1,15 @@ += content_tag :li, + class: ['nav-item', node.children.any? ? 'dropdown' : nil].compact do + = link_to_if node.url, + node.name, + @preview_mode ? 'javascript: void(0)' : node.url, + class: ['nav-link', current_page?(node.url) ? 'active' : nil].compact, + title: node.title, + target: node.external? ? '_blank' : nil, + rel: node.nofollow? ? 'nofollow' : nil + - if node.children.any? + %ul.dropdown-menu + = render partial: options[:node_partial_name], + collection: node.children.includes(:page, :children), + locals: { options: options }, + as: 'node' diff --git a/lib/rails/generators/alchemy/menus/templates/node.html.slim b/lib/rails/generators/alchemy/menus/templates/node.html.slim new file mode 100644 index 0000000000..aa88ff6a0e --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/node.html.slim @@ -0,0 +1,15 @@ += content_tag :li, + class: ['nav-item', node.children.any? ? 'dropdown' : nil].compact do + = link_to_if node.url, + node.name, + @preview_mode ? 'javascript: void(0)' : node.url, + class: ['nav-link', current_page?(node.url) ? 'active' : nil].compact, + title: node.title, + target: node.external? ? '_blank' : nil, + rel: node.nofollow? ? 'nofollow' : nil + - if node.children.any? + ul.dropdown-menu + = render partial: options[:node_partial_name], + collection: node.children.includes(:page, :children), + locals: { options: options }, + as: 'node' diff --git a/lib/rails/generators/alchemy/menus/templates/wrapper.html.erb b/lib/rails/generators/alchemy/menus/templates/wrapper.html.erb new file mode 100644 index 0000000000..d648063f73 --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/wrapper.html.erb @@ -0,0 +1,6 @@ + diff --git a/lib/rails/generators/alchemy/menus/templates/wrapper.html.haml b/lib/rails/generators/alchemy/menus/templates/wrapper.html.haml new file mode 100644 index 0000000000..8021b59b6b --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/wrapper.html.haml @@ -0,0 +1,5 @@ +%ul.nav + = render partial: options[:node_partial_name], + collection: node.children.includes(:page, :children), + locals: { options: options }, + as: 'node' %> diff --git a/lib/rails/generators/alchemy/menus/templates/wrapper.html.slim b/lib/rails/generators/alchemy/menus/templates/wrapper.html.slim new file mode 100644 index 0000000000..4afb2ece57 --- /dev/null +++ b/lib/rails/generators/alchemy/menus/templates/wrapper.html.slim @@ -0,0 +1,5 @@ +ul.nav + = render partial: options[:node_partial_name], + collection: node.children.includes(:page, :children), + locals: { options: options }, + as: 'node' %> diff --git a/spec/dummy/app/views/alchemy/menus/footer_navigation/_node.html.erb b/spec/dummy/app/views/alchemy/menus/footer_navigation/_node.html.erb new file mode 100644 index 0000000000..ed71624a72 --- /dev/null +++ b/spec/dummy/app/views/alchemy/menus/footer_navigation/_node.html.erb @@ -0,0 +1,17 @@ +<%= content_tag :li, class: ['nav-item', node.children.any? ? 'dropdown' : nil].compact do %> + <%= link_to_if node.url, + node.name, + @preview_mode ? 'javascript: void(0)' : node.url, + class: ['nav-link', current_page?(node.url) ? 'active' : nil].compact, + title: node.title, + target: node.external? ? '_blank' : nil, + rel: node.nofollow? ? 'nofollow' : nil %> + <% if node.children.any? %> + + <% end %> +<% end %> diff --git a/spec/dummy/app/views/alchemy/menus/footer_navigation/_wrapper.html.erb b/spec/dummy/app/views/alchemy/menus/footer_navigation/_wrapper.html.erb new file mode 100644 index 0000000000..f314d3eedc --- /dev/null +++ b/spec/dummy/app/views/alchemy/menus/footer_navigation/_wrapper.html.erb @@ -0,0 +1,6 @@ + diff --git a/spec/dummy/app/views/alchemy/menus/main_navigation/_node.html.erb b/spec/dummy/app/views/alchemy/menus/main_navigation/_node.html.erb new file mode 100644 index 0000000000..ed71624a72 --- /dev/null +++ b/spec/dummy/app/views/alchemy/menus/main_navigation/_node.html.erb @@ -0,0 +1,17 @@ +<%= content_tag :li, class: ['nav-item', node.children.any? ? 'dropdown' : nil].compact do %> + <%= link_to_if node.url, + node.name, + @preview_mode ? 'javascript: void(0)' : node.url, + class: ['nav-link', current_page?(node.url) ? 'active' : nil].compact, + title: node.title, + target: node.external? ? '_blank' : nil, + rel: node.nofollow? ? 'nofollow' : nil %> + <% if node.children.any? %> + + <% end %> +<% end %> diff --git a/spec/dummy/app/views/alchemy/menus/main_navigation/_wrapper.html.erb b/spec/dummy/app/views/alchemy/menus/main_navigation/_wrapper.html.erb new file mode 100644 index 0000000000..f314d3eedc --- /dev/null +++ b/spec/dummy/app/views/alchemy/menus/main_navigation/_wrapper.html.erb @@ -0,0 +1,6 @@ + From 0113747dc745cbab93aadc2bf4e84ec67fdcf967 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 31 Oct 2019 23:27:19 +0100 Subject: [PATCH 8/9] Add render_menu helper It renders a menu's view templates. --- app/helpers/alchemy/pages_helper.rb | 26 +++++++++++++++++ .../app/views/layouts/application.html.erb | 6 ++-- .../admin/page_editing_feature_spec.rb | 13 ++++++--- spec/features/page_feature_spec.rb | 29 +++++++------------ spec/helpers/alchemy/pages_helper_spec.rb | 27 +++++++++++++++++ 5 files changed, 76 insertions(+), 25 deletions(-) diff --git a/app/helpers/alchemy/pages_helper.rb b/app/helpers/alchemy/pages_helper.rb index e0db828dea..7b0f00d886 100644 --- a/app/helpers/alchemy/pages_helper.rb +++ b/app/helpers/alchemy/pages_helper.rb @@ -180,6 +180,32 @@ def render_navigation(options = {}, html_options = {}) end deprecate render_navigation: 'Create a menu and use render_menu instead', deprecator: Alchemy::Deprecation + # Renders a menu partial + # + # Menu partials are placed in the `app/views/alchemy/menus` folder + # Use the `rails g alchemy:menus` generator to create the partials + # + # @param [String] - Name of the menu + # @param [Hash] - A set of options available in your menu partials + def render_menu(name, options = {}) + root_node = Alchemy::Node.roots.find_by(name: name) + if root_node.nil? + warning("Menu with name #{name} not found!") + return + end + + options = { + node_partial_name: "#{root_node.view_folder_name}/node" + }.merge(options) + + render(root_node, node: root_node, options: options) + rescue ActionView::MissingTemplate => e + warning <<~WARN + Menu partial not found for #{name}. + #{e} + WARN + end + # Renders navigation the children and all siblings of the given page (standard is the current page). # # Use this helper if you want to render the subnavigation independent from the mainnavigation. I.E. to place it in a different area on your website. diff --git a/spec/dummy/app/views/layouts/application.html.erb b/spec/dummy/app/views/layouts/application.html.erb index 3179502411..17d080b35d 100644 --- a/spec/dummy/app/views/layouts/application.html.erb +++ b/spec/dummy/app/views/layouts/application.html.erb @@ -7,10 +7,10 @@ <%= csrf_meta_tags %> + <%= yield %> - <%= render "alchemy/edit_mode" %> diff --git a/spec/features/admin/page_editing_feature_spec.rb b/spec/features/admin/page_editing_feature_spec.rb index cc56d20aa1..d77a4f21cc 100644 --- a/spec/features/admin/page_editing_feature_spec.rb +++ b/spec/features/admin/page_editing_feature_spec.rb @@ -109,10 +109,15 @@ expect(page).not_to have_selector('#alchemy_menubar') end - it "navigation links are not clickable" do - visit alchemy.admin_page_path(a_page) - within('#navigation') do - expect(page).to have_selector('a[href="javascript: void(0)"]') + context 'with menu available' do + let!(:menu) { create(:alchemy_node, name: 'Main Navigation') } + let!(:node) { create(:alchemy_node, url: '/page-1', parent: menu) } + + it "navigation links are not clickable" do + visit alchemy.admin_page_path(a_page) + within('nav') do + expect(page).to have_selector('a[href="javascript: void(0)"]') + end end end end diff --git a/spec/features/page_feature_spec.rb b/spec/features/page_feature_spec.rb index 23aa37fa55..11333afa91 100644 --- a/spec/features/page_feature_spec.rb +++ b/spec/features/page_feature_spec.rb @@ -51,15 +51,6 @@ end end - it "should show the navigation with all visible pages" do - create(:alchemy_page, :public, visible: true, name: 'Page 1') - create(:alchemy_page, :public, visible: true, name: 'Page 2') - visit '/' - within('div#navigation ul') do - expect(page).to have_selector('li a[href="/page-1"], li a[href="/page-2"]') - end - end - describe "Handling of non-existing pages" do before do # We need a admin user or the signup page will show up @@ -146,15 +137,17 @@ end describe 'navigation rendering' do - context 'with page having an external url without protocol' do - let!(:external_page) do - create(:alchemy_page, urlname: 'google.com', page_layout: 'external', visible: true) - end - - it "adds an prefix to url" do - visit "/#{public_page.urlname}" - within '#navigation' do - expect(page.body).to match('http://google.com') + context 'with menu available' do + let(:menu) { create(:alchemy_node, name: 'Main Navigation') } + let(:page1) { create(:alchemy_page, :public, visible: true, name: 'Page 1') } + let(:page2) { create(:alchemy_page, :public, visible: true, name: 'Page 2') } + let!(:node1) { create(:alchemy_node, page: page1, parent: menu) } + let!(:node2) { create(:alchemy_node, page: page2, parent: menu) } + + it "should show the navigation with all visible pages" do + visit '/' + within('nav ul') do + expect(page).to have_selector('li a[href="/page-1"], li a[href="/page-2"]') end end end diff --git a/spec/helpers/alchemy/pages_helper_spec.rb b/spec/helpers/alchemy/pages_helper_spec.rb index 3f8f5c77eb..9a6d79f619 100644 --- a/spec/helpers/alchemy/pages_helper_spec.rb +++ b/spec/helpers/alchemy/pages_helper_spec.rb @@ -48,6 +48,33 @@ module Alchemy end end + describe '#render_menu' do + subject { helper.render_menu(name) } + + let(:name) { 'Main Navigation' } + + context 'if menu exists' do + let(:menu) { create(:alchemy_node, name: name) } + let!(:node) { create(:alchemy_node, parent: menu, url: '/') } + + context 'and the template exists' do + it 'renders the menu' do + is_expected.to have_selector('ul.nav > li.nav-item > a.nav-link') + end + end + + context 'but the template does not exist' do + let(:name) { 'Unkown' } + + it { is_expected.to be_nil } + end + end + + context 'if menu does not exist' do + it { is_expected.to be_nil } + end + end + describe "#render_navigation" do let(:user) { nil } From 7ba30a347848197897f17f414f0996d39ff85596 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Fri, 1 Nov 2019 22:55:36 +0100 Subject: [PATCH 9/9] Add a rake task to convert page trees into menus Run this task to convert your current page trees into menus rake alchemy:convert:page_trees:to_menus --- lib/tasks/alchemy/convert.rake | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/tasks/alchemy/convert.rake b/lib/tasks/alchemy/convert.rake index be1ded824b..8b42d1fdb9 100644 --- a/lib/tasks/alchemy/convert.rake +++ b/lib/tasks/alchemy/convert.rake @@ -31,5 +31,49 @@ namespace :alchemy do puts "Done." end end + + namespace :page_trees do + desc "Converts the page tree into a menu." + task to_menus: [:environment] do + if Alchemy::Node.roots.exists? + abort "\n⨯ There are already menus present in your database. Aborting!" + end + + def convert_to_nodes(children, node:) + children.each do |page| + has_children = page.children.any? + next unless page.visible || has_children + + Alchemy::Deprecation.silence do + new_node = node.children.create!( + name: page.visible? && page.public? && !page.redirects_to_external? ? nil : page.name, + page: page.visible? && page.public? && !page.redirects_to_external? ? page : nil, + url: page.redirects_to_external? ? page.urlname : nil, + external: page.redirects_to_external? && Alchemy::Config.get(:open_external_links_in_new_tab), + language_id: page.language_id + ) + print "." + if has_children + convert_to_nodes(page.children, node: new_node) + end + end + end + end + + menu_count = Alchemy::Language.count + puts "\n- Converting #{menu_count} page #{'tree'.pluralize(menu_count)} into #{'menu'.pluralize(menu_count)}." + Alchemy::BaseRecord.transaction do + Alchemy::Language.all.each do |language| + locale = language.locale.presence || I18n.default_locale + menu_name = I18n.t('Main Navigation', scope: 'alchemy.menu_names', default: 'Main Navigation', locale: locale) + root_node = Alchemy::Node.create(language: language, name: menu_name) + language.pages.language_roots.each do |root_page| + convert_to_nodes(root_page.children, node: root_node) + end + end + end + puts "\n✓ Done." + end + end end end