katalyst-navigation 1.1.2 → 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: e67d176cb768b324a1be109e98dc001bd9ce8419abba00d4856cd0d3c84dc6c9
4
- data.tar.gz: 35555b9bac352c1e21786dcdddc638ccc3929e314003c7859dc3be56ad13c03e
3
+ metadata.gz: 6424bb96df0ceb96b40aa6d225dbe12461784edb41c1b83863435a56db02fd68
4
+ data.tar.gz: 6a46bbd339f8c171fe7a9791ad94281c6e0cb6f846f8bab04bd62edb8e8c339c
5
5
  SHA512:
6
- metadata.gz: 4ba4e987c87e8dfe56def8ef4d2f9214c7376cd285385008079b82b84f2ed44e354248d1d8aceaa50f2ac0cad81311935b038c5cedaff9001ab38199bea61b8a
7
- data.tar.gz: 0ac8744ff0e111c921974d82951f4c7b09f57df32d1c93d8c5cff0474de53b5254d53bbc2129d90ca81597ae08c8166e843f0609e58bd5c6311606da0fa1f4f7
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
 
@@ -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
  /**
@@ -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 %>
@@ -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.2"
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.2
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-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