katalyst-navigation 1.1.1 → 1.2.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: 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