katalyst-navigation 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e358271c4b2a255cba861c6eac1f432e906c5d56f37f703646ff1dd504e582a
4
- data.tar.gz: a5c36789357921c48b42fd708f6289df7adb9c1bec7b6be1d6a39e7a608be468
3
+ metadata.gz: 94cc7f739764b2d65865d91d0d6f1431671bee909e285b51238f4343dd15883e
4
+ data.tar.gz: 51a2493050682848606784167c351865776daa9cc403e59e096e269b55afc208
5
5
  SHA512:
6
- metadata.gz: 400edfaf424e0cf2edf1867e8897961b911c1c64bed4839a30fd89d3d353af5639d6b3dc4dc943f4ec1c06efd64ff1bc7f1b55516c0d1440aa02210120e6004e
7
- data.tar.gz: 05c8cb35db579809acc4f68c2359ac2f11103e1f8c3255dc5a606ff12fe3e975a71738d9c760007efbd80fe505d948d49031c8cf5283edad05ac6bf58f21420e
6
+ metadata.gz: 51127826086e93bdc9d6796f8eab5096c0d4a1d6af14a0623f3387a6c567263b6f28351507f5bcb683cce7f88f0a5832ac7583e2a28d59b31463eeeba8b09710
7
+ data.tar.gz: 1b7b2e4b291da7ae044b6c5de3c5df0a0d56b5ba111d7fbf454648916baf2a8634cfbc2c94216a3f327ad5ffba51440b00ca5d052bcf0d55b070b345de2a72ea
@@ -6,6 +6,9 @@ import RulesEngine from "utils/navigation/editor/rules-engine";
6
6
 
