katalyst-navigation 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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