katalyst-navigation 1.0.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.
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