katalyst-navigation 1.1.2 → 1.3.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: e67d176cb768b324a1be109e98dc001bd9ce8419abba00d4856cd0d3c84dc6c9
4
- data.tar.gz: 35555b9bac352c1e21786dcdddc638ccc3929e314003c7859dc3be56ad13c03e
3
+ metadata.gz: 368bcecb2264a1a536d2bbeb6c05f2dfa6762dcd693a215db21ef93db4da26f8
4
+ data.tar.gz: 83ef6762cddd830df2acc4ef1404c8037211c4de4b3dbe5d8b5501bb4cf42fd7
5
5
  SHA512:
6
- metadata.gz: 4ba4e987c87e8dfe56def8ef4d2f9214c7376cd285385008079b82b84f2ed44e354248d1d8aceaa50f2ac0cad81311935b038c5cedaff9001ab38199bea61b8a
7
- data.tar.gz: 0ac8744ff0e111c921974d82951f4c7b09f57df32d1c93d8c5cff0474de53b5254d53bbc2129d90ca81597ae08c8166e843f0609e58bd5c6311606da0fa1f4f7
6
+ metadata.gz: a4e9965e0b7e49c055e405d90dd13dcb764f2b4b1b0fb62191347eb066e784dea5c1ee069b9a848fe283d87da1241fa840663d8152a92fe2b1f73607bae11b9b
7
+ data.tar.gz: df13a7692f558a2eb5937a4e85d7137540d175ca6be500a743bfbd1caa53fc4fbba1be0acc27cc6bedf665009a8ff58439bc1d2fc35ae76028059ec0ea0614f4
@@ -46,7 +46,7 @@ export default class ListController extends Controller {
46
46
  }
47
47
 
