katalyst-navigation 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +25 -0
- data/app/assets/config/katalyst-navigation.js +1 -0
- data/app/assets/javascripts/controllers/navigation/editor/item_controller.js +45 -0
- data/app/assets/javascripts/controllers/navigation/editor/list_controller.js +105 -0
- data/app/assets/javascripts/controllers/navigation/editor/menu_controller.js +110 -0
- data/app/assets/javascripts/controllers/navigation/editor/new_item_controller.js +12 -0
- data/app/assets/javascripts/controllers/navigation/editor/status_bar_controller.js +22 -0
- data/app/assets/javascripts/utils/navigation/editor/item.js +245 -0
- data/app/assets/javascripts/utils/navigation/editor/menu.js +54 -0
- data/app/assets/javascripts/utils/navigation/editor/rules-engine.js +140 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_icon.scss +17 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_index.scss +145 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_item-actions.scss +92 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_item-rules.scss +24 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_new-items.scss +22 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_status-bar.scss +87 -0
- data/app/controllers/katalyst/navigation/base_controller.rb +12 -0
- data/app/controllers/katalyst/navigation/items_controller.rb +57 -0
- data/app/controllers/katalyst/navigation/menus_controller.rb +82 -0
- data/app/helpers/katalyst/navigation/editor/base.rb +41 -0
- data/app/helpers/katalyst/navigation/editor/item.rb +69 -0
- data/app/helpers/katalyst/navigation/editor/list.rb +41 -0
- data/app/helpers/katalyst/navigation/editor/menu.rb +37 -0
- data/app/helpers/katalyst/navigation/editor/new_item.rb +53 -0
- data/app/helpers/katalyst/navigation/editor/status_bar.rb +57 -0
- data/app/helpers/katalyst/navigation/editor_helper.rb +46 -0
- data/app/helpers/katalyst/navigation/frontend/builder.rb +53 -0
- data/app/helpers/katalyst/navigation/frontend_helper.rb +42 -0
- data/app/models/concerns/katalyst/navigation/garbage_collection.rb +31 -0
- data/app/models/concerns/katalyst/navigation/has_tree.rb +63 -0
- data/app/models/katalyst/navigation/button.rb +15 -0
- data/app/models/katalyst/navigation/item.rb +21 -0
- data/app/models/katalyst/navigation/link.rb +10 -0
- data/app/models/katalyst/navigation/menu.rb +123 -0
- data/app/models/katalyst/navigation/node.rb +21 -0
- data/app/models/katalyst/navigation/types/nodes_type.rb +42 -0
- data/app/views/katalyst/navigation/items/_button.html.erb +28 -0
- data/app/views/katalyst/navigation/items/_link.html.erb +21 -0
- data/app/views/katalyst/navigation/items/edit.html.erb +4 -0
- data/app/views/katalyst/navigation/items/new.html.erb +4 -0
- data/app/views/katalyst/navigation/items/update.turbo_stream.erb +7 -0
- data/app/views/katalyst/navigation/menus/_item.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/_list_item.html.erb +14 -0
- data/app/views/katalyst/navigation/menus/_new_item.html.erb +3 -0
- data/app/views/katalyst/navigation/menus/_new_items.html.erb +5 -0
- data/app/views/katalyst/navigation/menus/edit.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/index.html.erb +17 -0
- data/app/views/katalyst/navigation/menus/new.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/show.html.erb +15 -0
- data/config/importmap.rb +5 -0
- data/config/locales/en.yml +12 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20220826034057_create_katalyst_navigation_menus.rb +25 -0
- data/db/migrate/20220826034507_create_katalyst_navigation_items.rb +17 -0
- data/lib/katalyst/navigation/engine.rb +36 -0
- data/lib/katalyst/navigation/version.rb +7 -0
- data/lib/katalyst/navigation.rb +9 -0
- data/lib/tasks/yarn.rake +18 -0
- data/spec/factories/katalyst/navigation/items.rb +14 -0
- data/spec/factories/katalyst/navigation/menus.rb +17 -0
- 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
|