katalyst-navigation 1.1.1 → 1.2.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: 5c41babca4e58e13c18e53437a8992f93a1f2e82566542ed43dd8f62a2a0a83f
4
- data.tar.gz: 4d3ea6600b660cf877ac8c53aa81be4367169ea27ffca16d86374ca0a61a0b41
3
+ metadata.gz: 6424bb96df0ceb96b40aa6d225dbe12461784edb41c1b83863435a56db02fd68
4
+ data.tar.gz: 6a46bbd339f8c171fe7a9791ad94281c6e0cb6f846f8bab04bd62edb8e8c339c
5
5
  SHA512:
6
- metadata.gz: dfff1369051123cbd3411fe4d873a8a1bee97166c09f05c117f6df9a11b0a0b618d8255349aed78213b76ee5b8c078807d904fd51899e803e2222f56d9beb086
7
- data.tar.gz: 07e367633bd15cc6f84aa242d5f785e82822f9354482ed071a90e4ed975aa4ed40eeac22bea5d30ab949031d7aa6702cfd18c7fae6f3f9beb08d2c202dbd6d1d
6
+ metadata.gz: 173290becb5111777b887baeefe3a93642d8b154cb6463fd0a84a83e0246be7159568cc836d3eb2ca4ca7b9d7a2b616eff69c284d7abbaa93f7f2a26e0f81f76
7
+ data.tar.gz: cad3f1cf87f00b7868aac073b71d192345b341feb73a0d28de332fccd932278988c256cc9228d2f30eadc55b2de0c4070d8a263c323e235b22f18f087bbc9abb
@@ -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
 
@@ -89,6 +115,7 @@ export default class MenuController extends Controller {
89
115
 
90
116
  this.updateRequested = false;
91
117
  const engine = new RulesEngine(this.maxDepthValue);
118
+ this.menu.items.forEach((item) => engine.normalize(item));
92
119
  this.menu.items.forEach((item) => engine.update(item));
93
120
 
94
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,12 +127,25 @@ 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));
133
+ this.#traverseCollapsed(callback);
134
+ expanded.forEach((item) => item.#traverseCollapsed(callback));
135
+ }
136
+
137
+ /**
138
+ * Recursively traverse the node's collapsed descendants, if any.
139
+ *
140
+ * @callback {Item}
141
+ */
142
+ #traverseCollapsed(callback) {
143
+ if (!this.hasCollapsedDescendants()) return;
144
+
145
+ this.#collapsedDescendants.forEach((item) => {
146
+ callback(item);
147
+ item.#traverseCollapsed(callback);
148
+ });
129
149
  }
130
150
 
131
151
  /**
@@ -162,10 +182,12 @@ export default class Item {
162
182
  * @param deny {boolean}
163
183
  */
164
184
  toggleRule(rule, deny = false) {
165
- if (this.node.dataset.hasOwnProperty(rule) && !deny)
185
+ if (this.node.dataset.hasOwnProperty(rule) && !deny) {
166
186
  delete this.node.dataset[rule];
167
- if (!this.node.dataset.hasOwnProperty(rule) && deny)
187
+ }
188
+ if (!this.node.dataset.hasOwnProperty(rule) && deny) {
168
189
  this.node.dataset[rule] = "";
190
+ }
169
191
 
170
192
  if (rule === "denyDrag") {
171
193
  if (!this.node.hasAttribute("draggable") && !deny) {
@@ -205,6 +227,9 @@ export default class Item {
205
227
  return this.node.querySelector(`:scope > [data-navigation-children]`);
206
228
  }
207
229
 
230
+ /**
231
+ * @returns {Item[]} all items that follow this element that have a greater depth.
232
+ */
208
233
  get #expandedDescendants() {
209
234
  const descendants = [];
210
235
 
@@ -217,7 +242,10 @@ export default class Item {
217
242
  return descendants;
218
243
  }
219
244
 
220
- get #collapsedChildren() {
245
+ /**
246
+ * @returns {Item[]} all items directly contained inside this element's hidden children element.
247
+ */
248
+ get #collapsedDescendants() {
221
249
  if (!this.hasCollapsedDescendants()) return [];
222
250
 
223
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
  /**
@@ -118,7 +118,7 @@ $status-dirty-color: #aaa !default;
118
118
  }
119
119
 
120
120
  // Ensures vertical alignment of header with rows
121
- [data-controller="navigation--editor-menu"] {
121
+ [data-controller="navigation--editor--menu"] {
122
122
  [role="rowheader"] {
123
123
  min-height: var(--row-height);
124
124
  background: var(--table-header-color);
@@ -14,13 +14,6 @@ module Katalyst
14
14
  end
15
15
  end
16
16
 
17
- def build_all(*items)
18
- render partial: "katalyst/navigation/menus/link_item",
19
- layout: "katalyst/navigation/menus/navigation_menu_link",
20
- collection: items,
21
- as: :item
22
- end
23
-
24
17
  def accordion_actions
25
18
  tag.div role: "toolbar", data: { tree_accordion_controls: "" } do
26
19
  concat tag.span(role: "button", value: "collapse",
@@ -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
@@ -5,6 +5,7 @@ module Katalyst
5
5
  module EditorHelper
6
6
  def navigation_editor_new_items(menu)
7
7
  [
8
+ Heading.new(menu: menu),
8
9
  Link.new(menu: menu),
9
10
  Button.new(menu: menu),
10
11
  ]
@@ -40,6 +40,10 @@ module Katalyst
40
40
  end
41
41
  end
42
42
 
43
+ def render_heading(heading)
44
+ tag.header { tag.p heading.title }
45
+ end
46
+
43
47
  def render_link(link)
44
48
  link_to(link.title, link.url, item_options)
45
49
  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,10 @@ module Katalyst
10
10
 
11
11
  attr_accessor :parent, :children, :index, :depth
12
12
 
13
+ def layout?
14
+ is_a? Layout
15
+ end
16
+
13
17
  private
14
18
 
15
19
  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 %>
@@ -2,7 +2,7 @@
2
2
  <div class="tree" data-invisible="<%= !item.visible? %>">
3
3
  <%= builder.accordion_actions %>
4
4
 
5
- <span role="img" value="<%= item.url.present? ? "link" : "title" %>" title="Hidden"></span>
5
+ <span role="img" value="<%= item.url.present? ? "link" : "title" %>" title="Type"></span>
6
6
  <h4 class="title" title="<%= item.title %>"><%= item.title %></h4>
7
7
  <span role="img" value="invisible" title="Hidden"></span>
8
8
  </div>
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Navigation
5
- VERSION = "1.1.1"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  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.1
4
+ version: 1.2.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-12 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