48
48
  drop(event) {
49
- const item = this.dragItem();
49
+ let item = this.dragItem();
50
50
 
51
51
  if (!item) return;
52
52
 
@@ -55,17 +55,22 @@ export default class ListController extends Controller {
55
55
  swap(this.dropTarget(event.target), item);
56
56
 
57
57
  if (item.dataset.hasOwnProperty("newItem")) {
58
+ const placeholder = item;
58
59
  const template = document.createElement("template");
59
60
  template.innerHTML = event.dataTransfer.getData("text/html");
60
- const newItem = template.content.querySelector("li");
61
+ item = template.content.querySelector("li");
61
62
 
62
- this.element.replaceChild(newItem, item);
63
+ this.element.replaceChild(item, placeholder);
63
64
  setTimeout(() =>
64
- newItem.querySelector("[role='button'][value='edit']").click()
65
+ item.querySelector("[role='button'][value='edit']").click()
65
66
  );
66
67
  }
67
68
 
68
- this.reindex();
69
+ this.dispatch("drop", {
70
+ target: item,
71
+ bubbles: true,
72
+ prefix: "navigation",
73
+ });
69
74
  }
70
75
 
71
76
  dragend() {
@@ -29,6 +29,32 @@ export default class MenuController extends Controller {
29
29
  this.menu.reset();
30
30
  }
31
31
 
32
+ drop(event) {
33
+ this.menu.reindex(); // set indexes before calculating previous
34
+
35
+ const item = getEventItem(event);
36
+ const previous = item.previousItem;
37
+
38
+ let delta = 0;
39
+ if (previous === undefined) {
40
+ // if previous does not exist, set depth to 0
41
+ delta = -item.depth;
42
+ } else if (item.nextItem && item.nextItem.depth > previous.depth) {
43
+ // if next is a child of previous, make item a child of previous
44
+ delta = previous.depth - item.depth + 1;
45
+ } else {
46
+ // otherwise, make item a sibling of previous
47
+ delta = previous.depth - item.depth;
48
+ }
49
+
50
+ item.traverse((child) => {
51
+ child.depth += delta;
52
+ });
53
+
54
+ this.#update();
55
+ event.preventDefault();
56
+ }
57
+
32
58
  remove(event) {
33
59
  const item = getEventItem(event);
34
60
 
@@ -41,7 +67,9 @@ export default class MenuController extends Controller {
41
67
  nest(event) {
42
68
  const item = getEventItem(event);
43
69
 
44
- item.nest();
70
+ item.traverse((child) => {
71
+ child.depth += 1;
72
+ });
45
73
 
46
74
  this.#update();
47
75
  event.preventDefault();
@@ -50,7 +78,9 @@ export default class MenuController extends Controller {
50
78
  deNest(event) {
51
79
  const item = getEventItem(event);
52
80
 
53
- item.deNest();
81
+ item.traverse((child) => {
82
+ child.depth -= 1;
83
+ });
54
84
 
55
85
  this.#update();
56
86
  event.preventDefault();
@@ -85,6 +115,7 @@ export default class MenuController extends Controller {
85
115
 
86
116
  this.updateRequested = false;
87
117
  const engine = new RulesEngine(this.maxDepthValue);
118
+ this.menu.items.forEach((item) => engine.normalize(item));
88
119
  this.menu.items.forEach((item) => engine.update(item));
89
120
 
90
121
  this.#notifyChange();
@@ -80,6 +80,13 @@ export default class Item {
80
80
  this.#indexInput.value = `${index}`;
81
81
  }
82
82
 
83
+ /**
84
+ * @returns {boolean} true if this item can have children
85
+ */
86
+ get isLayout() {
87
+ return this.node.hasAttribute("data-content-layout");
88
+ }
89
+
83
90
  /**
84
91
  * @returns {Item} nearest neighbour (index - 1)
85
92
  */
@@ -120,44 +127,24 @@ export default class Item {
120
127
  traverse(callback) {
121
128
  // capture descendants before traversal in case of side-effects
122
129
  // specifically, setting depth affects calculation
123
- const collapsed = this.#collapsedChildren;
124
130
  const expanded = this.#expandedDescendants;
125
131
 
126
132
  callback(this);
127
- collapsed.forEach((item) => item.traverse(callback));
128
- expanded.forEach((item) => item.traverse(callback));
129
- }
130
-
131
- /**
132
- * Increase the depth of this item and its descendants.
133
- * If this causes it to become a child of a collapsed item, then collapse this item.
134
- */
135
- nest() {
136
- this.traverse((child) => {
137
- child.depth += 1;
138
- });
139
-
140
- if (this.previousItem.hasCollapsedDescendants()) {
141
- this.previousItem.collapseChild(this);
142
- }
133
+ this.#traverseCollapsed(callback);
134
+ expanded.forEach((item) => item.#traverseCollapsed(callback));
143
135
  }
144
136
 
145
137
  /**
146
- * Move the given item into this element's hidden children list.
147
- * Assumes the list already exists.
138
+ * Recursively traverse the node's collapsed descendants, if any.
148
139
  *
149
- * @param item {Item}
140
+ * @callback {Item}
150
141
  */
151
- collapseChild(item) {
152
- this.#childrenListElement.appendChild(item.node);
153
- }
142
+ #traverseCollapsed(callback) {
143
+ if (!this.hasCollapsedDescendants()) return;
154
144
 
155
- /**
156
- * Decrease the depth of this item (and its descendants).
157
- */
158
- deNest() {
159
- this.traverse((child) => {
160
- child.depth -= 1;
145
+ this.#collapsedDescendants.forEach((item) => {
146
+ callback(item);
147
+ item.#traverseCollapsed(callback);
161
148
  });
162
149
  }
163
150
 
@@ -240,6 +227,9 @@ export default class Item {
240
227
  return this.node.querySelector(`:scope > [data-navigation-children]`);
241
228
  }
242
229
 
230
+ /**
231
+ * @returns {Item[]} all items that follow this element that have a greater depth.
232
+ */
243
233
  get #expandedDescendants() {
244
234
  const descendants = [];
245
235
 
@@ -252,7 +242,10 @@ export default class Item {
252
242
  return descendants;
253
243
  }
254
244
 
255
- get #collapsedChildren() {
245
+ /**
246
+ * @returns {Item[]} all items directly contained inside this element's hidden children element.
247
+ */
248
+ get #collapsedDescendants() {
256
249
  if (!this.hasCollapsedDescendants()) return [];
257
250
 
258
251
  return Array.from(this.#childrenListElement.children).map(
@@ -9,24 +9,38 @@ export default class RulesEngine {
9
9
  "denyEdit",
10
10
  ];
11
11
 
12
- constructor(maxDepth = null) {
12
+ constructor(maxDepth = null, debug = false) {
13
13
  this.maxDepth = maxDepth;
14
+ if (debug) {
15
+ this.debug = (...args) => console.log(...args);
16
+ } else {
17
+ this.debug = () => {};
18
+ }
14
19
  }
15
20
 
16
21
  /**
17
- * Apply rules to the given item by computing a ruleset then merging it
18
- * with the item's current state.
22
+ * Enforce structural rules to ensure that the given item is currently in a
23
+ * valid state.
19
24
  *
20
25
  * @param {Item} item
21
26
  */
22
- update(item) {
23
- this.rules = {};
24
-
27
+ normalize(item) {
25
28
  // structural rules enforce a valid tree structure
26
29
  this.firstItemDepthZero(item);
27
30
  this.depthMustBeSet(item);
28
31
  this.itemCannotHaveInvalidDepth(item);
29
32
  this.itemCannotExceedDepthLimit(item);
33
+ this.parentMustBeLayout(item);
34
+ this.parentCannotHaveExpandedAndCollapsedChildren(item);
35
+ }
36
+
37
+ /**
38
+ * Apply rules to the given item to determine what operations are permitted.
39
+ *
40
+ * @param {Item} item
41
+ */
42
+ update(item) {
43
+ this.rules = {};
30
44
 
31
45
  // behavioural rules define what the user is allowed to do
32
46
  this.parentsCannotDeNest(item);
@@ -47,7 +61,9 @@ export default class RulesEngine {
47
61
  * First item can't have a parent, so its depth should always be 0
48
62
  */
49
63
  firstItemDepthZero(item) {
50
- if (item.index === 0) {
64
+ if (item.index === 0 && item.depth !== 0) {
65
+ this.debug(`enforce depth on item ${item.index}: ${item.depth} => 0`);
66
+
51
67
  item.depth = 0;
52
68
  }
53
69
  }
@@ -59,6 +75,8 @@ export default class RulesEngine {
59
75
  */
60
76
  depthMustBeSet(item) {
61
77
  if (isNaN(item.depth) || item.depth < 0) {
78
+ this.debug(`unset depth on item ${item.index}: => 0`);
79
+
62
80
  item.depth = 0;
63
81
  }
64
82
  }
@@ -71,6 +89,12 @@ export default class RulesEngine {
71
89
  itemCannotHaveInvalidDepth(item) {
72
90
  const previous = item.previousItem;
73
91
  if (previous && previous.depth < item.depth - 1) {
92
+ this.debug(
93
+ `invalid depth on item ${item.index}: ${item.depth} => ${
94
+ previous.depth + 1
95
+ }`
96
+ );
97
+
74
98
  item.depth = previous.depth + 1;
75
99
  }
76
100
  }
@@ -91,6 +115,37 @@ export default class RulesEngine {
91
115
  }
92
116
  }
93
117
 
118
+ /**
119
+ * Parent item, if any, must be a layout.
120
+ *
121
+ * @param {Item} item
122
+ */
123
+ parentMustBeLayout(item) {
124
+ // if we're the first child, make sure our parent is a layout
125
+ // if we're a sibling, we know the previous item is valid so we must be too
126
+ const previous = item.previousItem;
127
+ if (previous && previous.depth < item.depth && !previous.isLayout) {
128
+ this.debug(
129
+ `invalid parent for item ${item.index}: ${item.depth} => ${previous.depth}`
130
+ );
131
+
132
+ item.depth = previous.depth;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * If a parent has expanded and collapsed children, expand.
138
+ *
139
+ * @param {Item} item
140
+ */
141
+ parentCannotHaveExpandedAndCollapsedChildren(item) {
142
+ if (item.hasCollapsedDescendants() && item.hasExpandedDescendants()) {
143
+ this.debug(`expanding collapsed children of item ${item.index}`);
144
+
145
+ item.expand();
146
+ }
147
+ }
148
+
94
149
  /**
95
150
  * De-nesting an item would create a gap of 2 between itself and its children
96
151
  *
@@ -134,7 +189,13 @@ export default class RulesEngine {
134
189
  */
135
190
  nestingNeedsParent(item) {
136
191
  const previous = item.previousItem;
137
- if (!previous || previous.depth < item.depth) this.#deny("denyNest");
192
+ // no previous, so cannot nest
193
+ if (!previous) this.#deny("denyNest");
194
+ // previous is too shallow, nesting would increase depth too much
195
+ else if (previous.depth < item.depth) this.#deny("denyNest");
196
+ // new parent is not a layout
197
+ else if (previous.depth === item.depth && !previous.isLayout)
198
+ this.#deny("denyNest");
138
199
  }
139
200
 
140
201
  /**
@@ -154,7 +215,7 @@ export default class RulesEngine {
154
215
  * @param {Item} item
155
216
  */
156
217
  parentsCannotBeDeleted(item) {
157
- if (item.hasExpandedDescendants()) this.#deny("denyRemove");
218
+ if (!item.itemId || item.hasExpandedDescendants()) this.#deny("denyRemove");
158
219
  }
159
220
 
160
221
  /**
@@ -41,8 +41,17 @@ module Katalyst
41
41
  params[:type] || Link.name
42
42
  end
43
43
 
44
+ def item_params_type
45
+ type = params.require(:item).fetch(:type, "")
46
+ if Katalyst::Navigation.config.items.include?(type)
47
+ type.safe_constantize
48
+ else
49
+ Item
50
+ end
51
+ end
52
+
44
53
  def item_params
45
- params.require(:item).permit(%i[title url visible http_method new_tab type])
54
+ params.require(:item).permit(item_params_type.permitted_params)
46
55
  end
47
56
 
48
57
  def set_menu
@@ -6,6 +6,7 @@ module Katalyst
6
6
  class Menu < Base
7
7
  ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
8
  submit->#{MENU_CONTROLLER}#reindex
9
+ navigation:drop->#{MENU_CONTROLLER}#drop
9
10
  navigation:reindex->#{MENU_CONTROLLER}#reindex
10
11
  navigation:reset->#{MENU_CONTROLLER}#reset
11
12
  ACTIONS
@@ -4,10 +4,10 @@ module Katalyst
4
4
  module Navigation
5
5
  module EditorHelper
6
6
  def navigation_editor_new_items(menu)
7
- [
8
- Link.new(menu: menu),
9
- Button.new(menu: menu),
10
- ]
7
+ Katalyst::Navigation.config.items.map do |item_class|
8
+ item_class = item_class.is_a?(String) ? item_class.safe_constantize : item_class
9
+ item_class.new(menu: menu)
10
+ end
11
11
  end
12
12
 
13
13
  def navigation_editor_menu(menu:, **options, &block)
@@ -1,22 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Rails/HelperInstanceVariable
3
4
  module Katalyst
4
5
  module Navigation
5
6
  module Frontend
6
7
  class Builder
7
- attr_accessor :template, :menu_options, :list_options, :item_options
8
+ attr_accessor :template
8
9
 
9
10
  delegate_missing_to :@template
10
11
 
11
12
  def initialize(template, list: {}, item: {}, **menu_options)
12
- self.template = template
13
- self.menu_options = menu_options
14
- self.list_options = list
15
- self.item_options = item
13
+ self.template = template
14
+ @menu_options = menu_options.freeze
15
+ @list_options = list.freeze
16
+ @item_options = item.freeze
16
17
  end
17
18
 
18
19
  def render(tree)
19
- tag.ul **menu_options do
20
+ tag.ul(**menu_options(tree)) do
20
21
  tree.each do |item|
21
22
  concat render_item(item)
22
23
  end
@@ -26,28 +27,47 @@ module Katalyst
26
27
  def render_item(item)
27
28
  return unless item.visible?
28
29
 
29
- tag.li **item_options do
30
+ tag.li(**item_options(item)) do
30
31
  concat public_send("render_#{item.model_name.param_key}", item)
31
- concat render_list(item.children) if item.children.any?
32
+ concat render_children(item) if item.children.any?
32
33
  end
33
34
  end
34
35
 
35
- def render_list(items)
36
- tag.ul **list_options do
37
- items.each do |child|
36
+ def render_children(item)
37
+ tag.ul(**list_options(item)) do
38
+ item.children.each do |child|
38
39
  concat render_item(child)
39
40
  end
40
41
  end
41
42
  end
42
43
 
44
+ def render_heading(heading)
45
+ tag.span(heading.title)
46
+ end
47
+
43
48
  def render_link(link)
44
- link_to(link.title, link.url, item_options)
49
+ link_to(link.title, link.url)
45
50
  end
46
51
 
47
52
  def render_button(link)
48
- link_to(link.title, link.url, **item_options, method: link.http_method)
53
+ link_to(link.title, link.url, method: link.http_method)
54
+ end
55
+
56
+ private
57
+
58
+ def menu_options(_tree)
59
+ @menu_options
60
+ end
61
+
62
+ def list_options(_item)
63
+ @list_options
64
+ end
65
+
66
+ def item_options(_item)
67
+ @item_options
49
68
  end
50
69
  end
51
70
  end
52
71
  end
53
72
  end
73
+ # rubocop:enable Rails/HelperInstanceVariable
@@ -10,6 +10,10 @@ module Katalyst
10
10
 
11
11
  validates :title, :url, :http_method, presence: true
12
12
  validates :http_method, inclusion: { in: HTTP_METHODS.values.map(&:to_s) }
13
+
14
+ def self.permitted_params
15
+ super + %i[http_method]
16
+ end
13
17
  end
14
18
  end
15
19
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ class Heading < Layout
6
+ validates :title, presence: true
7
+ end
8
+ end
9
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Katalyst
4
4
  module Navigation
5
- # STI base class for menu items (links and buttons)
5
+ # STI base class for menu items (headings, links and buttons)
6
6
  class Item < ApplicationRecord
7
7
  belongs_to :menu, inverse_of: :items, class_name: "Katalyst::Navigation::Menu"
8
8
 
@@ -10,6 +10,20 @@ module Katalyst
10
10
 
11
11
  attr_accessor :parent, :children, :index, :depth
12
12
 
13
+ def self.permitted_params
14
+ %i[
15
+ title
16
+ url
17
+ visible
18
+ new_tab
19
+ type
20
+ ]
21
+ end
22
+
23
+ def layout?
24
+ is_a? Layout
25
+ end
26
+
13
27
  private
14
28
 
15
29
  def initialize_tree
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ # Base class for Layout items
6
+ class Layout < Item
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ <%= form_with model: item, scope: :item, url: path do |form| %>
2
+ <%= form.hidden_field :type %>
3
+
4
+ <div class="field">
5
+ <%= form.label :title %>
6
+ <%= form.text_field :title %>
7
+ </div>
8
+
9
+ <div class="field">
10
+ <%= form.label :visible %>
11
+ <%= form.check_box :visible %>
12
+ </div>
13
+
14
+ <%= form.submit "Done" %>
15
+ <%= link_to "Discard", item.menu %>
16
+ <% end %>
@@ -3,6 +3,7 @@
3
3
  data-navigation-item-id="<%= item.id %>"
4
4
  data-navigation-index="<%= item.index %>"
5
5
  data-navigation-depth="<%= item.depth %>"
6
+ <%= "data-content-layout" if item.layout? %>
6
7
  data-deny-de-nest
7
8
  data-deny-nest
8
9
  data-deny-collapse
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/configurable"
4
+
5
+ module Katalyst
6
+ module Navigation
7
+ class Config
8
+ include ActiveSupport::Configurable
9
+
10
+ config_accessor(:items) do
11
+ %w[
12
+ Katalyst::Navigation::Heading
13
+ Katalyst::Navigation::Link
14
+ Katalyst::Navigation::Button
15
+ ]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Navigation
5
- VERSION = "1.1.2"
5
+ VERSION = "1.3.0"
6
6
  end
7
7
  end
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "katalyst/navigation/config"
3
4
  require "katalyst/navigation/engine"
4
5
  require "katalyst/navigation/version"
5
6
 
6
7
  module Katalyst
7
8
  module Navigation # :nodoc:
9
+ extend self
10
+
11
+ def config
12
+ @config ||= Config.new
13
+ end
14
+
15
+ def configure
16
+ yield config
17
+ end
8
18
  end
9
19
  end
@@ -11,4 +11,8 @@ FactoryBot.define do
11
11
  url { Faker::Internet.unique.url }
12
12
  http_method { Katalyst::Navigation::Button::HTTP_METHODS.keys.sample }
13
13
  end
14
+
15
+ factory :katalyst_navigation_heading, aliases: [:navigation_heading], class: "Katalyst::Navigation::Heading" do
16
+ title { Faker::Beer.hop }
17
+ end
14
18
  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.1.2
4
+ version: 1.3.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-15 00:00:00.000000000 Z
11
+ date: 2022-10-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -50,12 +50,15 @@ files:
50
50
  - app/models/concerns/katalyst/navigation/garbage_collection.rb
51
51
  - app/models/concerns/katalyst/navigation/has_tree.rb
52
52
  - app/models/katalyst/navigation/button.rb
53
+ - app/models/katalyst/navigation/heading.rb
53
54
  - app/models/katalyst/navigation/item.rb
55
+ - app/models/katalyst/navigation/layout.rb
54
56
  - app/models/katalyst/navigation/link.rb
55
57
  - app/models/katalyst/navigation/menu.rb
56
58
  - app/models/katalyst/navigation/node.rb
57
59
  - app/models/katalyst/navigation/types/nodes_type.rb
58
60
  - app/views/katalyst/navigation/items/_button.html.erb
61
+ - app/views/katalyst/navigation/items/_heading.html.erb
59
62
  - app/views/katalyst/navigation/items/_link.html.erb
60
63
  - app/views/katalyst/navigation/items/edit.html.erb
61
64
  - app/views/katalyst/navigation/items/new.html.erb
@@ -75,6 +78,7 @@ files:
75
78
  - db/migrate/20220826034507_create_katalyst_navigation_items.rb
76
79
  - db/migrate/20220908044500_add_depth_limit_to_menus.rb
77
80
  - lib/katalyst/navigation.rb
81
+ - lib/katalyst/navigation/config.rb
78
82
  - lib/katalyst/navigation/engine.rb
79
83
  - lib/katalyst/navigation/version.rb
80
84
  - lib/tasks/yarn.rake