7
7
  export default class MenuController extends Controller {
8
8
  static targets = ["menu"];
9
+ static values = {
10
+ maxDepth: Number,
11
+ };
9
12
 
10
13
  connect() {
11
14
  this.state = this.menu.state;
@@ -85,7 +88,7 @@ export default class MenuController extends Controller {
85
88
  if (!this.updateRequested) return;
86
89
 
87
90
  this.updateRequested = false;
88
- const engine = new RulesEngine();
91
+ const engine = new RulesEngine(this.maxDepthValue);
89
92
  this.menu.items.forEach((item) => engine.update(item));
90
93
 
91
94
  this.#notifyChange();
@@ -1,4 +1,4 @@
1
- import Item from "./item";
1
+ import Item from "utils/navigation/editor/item";
2
2
 
3
3
  /**
4
4
  * @param nodes {NodeList}
@@ -7,9 +7,12 @@ export default class RulesEngine {
7
7
  "denyRemove",
8
8
  "denyDrag",
9
9
  "denyEdit",
10
- "invalidDepth",
11
10
  ];
12
11
 
12
+ constructor(maxDepth = null) {
13
+ this.maxDepth = maxDepth;
14
+ }
15
+
13
16
  /**
14
17
  * Apply rules to the given item by computing a ruleset then merging it
15
18
  * with the item's current state.
@@ -19,16 +22,21 @@ export default class RulesEngine {
19
22
  update(item) {
20
23
  this.rules = {};
21
24
 
25
+ // structural rules enforce a valid tree structure
22
26
  this.firstItemDepthZero(item);
23
27
  this.depthMustBeSet(item);
28
+ this.itemCannotHaveInvalidDepth(item);
29
+ this.itemCannotExceedDepthLimit(item);
30
+
31
+ // behavioural rules define what the user is allowed to do
24
32
  this.parentsCannotDeNest(item);
25
33
  this.rootsCannotDeNest(item);
26
34
  this.nestingNeedsParent(item);
35
+ this.nestingCannotExceedMaxDepth(item);
27
36
  this.leavesCannotCollapse(item);
28
37
  this.needHiddenItemsToExpand(item);
29
38
  this.parentsCannotBeDeleted(item);
30
39
  this.parentsCannotBeDragged(item);
31
- this.itemCannotHaveInvalidDepth(item);
32
40
 
33
41
  RulesEngine.rules.forEach((rule) => {
34
42
  item.toggleRule(rule, !!this.rules[rule]);
@@ -55,6 +63,34 @@ export default class RulesEngine {
55
63
  }
56
64
  }
57
65
 
66
+ /**
67
+ * Depth must increase stepwise.
68
+ *
69
+ * @param {Item} item
70
+ */
71
+ itemCannotHaveInvalidDepth(item) {
72
+ const previous = item.previousItem;
73
+ if (previous && previous.depth < item.depth - 1) {
74
+ item.depth = previous.depth + 1;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Depth must not exceed menu's depth limit.
80
+ *
81
+ * @param {Item} item
82
+ */
83
+ itemCannotExceedDepthLimit(item) {
84
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth) {
85
+ // Note: this change can cause an issue where the previous item is treated
86
+ // like a parent even though it no longer has children. This is because
87
+ // items are processed in order. This issue does not seem worth solving
88
+ // as it only occurs if the max depth is altered. The issue can be worked
89
+ // around by saving the menu.
90
+ item.depth = this.maxDepth - 1;
91
+ }
92
+ }
93
+
58
94
  /**
59
95
  * De-nesting an item would create a gap of 2 between itself and its children
60
96
  *
@@ -102,31 +138,32 @@ export default class RulesEngine {
102
138
  }
103
139
 
104
140
  /**
105
- * An item can't be deleted if it has visible children.
141
+ * An item can't be nested (indented) if doing so would exceed the max depth.
106
142
  *
107
143
  * @param {Item} item
108
144
  */
109
- parentsCannotBeDeleted(item) {
110
- if (item.hasExpandedDescendants()) this.#deny("denyRemove");
145
+ nestingCannotExceedMaxDepth(item) {
146
+ if (this.maxDepth > 0 && this.maxDepth >= item.depth + 1) {
147
+ this.#deny("denyNest");
148
+ }
111
149
  }
112
150
 
113
151
  /**
114
- * Items cannot be dragged if they have visible children.
152
+ * An item can't be deleted if it has visible children.
115
153
  *
116
154
  * @param {Item} item
117
155
  */
118
- parentsCannotBeDragged(item) {
119
- if (item.hasExpandedDescendants()) this.#deny("denyDrag");
156
+ parentsCannotBeDeleted(item) {
157
+ if (item.hasExpandedDescendants()) this.#deny("denyRemove");
120
158
  }
121
159
 
122
160
  /**
123
- * Depth must increase stepwise.
161
+ * Items cannot be dragged if they have visible children.
124
162
  *
125
163
  * @param {Item} item
126
164
  */
127
- itemCannotHaveInvalidDepth(item) {
128
- const previous = item.previousItem;
129
- if (previous && previous.depth < item.depth - 1) this.#deny("invalidDepth");
165
+ parentsCannotBeDragged(item) {
166
+ if (item.hasExpandedDescendants()) this.#deny("denyDrag");
130
167
  }
131
168
 
132
169
  /**
@@ -80,7 +80,7 @@ $status-dirty-color: #aaa !default;
80
80
 
81
81
  // Dragged visuals
82
82
  &[data-dragging] {
83
- border: 2px dashed;
83
+ box-shadow: inset 0 0 0 2px var(--icon-passive-color);
84
84
 
85
85
  > * {
86
86
  visibility: hidden;
@@ -16,9 +16,4 @@
16
16
  display: none !important;
17
17
  pointer-events: none;
18
18
  }
19
-
20
- [data-invalid-depth] {
21
- border-color: red;
22
- background-color: indianred;
23
- }
24
19
  }
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ # Preloads navigation menus before content is rendered.
6
+ module HasNavigation
7
+ extend ActiveSupport::Concern
8
+
9
+ # Override the default navigation builder for all views rendered by this
10
+ # controller and any of its descendants. Accepts a subclass of
11
+ # Katalyst::Navigation::Frontend::Builder.
12
+ #
13
+ # For example, given a form builder:
14
+ #
15
+ # class AdminNavBuilder < Katalyst::Navigation::Frontend::Builder
16
+ # def render_item(item)
17
+ # end
18
+ # end
19
+ #
20
+ # The controller specifies a form builder as its default:
21
+ #
22
+ # class AdminAreaController < ApplicationController
23
+ # default_navigation_builder AdminNavBuilder
24
+ # end
25
+ #
26
+ # Then in the view any form using +navigation_menu_with+ will be an instance of the
27
+ # specified navigation builder:
28
+ #
29
+ # <%= navigation_menu_with(menu: @menu) %>
30
+ module NavigationBuilder
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ class_attribute :_default_navigation_builder, instance_accessor: false
35
+ end
36
+
37
+ class_methods do
38
+ # Set the navigation builder to be used as the default for all navs
39
+ # in the views rendered by this controller and its subclasses.
40
+ #
41
+ # @param builder {Katalyst::Navigation::Frontend::Builder}
42
+ def default_navigation_builder(builder)
43
+ self._default_navigation_builder = builder
44
+ end
45
+ end
46
+
47
+ # Default navigation builder for the controller
48
+ #
49
+ # @return {Katalyst::Navigation::Frontend::Builder}
50
+ def default_navigation_builder
51
+ self.class._default_navigation_builder
52
+ end
53
+ end
54
+
55
+ # Provide an accessor for navigation menus
56
+ module NavigationHelper
57
+ # Retrieves the preloaded menu that matches the given slug.
58
+ #
59
+ # @return {Katalyst::Navigation::Menu} menu with the given slug
60
+ def navigation_menu_for(slug)
61
+ @navigation_menus[slug.to_s]
62
+ end
63
+
64
+ # @see ActionView::Helpers::ControllerHelper#assign_controller
65
+ def assign_controller(controller)
66
+ super
67
+
68
+ if controller.respond_to?(:default_navigation_builder)
69
+ @_default_navigation_builder = controller.default_navigation_builder
70
+ end
71
+ end
72
+ end
73
+
74
+ included do
75
+ include NavigationBuilder
76
+
77
+ helper Katalyst::Navigation::FrontendHelper
78
+ helper NavigationHelper
79
+
80
+ # @see ActionController::Rendering#render
81
+ def render(*args)
82
+ set_navigation_menus
83
+
84
+ super
85
+ end
86
+ end
87
+
88
+ protected
89
+
90
+ def set_navigation_menus
91
+ @navigation_menus = Katalyst::Navigation::Menu.includes(:published_version).index_by(&:slug)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -39,7 +39,7 @@ module Katalyst
39
39
  def update
40
40
  menu = Menu.find(params[:id])
41
41
 
42
- menu.attributes = navigation_params
42
+ menu.attributes = menu_params
43
43
 
44
44
  unless menu.valid?
45
45
  return render :show, locals: { menu: menu }, status: :unprocessable_entity
@@ -62,20 +62,15 @@ module Katalyst
62
62
 
63
63
  menu.destroy!
64
64
 
65
- redirect_to action: :index
65
+ redirect_to action: :index, status: :see_other
66
66
  end
67
67
 
68
68
  private
69
69
 
70
70
  def menu_params
71
- params.require(:menu).permit(:title, :slug)
72
- end
73
-
74
- def navigation_params
75
71
  return {} if params[:menu].blank?
76
72
 
77
- params.require(:menu)
78
- .permit(:title, :slug, items_attributes: %i[id index depth])
73
+ params.require(:menu).permit(:title, :slug, :depth, items_attributes: %i[id index depth])
79
74
  end
80
75
  end
81
76
  end
@@ -8,7 +8,7 @@ module Katalyst
8
8
 
9
9
  def build(item, **options, &block)
10
10
  self.item = item
11
- content_tag(:div, default_options(id: dom_id(item), **options)) do
11
+ tag.div **default_options(id: dom_id(item), **options) do
12
12
  concat(capture { yield self }) if block
13
13
  concat fields(item)
14
14
  end
@@ -14,7 +14,7 @@ module Katalyst
14
14
  ACTIONS
15
15
 
16
16
  def build(options, &_block)
17
- content_tag :ol, default_options(id: menu_form_id, **options) do
17
+ tag.ol **default_options(id: menu_form_id, **options) do
18
18
  yield self
19
19
  end
20
20
  end
@@ -29,6 +29,10 @@ module Katalyst
29
29
  add_option(options, :data, :controller, MENU_CONTROLLER)
30
30
  add_option(options, :data, :action, ACTIONS)
31
31
 
32
+ depth = options.delete(:depth) || menu.depth
33
+
34
+ add_option(options, :data, :"#{MENU_CONTROLLER}-max-depth-value", depth) if depth
35
+
32
36
  options
33
37
  end
34
38
  end
@@ -10,7 +10,7 @@ module Katalyst
10
10
 
11
11
  def build(item, **options, &block)
12
12
  capture do
13
- concat(content_tag(:div, **default_options(options)) do
13
+ concat(tag.div(**default_options(options)) do
14
14
  concat capture(&block)
15
15
  concat item_template(item)
16
16
  end)
@@ -31,7 +31,7 @@ module Katalyst
31
31
  # events when the user initiates drag so that it can be copied into the
32
32
  # editor list on drop.
33
33
  def item_template(item)
34
- content_tag(:template, data: { "#{NEW_ITEM_CONTROLLER}-target" => "template" }) do
34
+ tag.template data: { "#{NEW_ITEM_CONTROLLER}-target" => "template" } do
35
35
  navigation_editor_items(item: item)
36
36
  end
37
37
  end
@@ -9,7 +9,7 @@ module Katalyst
9
9
  ACTIONS
10
10
 
11
11
  def build(**options)
12
- content_tag(:div, default_options(**options)) do
12
+ tag.div **default_options(**options) do
13
13
  concat status(:published, last_update: l(menu.updated_at, format: :short))
14
14
  concat status(:draft)
15
15
  concat status(:dirty)
@@ -24,7 +24,7 @@ module Katalyst
24
24
  end
25
25
 
26
26
  def actions
27
- content_tag(:menu) do
27
+ tag.menu do
28
28
  concat action(:discard, class: "button button--text")
29
29
  concat action(:revert, class: "button button--text") if menu.state == :draft
30
30
  concat action(:save, class: "button button--secondary")
@@ -8,15 +8,15 @@ module Katalyst
8
8
 
9
9
  delegate_missing_to :@template
10
10
 
11
- def initialize(template, menu: {}, list: {}, item: {})
11
+ def initialize(template, list: {}, item: {}, **menu_options)
12
12
  self.template = template
13
- self.menu_options = menu
13
+ self.menu_options = menu_options
14
14
  self.list_options = list
15
15
  self.item_options = item
16
16
  end
17
17
 
18
18
  def render(tree)
19
- content_tag(:ul, menu_options) do
19
+ tag.ul **menu_options do
20
20
  tree.each do |item|
21
21
  concat render_item(item)
22
22
  end
@@ -26,14 +26,14 @@ module Katalyst
26
26
  def render_item(item)
27
27
  return unless item.visible?
28
28
 
29
- content_tag :li, item_options do
29
+ tag.li **item_options do
30
30
  concat public_send("render_#{item.model_name.param_key}", item)
31
31
  concat render_list(item.children) if item.children.any?
32
32
  end
33
33
  end
34
34
 
35
35
  def render_list(items)
36
- content_tag :ul, list_options do
36
+ tag.ul **list_options do
37
37
  items.each do |child|
38
38
  concat render_item(child)
39
39
  end
@@ -5,15 +5,19 @@ module Katalyst
5
5
  module FrontendHelper
6
6
  mattr_accessor :navigation_builder
7
7
 
8
+ attr_internal :default_navigation_builder
9
+
8
10
  # Render a navigation menu. Caches based on the published version's id.
9
11
  #
10
12
  # @param(menu: Katalyst::Navigation::Menu)
11
13
  # @return Structured HTML containing top level + nested navigation links
12
- def render_navigation_menu(menu, item: {}, list: {}, **options)
14
+ def navigation_menu_with(menu:, **options)
15
+ builder = navigation_builder(**options)
16
+ menu = menu.is_a?(Symbol) ? navigation_menu_for(menu) : menu
17
+
13
18
  return unless menu&.published_version&.present?
14
19
 
15
20
  cache menu.published_version do
16
- builder = default_navigation_builder_class.new(self, menu: options, item: item, list: list)
17
21
  concat builder.render(menu.published_tree)
18
22
  end
19
23
  end
@@ -22,8 +26,9 @@ module Katalyst
22
26
  #
23
27
  # @param(items: [Katalyst::Navigation::Item])
24
28
  # @return Structured HTML containing top level + nested navigation links
25
- def render_navigation_items(items, list: {}, **options)
26
- builder = default_navigation_builder_class.new(self, item: options, list: list)
29
+ def navigation_items_with(items:, **options)
30
+ builder = navigation_builder(**options)
31
+
27
32
  capture do
28
33
  items.each do |item|
29
34
  concat builder.render_item(item)
@@ -33,8 +38,13 @@ module Katalyst
33
38
 
34
39
  private
35
40
 
41
+ def navigation_builder(**options)
42
+ builder = options.delete(:builder) || default_navigation_builder_class
43
+ builder.new(self, **options)
44
+ end
45
+
36
46
  def default_navigation_builder_class
37
- builder = controller.try(:default_navigation_builder) || Frontend::Builder
47
+ builder = default_navigation_builder || Frontend::Builder
38
48
  builder.respond_to?(:constantize) ? builder.constantize : builder
39
49
  end
40
50
  end
@@ -45,6 +45,7 @@ module Katalyst
45
45
 
46
46
  validates :title, :slug, presence: true
47
47
  validates :slug, uniqueness: true
48
+ validates :depth, numericality: { greater_than: 0, only_integer: true, allow_nil: true }
48
49
 
49
50
  # A menu is in draft mode if it has an unpublished draft or it has no published version.
50
51
  # @return the current state of the menu, either `published` or `draft`
@@ -8,7 +8,7 @@
8
8
  </div>
9
9
 
10
10
  <div class="url">
11
- <%= link_to item.url || "", item.url || "" %>
11
+ <%= link_to item.url || "", item.url || "", data: { turbo: false } %>
12
12
  </div>
13
13
 
14
14
  <%= builder.item_actions %>
@@ -1,15 +1,25 @@
1
1
  <% content_for :title, "Edit navigation" %>
2
2
 
3
3
  <%= form_with model: menu do |form| %>
4
- <div class="field">
4
+ <div class="field required">
5
5
  <%= form.label :title %>
6
6
  <%= form.text_field :title %>
7
7
  </div>
8
8
 
9
- <div class="field">
9
+ <div class="field required">
10
10
  <%= form.label :slug %>
11
11
  <%= form.text_field :slug %>
12
12
  </div>
13
13
 
14
- <%= form.submit :save %>
14
+ <div class="field optional">
15
+ <%= form.label :depth %>
16
+ <%= form.text_field :depth %>
17
+ </div>
18
+
19
+ <div class="button-row">
20
+ <%= form.submit :save %>
21
+ <%= link_to "Delete", url_for(action: :show),
22
+ class: "button button-secondary",
23
+ data: { turbo_method: :delete, turbo_confirm: "Are you sure?" } %>
24
+ </div>
15
25
  <% end %>
@@ -11,7 +11,4 @@
11
11
  <%= link_to cell.value, menu %>
12
12
  <% end %>
13
13
  <%= row.cell :slug %>
14
- <%= row.cell :actions do |cell| %>
15
- <%= button_to "Delete", menu, method: :delete, class: "button button--text" %>
16
- <% end %>
17
14
  <% end %>
@@ -1,15 +1,20 @@
1
1
  <% content_for :title, "New navigation" %>
2
2
 
3
3
  <%= form_with model: menu do |form| %>
4
- <div class="field">
4
+ <div class="field required">
5
5
  <%= form.label :title %>
6
6
  <%= form.text_field :title %>
7
7
  </div>
8
8
 
9
- <div class="field">
9
+ <div class="field required">
10
10
  <%= form.label :slug %>
11
11
  <%= form.text_field :slug %>
12
12
  </div>
13
13
 
14
+ <div class="field optional">
15
+ <%= form.label :depth %>
16
+ <%= form.text_field :depth %>
17
+ </div>
18
+
14
19
  <%= form.submit %>
15
20
  <% end %>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddDepthLimitToMenus < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :katalyst_navigation_menus, :depth, :integer
6
+ end
7
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Navigation
5
- VERSION = "1.0.1"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-navigation
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-09-05 00:00:00.000000000 Z
11
+ date: 2022-09-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -17,7 +17,6 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
- - CHANGELOG.md
21
20
  - LICENSE.txt
22
21
  - README.md
23
22
  - app/assets/config/katalyst-navigation.js
@@ -35,6 +34,7 @@ files:
35
34
  - app/assets/stylesheets/katalyst/navigation/editor/_item-rules.scss
36
35
  - app/assets/stylesheets/katalyst/navigation/editor/_new-items.scss
37
36
  - app/assets/stylesheets/katalyst/navigation/editor/_status-bar.scss
37
+ - app/controllers/concerns/katalyst/navigation/has_navigation.rb
38
38
  - app/controllers/katalyst/navigation/base_controller.rb
39
39
  - app/controllers/katalyst/navigation/items_controller.rb
40
40
  - app/controllers/katalyst/navigation/menus_controller.rb
@@ -73,6 +73,7 @@ files:
73
73
  - config/routes.rb
74
74
  - db/migrate/20220826034057_create_katalyst_navigation_menus.rb
75
75
  - db/migrate/20220826034507_create_katalyst_navigation_items.rb
76
+ - db/migrate/20220908044500_add_depth_limit_to_menus.rb
76
77
  - lib/katalyst/navigation.rb
77
78
  - lib/katalyst/navigation/engine.rb
78
79
  - lib/katalyst/navigation/version.rb
@@ -83,9 +84,6 @@ homepage: https://github.com/katalyst/navigation
83
84
  licenses:
84
85
  - MIT
85
86
  metadata:
86
- homepage_uri: https://github.com/katalyst/navigation
87
- source_code_uri: https://github.com/katalyst/navigation
88
- changelog_uri: https://github.com/katalyst/navigation/blob/main/CHANGELOG.md
89
87
  rubygems_mfa_required: 'true'
90
88
  post_install_message:
91
89
  rdoc_options: []
data/CHANGELOG.md DELETED
@@ -1,10 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [1.0.1]
4
-
5
- - Update - Draggable "new" menu items are now styled
6
- - Bugfix - leading indicator would shrink on narrow screens, or on heavily indented menus
7
-
8
- ## [1.0.0] - 2022-09-05
9
-
10
- - Initial release