katalyst-navigation 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +25 -0
  5. data/app/assets/config/katalyst-navigation.js +1 -0
  6. data/app/assets/javascripts/controllers/navigation/editor/item_controller.js +45 -0
  7. data/app/assets/javascripts/controllers/navigation/editor/list_controller.js +105 -0
  8. data/app/assets/javascripts/controllers/navigation/editor/menu_controller.js +110 -0
  9. data/app/assets/javascripts/controllers/navigation/editor/new_item_controller.js +12 -0
  10. data/app/assets/javascripts/controllers/navigation/editor/status_bar_controller.js +22 -0
  11. data/app/assets/javascripts/utils/navigation/editor/item.js +245 -0
  12. data/app/assets/javascripts/utils/navigation/editor/menu.js +54 -0
  13. data/app/assets/javascripts/utils/navigation/editor/rules-engine.js +140 -0
  14. data/app/assets/stylesheets/katalyst/navigation/editor/_icon.scss +17 -0
  15. data/app/assets/stylesheets/katalyst/navigation/editor/_index.scss +145 -0
  16. data/app/assets/stylesheets/katalyst/navigation/editor/_item-actions.scss +92 -0
  17. data/app/assets/stylesheets/katalyst/navigation/editor/_item-rules.scss +24 -0
  18. data/app/assets/stylesheets/katalyst/navigation/editor/_new-items.scss +22 -0
  19. data/app/assets/stylesheets/katalyst/navigation/editor/_status-bar.scss +87 -0
  20. data/app/controllers/katalyst/navigation/base_controller.rb +12 -0
  21. data/app/controllers/katalyst/navigation/items_controller.rb +57 -0
  22. data/app/controllers/katalyst/navigation/menus_controller.rb +82 -0
  23. data/app/helpers/katalyst/navigation/editor/base.rb +41 -0
  24. data/app/helpers/katalyst/navigation/editor/item.rb +69 -0
  25. data/app/helpers/katalyst/navigation/editor/list.rb +41 -0
  26. data/app/helpers/katalyst/navigation/editor/menu.rb +37 -0
  27. data/app/helpers/katalyst/navigation/editor/new_item.rb +53 -0
  28. data/app/helpers/katalyst/navigation/editor/status_bar.rb +57 -0
  29. data/app/helpers/katalyst/navigation/editor_helper.rb +46 -0
  30. data/app/helpers/katalyst/navigation/frontend/builder.rb +53 -0
  31. data/app/helpers/katalyst/navigation/frontend_helper.rb +42 -0
  32. data/app/models/concerns/katalyst/navigation/garbage_collection.rb +31 -0
  33. data/app/models/concerns/katalyst/navigation/has_tree.rb +63 -0
  34. data/app/models/katalyst/navigation/button.rb +15 -0
  35. data/app/models/katalyst/navigation/item.rb +21 -0
  36. data/app/models/katalyst/navigation/link.rb +10 -0
  37. data/app/models/katalyst/navigation/menu.rb +123 -0
  38. data/app/models/katalyst/navigation/node.rb +21 -0
  39. data/app/models/katalyst/navigation/types/nodes_type.rb +42 -0
  40. data/app/views/katalyst/navigation/items/_button.html.erb +28 -0
  41. data/app/views/katalyst/navigation/items/_link.html.erb +21 -0
  42. data/app/views/katalyst/navigation/items/edit.html.erb +4 -0
  43. data/app/views/katalyst/navigation/items/new.html.erb +4 -0
  44. data/app/views/katalyst/navigation/items/update.turbo_stream.erb +7 -0
  45. data/app/views/katalyst/navigation/menus/_item.html.erb +15 -0
  46. data/app/views/katalyst/navigation/menus/_list_item.html.erb +14 -0
  47. data/app/views/katalyst/navigation/menus/_new_item.html.erb +3 -0
  48. data/app/views/katalyst/navigation/menus/_new_items.html.erb +5 -0
  49. data/app/views/katalyst/navigation/menus/edit.html.erb +15 -0
  50. data/app/views/katalyst/navigation/menus/index.html.erb +17 -0
  51. data/app/views/katalyst/navigation/menus/new.html.erb +15 -0
  52. data/app/views/katalyst/navigation/menus/show.html.erb +15 -0
  53. data/config/importmap.rb +5 -0
  54. data/config/locales/en.yml +12 -0
  55. data/config/routes.rb +9 -0
  56. data/db/migrate/20220826034057_create_katalyst_navigation_menus.rb +25 -0
  57. data/db/migrate/20220826034507_create_katalyst_navigation_items.rb +17 -0
  58. data/lib/katalyst/navigation/engine.rb +36 -0
  59. data/lib/katalyst/navigation/version.rb +7 -0
  60. data/lib/katalyst/navigation.rb +9 -0
  61. data/lib/tasks/yarn.rake +18 -0
  62. data/spec/factories/katalyst/navigation/items.rb +14 -0
  63. data/spec/factories/katalyst/navigation/menus.rb +17 -0
  64. metadata +109 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class Base
