katalyst-navigation 1.1.2 → 1.3.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: 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