7
+ MENU_CONTROLLER = "navigation--editor--menu"
8
+ LIST_CONTROLLER = "navigation--editor--list"
9
+ ITEM_CONTROLLER = "navigation--editor--item"
10
+ STATUS_BAR_CONTROLLER = "navigation--editor--status-bar"
11
+ NEW_ITEM_CONTROLLER = "navigation--editor--new-item"
12
+
13
+ ATTRIBUTES_SCOPE = "menu[items_attributes][]"
14
+ TURBO_FRAME = "navigation--editor--item-frame"
15
+
16
+ attr_accessor :template, :menu
17
+
18
+ delegate_missing_to :template
19
+
20
+ def initialize(template, menu)
21
+ self.template = template
22
+ self.menu = menu
23
+ end
24
+
25
+ def menu_form_id
26
+ dom_id(menu, :items)
27
+ end
28
+
29
+ private
30
+
31
+ def add_option(options, key, *path)
32
+ if path.length > 1
33
+ add_option(options[key] ||= {}, *path)
34
+ else
35
+ options[key] = [options[key], *path].compact.join(" ")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class Item < Base
7
+ attr_accessor :item
8
+
9
+ def build(item, **options, &block)
10
+ self.item = item
11
+ content_tag(:div, default_options(id: dom_id(item), **options)) do
12
+ concat(capture { yield self }) if block
13
+ concat fields(item)
14
+ end
15
+ end
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
+ def accordion_actions
25
+ tag.div role: "toolbar", data: { tree_accordion_controls: "" } do
26
+ concat tag.span(role: "button", value: "collapse",
27
+ data: { action: "click->#{MENU_CONTROLLER}#collapse", title: "Collapse tree" })
28
+ concat tag.span(role: "button", value: "expand",
29
+ data: { action: "click->#{MENU_CONTROLLER}#expand", title: "Expand tree" })
30
+ end
31
+ end
32
+
33
+ def item_actions
34
+ tag.div role: "toolbar", data: { tree_controls: "" } do
35
+ concat tag.span(role: "button", value: "de-nest",
36
+ data: { action: "click->#{MENU_CONTROLLER}#deNest", title: "Outdent" })
37
+ concat tag.span(role: "button", value: "nest",
38
+ data: { action: "click->#{MENU_CONTROLLER}#nest", title: "Indent" })
39
+ concat link_to("", edit_item_link,
40
+ role: "button", title: "Edit", value: "edit",
41
+ data: { turbo_frame: TURBO_FRAME })
42
+ concat tag.span(role: "button", value: "remove",
43
+ data: { action: "click->#{MENU_CONTROLLER}#remove", title: "Remove" })
44
+ end
45
+ end
46
+
47
+ def edit_item_link
48
+ item.persisted? ? edit_menu_item_path(item.menu, item) : new_menu_item_path(item.menu, type: item.type)
49
+ end
50
+
51
+ def fields(item)
52
+ template.fields(ATTRIBUTES_SCOPE, model: item, index: nil, skip_default_ids: true) do |f|
53
+ concat f.hidden_field(:id)
54
+ concat f.hidden_field(:depth)
55
+ concat f.hidden_field(:index)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def default_options(options)
62
+ add_option(options, :data, :controller, ITEM_CONTROLLER)
63
+
64
+ options
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class List < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ dragstart->#{LIST_CONTROLLER}#dragstart
9
+ dragover->#{LIST_CONTROLLER}#dragover
10
+ dragenter->#{LIST_CONTROLLER}#dragenter
11
+ dragleave->#{LIST_CONTROLLER}#dragleave
12
+ drop->#{LIST_CONTROLLER}#drop
13
+ dragend->#{LIST_CONTROLLER}#dragend
14
+ ACTIONS
15
+
16
+ def build(options, &_block)
17
+ content_tag :ol, default_options(id: menu_form_id, **options) do
18
+ yield self
19
+ end
20
+ end
21
+
22
+ def items(*items)
23
+ render partial: "katalyst/navigation/menus/item",
24
+ layout: "katalyst/navigation/menus/list_item",
25
+ collection: items,
26
+ as: :item
27
+ end
28
+
29
+ private
30
+
31
+ def default_options(options)
32
+ add_option(options, :data, :controller, LIST_CONTROLLER)
33
+ add_option(options, :data, :action, ACTIONS)
34
+ add_option(options, :data, :"#{MENU_CONTROLLER}_target", "menu")
35
+
36
+ options
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class Menu < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ submit->#{MENU_CONTROLLER}#reindex
9
+ navigation:reindex->#{MENU_CONTROLLER}#reindex
10
+ navigation:reset->#{MENU_CONTROLLER}#reset
11
+ ACTIONS
12
+
13
+ def build(options)
14
+ form_with(model: menu, **default_options(id: menu_form_id, **options)) do |form|
15
+ concat hidden_input
16
+ concat(capture { yield form })
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # Hidden input ensures that if the menu is empty then the controller
23
+ # receives an empty array.
24
+ def hidden_input
25
+ tag.input(type: "hidden", name: "#{Item::ATTRIBUTES_SCOPE}[id]")
26
+ end
27
+
28
+ def default_options(options)
29
+ add_option(options, :data, :controller, MENU_CONTROLLER)
30
+ add_option(options, :data, :action, ACTIONS)
31
+
32
+ options
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class NewItem < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ dragstart->#{NEW_ITEM_CONTROLLER}#dragstart
9
+ ACTIONS
10
+
11
+ def build(item, **options, &block)
12
+ capture do
13
+ concat(content_tag(:div, **default_options(options)) do
14
+ concat capture(&block)
15
+ concat item_template(item)
16
+ end)
17
+ concat turbo_replace_placeholder(item)
18
+ end
19
+ end
20
+
21
+ # Remove items that are incomplete when rendering new items, this
22
+ # causes incomplete items to be removed from the list when the user
23
+ # cancels adding a new item by pressing 'discard' in the new item form.
24
+ def turbo_replace_placeholder(item)
25
+ turbo_stream.replace dom_id(item) do
26
+ navigation_editor_item(item: item, data: { delete: "" })
27
+ end
28
+ end
29
+
30
+ # Template is stored inside the new item dom, and copied into drag
31
+ # events when the user initiates drag so that it can be copied into the
32
+ # editor list on drop.
33
+ def item_template(item)
34
+ content_tag(:template, data: { "#{NEW_ITEM_CONTROLLER}-target" => "template" }) do
35
+ navigation_editor_items(item: item)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def default_options(options)
42
+ add_option(options, :draggable, true)
43
+ add_option(options, :role, "listitem")
44
+ add_option(options, :data, :turbo_frame, TURBO_FRAME)
45
+ add_option(options, :data, :controller, NEW_ITEM_CONTROLLER)
46
+ add_option(options, :data, :action, ACTIONS)
47
+
48
+ options
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class StatusBar < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ navigation:change@document->#{STATUS_BAR_CONTROLLER}#change
9
+ ACTIONS
10
+
11
+ def build(**options)
12
+ content_tag(:div, default_options(**options)) do
13
+ concat status(:published, last_update: l(menu.updated_at, format: :short))
14
+ concat status(:draft)
15
+ concat status(:dirty)
16
+ concat actions
17
+ end
18
+ end
19
+
20
+ def status(state, **options)
21
+ tag.span(t("views.katalyst.navigation.editor.#{state}_html", **options),
22
+ class: "status-text",
23
+ data: { state => "" })
24
+ end
25
+
26
+ def actions
27
+ content_tag(:menu) do
28
+ concat action(:discard, class: "button button--text")
29
+ concat action(:revert, class: "button button--text") if menu.state == :draft
30
+ concat action(:save, class: "button button--secondary")
31
+ concat action(:publish, class: "button button--primary")
32
+ end
33
+ end
34
+
35
+ def action(action, **options)
36
+ tag.li do
37
+ button_tag(t("views.katalyst.navigation.editor.#{action}"),
38
+ name: "commit",
39
+ value: action,
40
+ form: menu_form_id,
41
+ **options)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def default_options(**options)
48
+ add_option(options, :data, :controller, STATUS_BAR_CONTROLLER)
49
+ add_option(options, :data, :action, ACTIONS)
50
+ add_option(options, :data, :state, menu.state)
51
+
52
+ options
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module EditorHelper
6
+ def navigation_editor_new_items(menu)
7
+ [
8
+ Link.new(menu: menu),
9
+ Button.new(menu: menu),
10
+ ]
11
+ end
12
+
13
+ def navigation_editor_menu(menu:, **options, &block)
14
+ Editor::Menu.new(self, menu).build(options, &block)
15
+ end
16
+
17
+ def navigation_editor_list(menu:, items: menu.draft_items, **options)
18
+ Editor::List.new(self, menu).build(options) do |list|
19
+ list.items(*items) if items.present?
20
+ end
21
+ end
22
+
23
+ # Generate items without their list wrapper, similar to form_with/fields
24
+ def navigation_editor_items(item:, menu: item.menu)
25
+ Editor::List.new(self, menu).items(item)
26
+ end
27
+
28
+ # Gene
29
+ def navigation_editor_new_item(item:, menu: item.menu, **options, &block)
30
+ Editor::NewItem.new(self, menu).build(item, **options, &block)
31
+ end
32
+
33
+ def navigation_editor_item(item:, menu: item.menu, **options, &block)
34
+ Editor::Item.new(self, menu).build(item, **options, &block)
35
+ end
36
+
37
+ def navigation_editor_item_fields(item:, menu: item.menu)
38
+ Editor::Item.new(self, menu).fields(item)
39
+ end
40
+
41
+ def navigation_editor_status_bar(menu:, **options)
42
+ Editor::StatusBar.new(self, menu).build(**options)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Frontend
6
+ class Builder
7
+ attr_accessor :template, :menu_options, :list_options, :item_options
8
+
9
+ delegate_missing_to :@template
10
+
11
+ def initialize(template, menu: {}, list: {}, item: {})
12
+ self.template = template
13
+ self.menu_options = menu
14
+ self.list_options = list
15
+ self.item_options = item
16
+ end
17
+
18
+ def render(tree)
19
+ content_tag(:ul, menu_options) do
20
+ tree.each do |item|
21
+ concat render_item(item)
22
+ end
23
+ end
24
+ end
25
+
26
+ def render_item(item)
27
+ return unless item.visible?
28
+
29
+ content_tag :li, item_options do
30
+ concat public_send("render_#{item.model_name.param_key}", item)
31
+ concat render_list(item.children) if item.children.any?
32
+ end
33
+ end
34
+
35
+ def render_list(items)
36
+ content_tag :ul, list_options do
37
+ items.each do |child|
38
+ concat render_item(child)
39
+ end
40
+ end
41
+ end
42
+
43
+ def render_link(link)
44
+ link_to(link.title, link.url, item_options)
45
+ end
46
+
47
+ def render_button(link)
48
+ link_to(link.title, link.url, **item_options, method: link.http_method)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module FrontendHelper
6
+ mattr_accessor :navigation_builder
7
+
8
+ # Render a navigation menu. Caches based on the published version's id.
9
+ #
10
+ # @param(menu: Katalyst::Navigation::Menu)
11
+ # @return Structured HTML containing top level + nested navigation links
12
+ def render_navigation_menu(menu, item: {}, list: {}, **options)
13
+ return unless menu&.published_version&.present?
14
+
15
+ cache menu.published_version do
16
+ builder = default_navigation_builder_class.new(self, menu: options, item: item, list: list)
17
+ concat builder.render(menu.published_tree)
18
+ end
19
+ end
20
+
21
+ # Render items without a wrapper list, useful for inline rendering of items
22
+ #
23
+ # @param(items: [Katalyst::Navigation::Item])
24
+ # @return Structured HTML containing top level + nested navigation links
25
+ def render_navigation_items(items, list: {}, **options)
26
+ builder = default_navigation_builder_class.new(self, item: options, list: list)
27
+ capture do
28
+ items.each do |item|
29
+ concat builder.render_item(item)
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def default_navigation_builder_class
37
+ builder = controller.try(:default_navigation_builder) || Frontend::Builder
38
+ builder.respond_to?(:constantize) ? builder.constantize : builder
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module GarbageCollection
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_update :remove_stale_versions
10
+ end
11
+
12
+ def remove_stale_versions
13
+ transaction do
14
+ # find all the versions that are not linked to the menu
15
+ orphaned_versions = versions.inactive
16
+
17
+ next unless orphaned_versions.any?
18
+
19
+ # find links that are not included in active versions
20
+ orphaned_items = items.pluck(:id) - versions.active.pluck(:nodes).flat_map { |k| k.map(&:id) }.uniq
21
+
22
+ # delete orphaned links with a 2 hour grace period to allow for in-progress editing
23
+ items.where(id: orphaned_items, updated_at: ..2.hours.ago).destroy_all
24
+
25
+ # delete orphaned versions without a grace period as they can only be created by updates
26
+ orphaned_versions.destroy_all
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module HasTree
6
+ # Constructs a tree from an ordered list of items with depth.
7
+ # * items that have higher depth than their predecessor are nested as `children`.
8
+ # * items with the same depth become siblings.
9
+ def tree
10
+ items.reduce(Builder.new, &:add).tree
11
+ end
12
+
13
+ class Builder
14
+ attr_reader :current, :depth, :children, :tree
15
+
16
+ def initialize
17
+ @depth = 0
18
+ @children = @tree = []
19
+ end
20
+
21
+ def add(node)
22
+ if node.depth == depth
23
+ node.parent = current
24
+ children << node
25
+ self
26
+ elsif node.depth > depth
27
+ push(children.last)
28
+ add(node)
29
+ else
30
+ pop
31
+ add(node)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_writer :current, :depth, :children
38
+
39
+ # Add node to the top of builder stacks
40
+ def push(node)
41
+ self.depth += 1
42
+ self.current = node
43
+ self.children = node.children = []
44
+ node
45
+ end
46
+
47
+ # Remove current from builder stack
48
+ def pop
49
+ previous = current
50
+ self.depth -= 1
51
+ if depth.zero?
52
+ self.current = nil
53
+ self.children = tree
54
+ else
55
+ self.current = previous.parent
56
+ self.children = current.children
57
+ end
58
+ previous
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ # Renders an HTML button using `button_to`.
6
+ class Button < Item
7
+ HTTP_METHODS = %i[get post patch put delete].index_by(&:itself).freeze
8
+
9
+ enum method: HTTP_METHODS, _prefix: :http
10
+
11
+ validates :title, :url, :http_method, presence: true
12
+ validates :http_method, inclusion: { in: HTTP_METHODS.values.map(&:to_s) }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ # STI base class for menu items (links and buttons)
6
+ class Item < ApplicationRecord
7
+ belongs_to :menu, inverse_of: :items, class_name: "Katalyst::Navigation::Menu"
8
+
9
+ after_initialize :initialize_tree
10
+
11
+ attr_accessor :parent, :children, :index, :depth
12
+
13
+ private
14
+
15
+ def initialize_tree
16
+ self.parent ||= nil
17
+ self.children ||= []
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ # Renders an HTML link using `link_to`.
6
+ class Link < Item
7
+ validates :title, :url, presence: true
8
+ end
9
+ end
10
+ end