active_element 0.0.1 → 0.0.3

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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -0
  3. data/.strong_versions.yml +2 -0
  4. data/Gemfile +10 -2
  5. data/Gemfile.lock +229 -4
  6. data/Rakefile +1 -0
  7. data/active_element.gemspec +7 -0
  8. data/app/assets/config/active_element/manifest.js +2 -0
  9. data/app/assets/javascripts/active_element/application.js +10 -0
  10. data/app/assets/javascripts/active_element/confirm.js +67 -0
  11. data/app/assets/javascripts/active_element/form.js +61 -0
  12. data/app/assets/javascripts/active_element/json_field.js +316 -0
  13. data/app/assets/javascripts/active_element/pagination.js +18 -0
  14. data/app/assets/javascripts/active_element/search_field.js +127 -0
  15. data/app/assets/javascripts/active_element/secret.js +40 -0
  16. data/app/assets/javascripts/active_element/setup.js +36 -0
  17. data/app/assets/javascripts/active_element/theme.js +42 -0
  18. data/app/assets/stylesheets/active_element/_variables.scss +142 -0
  19. data/app/assets/stylesheets/active_element/application.scss +77 -0
  20. data/app/controllers/active_element/application_controller.rb +41 -0
  21. data/app/controllers/active_element/text_searches_controller.rb +189 -0
  22. data/app/views/active_element/components/_horizontal_tabs.html.erb +32 -0
  23. data/app/views/active_element/components/_vertical_tabs.html.erb +38 -0
  24. data/app/views/active_element/components/button.html.erb +27 -0
  25. data/app/views/active_element/components/fields/_boolean.html.erb +11 -0
  26. data/app/views/active_element/components/form/_check_box.html.erb +3 -0
  27. data/app/views/active_element/components/form/_check_boxes.html.erb +33 -0
  28. data/app/views/active_element/components/form/_field.html.erb +28 -0
  29. data/app/views/active_element/components/form/_generic_field.html.erb +3 -0
  30. data/app/views/active_element/components/form/_json.html.erb +12 -0
  31. data/app/views/active_element/components/form/_label.html.erb +17 -0
  32. data/app/views/active_element/components/form/_option_groups_summary.html.erb +17 -0
  33. data/app/views/active_element/components/form/_select.html.erb +4 -0
  34. data/app/views/active_element/components/form/_summary.html.erb +40 -0
  35. data/app/views/active_element/components/form/_templates.html.erb +85 -0
  36. data/app/views/active_element/components/form/_text_area.html.erb +4 -0
  37. data/app/views/active_element/components/form/_text_search.html.erb +16 -0
  38. data/app/views/active_element/components/form.html.erb +78 -0
  39. data/app/views/active_element/components/json.html.erb +8 -0
  40. data/app/views/active_element/components/page_description.html.erb +3 -0
  41. data/app/views/active_element/components/secret/_field.html.erb +1 -0
  42. data/app/views/active_element/components/secret/_templates.html.erb +11 -0
  43. data/app/views/active_element/components/table/_collection_row.html.erb +30 -0
  44. data/app/views/active_element/components/table/_grouped_collection.html.erb +88 -0
  45. data/app/views/active_element/components/table/_pagination.html.erb +17 -0
  46. data/app/views/active_element/components/table/_ungrouped_collection.html.erb +49 -0
  47. data/app/views/active_element/components/table/collection.html.erb +39 -0
  48. data/app/views/active_element/components/table/item.html.erb +39 -0
  49. data/app/views/active_element/components/tabs.html.erb +7 -0
  50. data/app/views/active_element/decorators/_boolean.html.erb +5 -0
  51. data/app/views/active_element/decorators/_date.html.erb +3 -0
  52. data/app/views/active_element/decorators/_datetime.html.erb +3 -0
  53. data/app/views/active_element/decorators/_time.html.erb +3 -0
  54. data/app/views/active_element/forbidden.html.erb +33 -0
  55. data/app/views/active_element/main_menu/_item.html.erb +9 -0
  56. data/app/views/active_element/navbar/_menu.html.erb +30 -0
  57. data/app/views/active_element/theme/_select.html.erb +1 -0
  58. data/app/views/active_element/theme/_templates.html.erb +6 -0
  59. data/app/views/kaminari/_first_page.html.erb +3 -0
  60. data/app/views/kaminari/_gap.html.erb +3 -0
  61. data/app/views/kaminari/_last_page.html.erb +3 -0
  62. data/app/views/kaminari/_next_page.html.erb +3 -0
  63. data/app/views/kaminari/_page.html.erb +9 -0
  64. data/app/views/kaminari/_paginator.html.erb +17 -0
  65. data/app/views/kaminari/_prev_page.html.erb +3 -0
  66. data/app/views/layouts/active_element.html.erb +65 -0
  67. data/app/views/layouts/active_element_error.html.erb +40 -0
  68. data/config/routes.rb +5 -0
  69. data/lib/active_element/active_menu_link.rb +80 -0
  70. data/lib/active_element/active_record_text_search_authorization.rb +12 -0
  71. data/lib/active_element/colorized_string.rb +33 -0
  72. data/lib/active_element/component.rb +122 -0
  73. data/lib/active_element/components/button.rb +156 -0
  74. data/lib/active_element/components/collection_table.rb +118 -0
  75. data/lib/active_element/components/form.rb +210 -0
  76. data/lib/active_element/components/item_table.rb +57 -0
  77. data/lib/active_element/components/json.rb +31 -0
  78. data/lib/active_element/components/link_helpers.rb +9 -0
  79. data/lib/active_element/components/page_description.rb +28 -0
  80. data/lib/active_element/components/secret_fields.rb +15 -0
  81. data/lib/active_element/components/tab.rb +37 -0
  82. data/lib/active_element/components/tabs.rb +35 -0
  83. data/lib/active_element/components/translations.rb +18 -0
  84. data/lib/active_element/components/util/association_mapping.rb +80 -0
  85. data/lib/active_element/components/util/decorator.rb +107 -0
  86. data/lib/active_element/components/util/display_value_mapping.rb +48 -0
  87. data/lib/active_element/components/util/field_mapping.rb +144 -0
  88. data/lib/active_element/components/util/form_field_mapping.rb +104 -0
  89. data/lib/active_element/components/util/form_value_mapping.rb +49 -0
  90. data/lib/active_element/components/util/i18n.rb +66 -0
  91. data/lib/active_element/components/util/record_mapping.rb +111 -0
  92. data/lib/active_element/components/util.rb +43 -0
  93. data/lib/active_element/components.rb +20 -0
  94. data/lib/active_element/controller_action.rb +91 -0
  95. data/lib/active_element/engine.rb +26 -0
  96. data/lib/active_element/permissions_check.rb +101 -0
  97. data/lib/active_element/rails_component.rb +40 -0
  98. data/lib/active_element/route.rb +112 -0
  99. data/lib/active_element/routes.rb +62 -0
  100. data/lib/active_element/version.rb +1 -1
  101. data/lib/active_element.rb +91 -1
  102. data/lib/tasks/active_element.rake +23 -0
  103. data/rspec-documentation/dummy +1 -0
  104. data/rspec-documentation/pages/Components/Forms.md +1 -0
  105. data/rspec-documentation/pages/Components/Tables.md +47 -0
  106. data/rspec-documentation/pages/Components/Tabs.md +1 -0
  107. data/rspec-documentation/pages/Components.md +1 -0
  108. data/rspec-documentation/pages/Decorators/Inline Decorators.md +1 -0
  109. data/rspec-documentation/pages/Decorators/View Decorators.md +1 -0
  110. data/rspec-documentation/pages/Index.md +3 -0
  111. data/rspec-documentation/pages/Util/I18n.md +1 -0
  112. data/rspec-documentation/spec_helper.rb +35 -0
  113. metadata +191 -3
@@ -0,0 +1,3 @@
1
+ <li class="page-item">
2
+ <%= link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, remote: remote, class: 'page-link' %>
3
+ </li>
@@ -0,0 +1,3 @@
1
+ <li class="page-item">
2
+ <%= link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, rel: 'next', remote: remote, class: 'page-link' %>
3
+ </li>
@@ -0,0 +1,9 @@
1
+ <% if page.current? %>
2
+ <li class="page-item active">
3
+ <%= content_tag :a, page, data: { remote: remote }, rel: page.rel, class: 'page-link' %>
4
+ </li>
5
+ <% else %>
6
+ <li class="page-item">
7
+ <%= link_to page, url, remote: remote, rel: page.rel, class: 'page-link' %>
8
+ </li>
9
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <%= paginator.render do %>
2
+ <nav>
3
+ <ul class="pagination">
4
+ <%= first_page_tag unless current_page.first? %>
5
+ <%= prev_page_tag unless current_page.first? %>
6
+ <% each_page do |page| %>
7
+ <% if page.left_outer? || page.right_outer? || page.inside_window? %>
8
+ <%= page_tag page %>
9
+ <% elsif !page.was_truncated? -%>
10
+ <%= gap_tag %>
11
+ <% end %>
12
+ <% end %>
13
+ <%= next_page_tag unless current_page.last? %>
14
+ <%= last_page_tag unless current_page.last? %>
15
+ </ul>
16
+ </nav>
17
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <li class="page-item">
2
+ <%= link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, rel: 'prev', remote: remote, class: 'page-link' %>
3
+ </li>
@@ -0,0 +1,65 @@
1
+ <html>
2
+ <head>
3
+ <%= render_active_element_hook 'active_element/before_head' %>
4
+
5
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
6
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" integrity="sha512-fD9DI5bZwQxOi7MhYWnnNPlvXdp/2Pj3XSTRrFs5FQa4mizyGLnJcN6tuvUS6LbmgN1ut+XGSABKvjN0H6Aoow==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js" integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
8
+
9
+ <%= stylesheet_link_tag 'active_element/application', 'data-turbolinks-track': 'reload' %>
10
+ <%= javascript_include_tag 'active_element/application', 'data-turbolinks-track': 'reload' %>
11
+
12
+ <% if Rails.application.assets.find_asset('application.css').present? %>
13
+ <%= stylesheet_link_tag 'application', 'data-turbolinks-track': 'reload' %>
14
+ <% end %>
15
+
16
+ <% if Rails.application.assets.find_asset('application.js').present? %>
17
+ <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
18
+ <% end %>
19
+
20
+ <%= csrf_meta_tag %>
21
+
22
+ <%= render_active_element_hook 'active_element/after_head' %>
23
+ </head>
24
+
25
+ <body>
26
+ <%= render_active_element_hook 'active_element/before_navbar' %>
27
+ <%= render partial: 'active_element/navbar/menu' %>
28
+ <%= render_active_element_hook 'active_element/after_navbar' %>
29
+
30
+ <% flash.each do |type, message| %>
31
+ <div class="flash card text-bg-<%= { notice: 'info', alert: 'danger' }.fetch(type.to_sym, 'info') %> flash <%= type %> float-end">
32
+ <div class="card-body">
33
+ <%= message %>
34
+ <button type="button" class="btn-close" aria-label="Close"></button>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+
39
+ <%= render_active_element_hook 'active_element/before_content' %>
40
+
41
+ <div class="content p-3 m-3">
42
+ <%= yield %>
43
+ </div>
44
+
45
+ <%= render partial: 'active_element/components/form/templates' %>
46
+ <%= render partial: 'active_element/components/secret/templates' %>
47
+ <%= render partial: 'active_element/theme/templates' %>
48
+
49
+ <%= render_active_element_hook 'active_element/after_content' %>
50
+
51
+ <script>
52
+ var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
53
+ var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
54
+ return new bootstrap.Popover(popoverTriggerEl)
55
+ })
56
+
57
+ document.querySelectorAll('.flash .btn-close').forEach(element => {
58
+ element.onclick = ev => {
59
+ ev.stopPropagation();
60
+ ev.target.parentElement.parentElement.classList.add('d-none');
61
+ };
62
+ });
63
+ </script>
64
+ </body>
65
+ </html>
@@ -0,0 +1,40 @@
1
+ <html>
2
+ <head>
3
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
4
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
5
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
6
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" integrity="sha512-fD9DI5bZwQxOi7MhYWnnNPlvXdp/2Pj3XSTRrFs5FQa4mizyGLnJcN6tuvUS6LbmgN1ut+XGSABKvjN0H6Aoow==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
7
+ </head>
8
+
9
+ <style>
10
+ body {
11
+ background-color: #623131;
12
+ }
13
+
14
+ a {
15
+ color: #d7df93;
16
+ }
17
+
18
+ div.content {
19
+ position: absolute;
20
+ color: #efefef;
21
+ left: 50%;
22
+ top: 40%;
23
+ -webkit-transform: translate(-50%, -50%);
24
+ transform: translate(-50%, -50%);
25
+ text-align: center;
26
+ }
27
+ </style>
28
+
29
+ <%= render_active_element_hook 'active_element/before_error_content' %>
30
+
31
+ <body>
32
+ <div class="wrapper">
33
+ <div class="middle">
34
+ <div class="content p-3 m-3">
35
+ <%= yield %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </body>
40
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveElement::Engine.routes.draw do
4
+ post '/_text_search', controller: 'active_element/text_searches', action: 'create'
5
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Detects which link should be highlighted in the main application menu.
5
+ class ActiveMenuLink
6
+ # rubocop:disable Metrics/ParameterLists
7
+ def initialize(rails_component:, current_path:, controller_path:, action_name:, current_navbar_item:, navbar_items:)
8
+ @rails_component = rails_component
9
+ @current_path = current_path
10
+ @controller_path = controller_path
11
+ @action_name = action_name
12
+ @current_navbar_item = current_navbar_item
13
+ @navbar_items = navbar_items
14
+ end
15
+ # rubocop:enable Metrics/ParameterLists
16
+
17
+ def active?
18
+ return true if exact_match?
19
+ return true if !any_exact_match? && exact_without_query_string_match?
20
+ return true if !any_exact_match? && !any_exact_without_query_string_match? && exact_without_resource_match?
21
+
22
+ false
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :rails_component,
28
+ :current_path, :controller_path, :action_name,
29
+ :current_navbar_item, :navbar_items
30
+
31
+ def exact_match?(navbar_item = current_navbar_item)
32
+ exact_matched_route(navbar_item)&.fetch(:path) == current_path
33
+ end
34
+
35
+ def exact_matched_route(navbar_item)
36
+ rails_component.route_paths_with_requirements.find do |route_path_with_requirements|
37
+ route_path_with_requirements[:path] == navbar_item[:path]
38
+ end
39
+ end
40
+
41
+ def any_exact_match?
42
+ navbar_items.any? do |navbar_item|
43
+ exact_match?(navbar_item)
44
+ end
45
+ end
46
+
47
+ def exact_without_query_string_match?(navbar_item = current_navbar_item)
48
+ exact_matched_route_without_query_string(navbar_item)&.fetch(:path) == without_query_string(current_path)
49
+ end
50
+
51
+ def any_exact_without_query_string_match?
52
+ navbar_items.any? do |navbar_item|
53
+ exact_without_query_string_match?(navbar_item)
54
+ end
55
+ end
56
+
57
+ def exact_matched_route_without_query_string(navbar_item)
58
+ rails_component.route_paths_with_requirements.find do |route_path_with_requirements|
59
+ route_path_with_requirements[:path] == without_query_string(navbar_item[:path])
60
+ end
61
+ end
62
+
63
+ def without_query_string(path)
64
+ path.rpartition('?').compact_blank.first
65
+ end
66
+
67
+ def exact_without_resource_match?
68
+ route = exact_matched_route_without_resource(current_navbar_item)
69
+ return false if route.blank?
70
+
71
+ route[:controller] == controller_path
72
+ end
73
+
74
+ def exact_matched_route_without_resource(navbar_item)
75
+ rails_component.route_paths_with_requirements.find do |route_path_with_requirements|
76
+ navbar_item.dig(:spec, :controller) == route_path_with_requirements[:controller]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Base.class_eval do
4
+ class << self
5
+ attr_reader :authorized_active_element_text_search_fields
6
+
7
+ def authorize_active_element_text_search_for(field, exposes:)
8
+ @authorized_active_element_text_search_fields ||= []
9
+ @authorized_active_element_text_search_fields << [field, exposes]
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Wraps strings in terminal escape codes to provide colourisation in (e.g.) logging output.
5
+ class ColorizedString
6
+ COLOR_CODES = {
7
+ cyan: '36',
8
+ red: '31',
9
+ green: '32',
10
+ blue: '34',
11
+ purple: '35',
12
+ yellow: '33',
13
+ light_gray: '37',
14
+ light_blue: '1;34',
15
+ white: '1;37'
16
+ }.freeze
17
+
18
+ def initialize(string, color:)
19
+ @string = string
20
+ @color = color
21
+ end
22
+
23
+ def value
24
+ return string unless Rails.env.development? || Rails.env.test?
25
+
26
+ "\e[#{COLOR_CODES.fetch(color)}m#{string}\e[0m"
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :string, :color
32
+ end
33
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Exposed by `component` view helper, used as general entrypoint for component creation.
5
+ class Component
6
+ def initialize(controller)
7
+ @controller = controller
8
+ end
9
+
10
+ def page_title(title, **kwargs)
11
+ controller.content_tag(:h2, title, **kwargs)
12
+ end
13
+
14
+ def page_subtitle(subtitle, **kwargs)
15
+ controller.content_tag(:h3, subtitle, **kwargs)
16
+ end
17
+
18
+ def page_section_title(section_title, **kwargs)
19
+ kwargs[:class] ||= 'mt-3'
20
+ controller.content_tag(:h4, section_title, **kwargs)
21
+ end
22
+
23
+ def page_description(content)
24
+ render Components::PageDescription.new(controller, content: content)
25
+ end
26
+
27
+ def show_button(record = nil, flag_or_options = true, **kwargs) # rubocop:disable Style/OptionalBooleanParameter
28
+ render Components::Button.new(controller, record, flag_or_options, type: :show, **kwargs)
29
+ end
30
+
31
+ def new_button(record = nil, flag_or_options = true, **kwargs) # rubocop:disable Style/OptionalBooleanParameter
32
+ render Components::Button.new(controller, record, flag_or_options, type: :new, **kwargs)
33
+ end
34
+
35
+ def edit_button(record = nil, flag_or_options = true, **kwargs) # rubocop:disable Style/OptionalBooleanParameter
36
+ render Components::Button.new(controller, record, flag_or_options, type: :edit, **kwargs)
37
+ end
38
+
39
+ def destroy_button(record = nil, flag_or_options = true, **kwargs) # rubocop:disable Style/OptionalBooleanParameter
40
+ confirm = kwargs.delete(:confirm) { true }
41
+
42
+ render Components::Button.new(
43
+ controller, record, flag_or_options, type: :destroy, confirm: confirm, **kwargs
44
+ )
45
+ end
46
+
47
+ def button(title = nil, url = nil, type: 'primary', float: nil, **kwargs, &block)
48
+ render Components::Button.new(
49
+ controller, nil, { path: url, title: title }, type: type, float: float, **kwargs, &block
50
+ )
51
+ end
52
+
53
+ def json(key, object)
54
+ render Components::Json.new(controller, object: object, key: key)
55
+ end
56
+
57
+ def tabs(class: nil, &block)
58
+ class_name = binding.local_variable_get(:class) || 'tabs'
59
+ render Components::Tabs.new(controller, class_name: class_name, &block)
60
+ end
61
+
62
+ def table(**kwargs)
63
+ class_name = kwargs.delete(:class) { default_class(kwargs[:collection], kwargs[:item], kwargs[:model_name]) }
64
+
65
+ return render item_table(controller, class_name: class_name, **kwargs) if kwargs.key?(:item)
66
+
67
+ if kwargs.key?(:collection)
68
+ return render collection_table(controller, class_name: class_name, params: params, **kwargs)
69
+ end
70
+
71
+ raise ArgumentError, 'Must provide one of `item` or `collection`.'
72
+ end
73
+
74
+ def form(fields: nil, submit: nil, item: nil, **kwargs)
75
+ render Components::Form.new(controller, fields: fields, submit: submit, item: item, **kwargs)
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :controller
81
+
82
+ def item_table(controller, **kwargs)
83
+ Components::ItemTable.new(controller, **kwargs)
84
+ end
85
+
86
+ def collection_table(controller, **kwargs)
87
+ Components::CollectionTable.new(controller, **kwargs)
88
+ end
89
+
90
+ def render(component)
91
+ ActiveElement.with_silenced_logging do
92
+ controller.render_to_string component.template, locals: component.locals, layout: nil
93
+ end
94
+ end
95
+
96
+ def default_class(collection, item, model_name)
97
+ class_from_collection(collection, model_name) || class_from_item(item, model_name)
98
+ end
99
+
100
+ def class_from_collection(collection, model_name)
101
+ return model_name.pluralize if model_name.present?
102
+ return nil if collection.nil?
103
+
104
+ Components::Util::I18n.class_name(class_name_from_item(collection.first), plural: true)
105
+ end
106
+
107
+ def class_from_item(item, model_name)
108
+ return model_name.pluralize if model_name.present?
109
+ return nil if item.nil?
110
+
111
+ Components::Util::I18n.class_name(class_name_from_item(item))
112
+ end
113
+
114
+ def params
115
+ controller.params
116
+ end
117
+
118
+ def class_name_from_item(item)
119
+ Components::Util.sti_record_name(item) || Components::Util.record_name(item)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ # A clickable button.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class Button
8
+ # rubocop:disable Metrics/MethodLength
9
+ def initialize(controller, record, flag_or_options, confirm: false, type: :primary, method: nil,
10
+ float: nil, icon: nil, **kwargs, &block)
11
+ @controller = controller
12
+ @record = record.is_a?(ActiveRecord::Relation) ? record.klass.new : record
13
+ @flag_or_options = flag_or_options
14
+ @float = float
15
+ @kwargs = kwargs
16
+ @kwargs_class = kwargs.delete(:class)
17
+ @confirm = confirm
18
+ @type = type
19
+ @method = method
20
+ @icon = icon
21
+ @block_given = block_given?
22
+ @content = block.call if block_given?
23
+ end
24
+ # rubocop:enable Metrics/MethodLength
25
+
26
+ def template
27
+ 'active_element/components/button'
28
+ end
29
+
30
+ def locals # rubocop:disable Metrics/MethodLength
31
+ {
32
+ controller: controller,
33
+ method: link_method,
34
+ path: resource_path,
35
+ confirm: confirm,
36
+ type: type,
37
+ title: title,
38
+ float_class: float_class,
39
+ icon: icon,
40
+ button_class: button_class,
41
+ kwargs_class: kwargs_class,
42
+ kwargs: kwargs,
43
+ content: content,
44
+ block_given: block_given
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :controller, :record, :flag_or_options, :float, :kwargs, :kwargs_class, :type, :method, :icon,
51
+ :block_given, :content, :confirm
52
+
53
+ def link_method
54
+ return method if method.present?
55
+
56
+ { show: :get, destroy: :delete, new: :get, edit: :get }.fetch(type, :get)
57
+ end
58
+
59
+ def button_class # rubocop:disable Metrics/MethodLength
60
+ case type
61
+ when :destroy
62
+ 'btn-danger destroy-button action-button'
63
+ when :show
64
+ 'btn-primary show-button action-button'
65
+ when :edit
66
+ 'btn-primary edit-button action-button'
67
+ when :new
68
+ 'btn-primary new-button action-button'
69
+ else
70
+ "btn-#{type}"
71
+ end
72
+ end
73
+
74
+ def float_class
75
+ { 'end' => 'float-end', 'start' => 'float-start', nil => nil }.fetch(float)
76
+ end
77
+
78
+ def namespace_prefix
79
+ # XXX: We guess the namespace from the current controller's module name. This will work
80
+ # most of the time but will break the current record's controller exists in a different
81
+ # namespace to the current controller, e.g. `BackEndAdmin::UsersController` and
82
+ # `FrontEndAdmin::ThemesController` - if `FrontEndAdmin::ThemesController` renders a
83
+ # collection of `User` objects, the "show" path will be wrong:
84
+ # `front_end_admin_user_path`. Maybe descend through the full controller class tree to
85
+ # find a best match ?
86
+ namespace = controller.class.name.deconstantize.underscore
87
+ return nil if namespace.blank?
88
+
89
+ "#{namespace}_"
90
+ end
91
+
92
+ def record_path
93
+ return nil if record.nil?
94
+
95
+ controller.helpers.public_send(default_record_path, record)
96
+ rescue NoMethodError
97
+ controller.helpers.public_send(sti_record_path, record)
98
+ end
99
+
100
+ def default_record_path
101
+ "#{record_path_prefix}#{namespace_prefix}#{record_name}_path"
102
+ end
103
+
104
+ def sti_record_path
105
+ "#{record_path_prefix}#{namespace_prefix}#{sti_record_name}_path"
106
+ end
107
+
108
+ def record_path_prefix
109
+ case type
110
+ when :edit
111
+ 'edit_'
112
+ when :new
113
+ 'new_'
114
+ end
115
+ end
116
+
117
+ def title
118
+ return flag_or_options[:title] if flag_or_options.is_a?(Hash) && flag_or_options[:title].present?
119
+ return default_action_title if %i[show destroy edit new].include?(type)
120
+
121
+ kwargs.fetch(:title, '')
122
+ end
123
+
124
+ def default_action_title
125
+ return 'View' if type == :show
126
+ return 'Delete' if type == :destroy
127
+ return 'Edit' if type == :edit
128
+ return "Create New #{(sti_record_name || record_name).titleize}" if type == :new
129
+ end
130
+
131
+ def resource_path
132
+ return record_path if flag_or_options == true && record.present?
133
+ return nil if !flag_or_options.is_a?(Hash) && record.blank?
134
+ return resource_path_from_options if resource_path_from_options.present?
135
+
136
+ record_path
137
+ end
138
+
139
+ def resource_path_from_options
140
+ return nil unless flag_or_options.is_a?(Hash)
141
+ return flag_or_options[:path].call(record) if flag_or_options[:path].is_a?(Proc)
142
+
143
+ flag_or_options[:path]
144
+ end
145
+
146
+ def record_name
147
+ Util.record_name(record)
148
+ end
149
+
150
+ def sti_record_name
151
+ Util.sti_record_name(record)
152
+ end
153
+ end
154
+ end
155
+ # rubocop:enable Metrics/ClassLength
156
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ # A table component for rendering a collection of data.
6
+ class CollectionTable
7
+ include LinkHelpers
8
+ include SecretFields
9
+ include Translations
10
+
11
+ DEFAULT_PAGE_SIZE = 50
12
+
13
+ attr_reader :controller, :model_name
14
+
15
+ # rubocop:disable Metrics/MethodLength
16
+ def initialize(controller, class_name:, collection:, fields:, params:, model_name: nil, style: nil,
17
+ show: false, new: false, edit: false, destroy: false, paginate: true, group: nil,
18
+ group_title: false, row_class: nil, **_kwargs)
19
+ @controller = controller
20
+ @class_name = class_name
21
+ @model_name = model_name
22
+ @collection = collection || []
23
+ @fields = fields
24
+ @style = style
25
+ @params = params
26
+ @show = show
27
+ @new = new
28
+ @edit = edit
29
+ @destroy = destroy
30
+ @paginate = paginate
31
+ @group = group
32
+ @group_title = group_title
33
+ @row_class = row_class
34
+ verify_paginate_and_group
35
+ end
36
+ # rubocop:enable Metrics/MethodLength
37
+
38
+ def template
39
+ 'active_element/components/table/collection'
40
+ end
41
+
42
+ def locals # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
43
+ {
44
+ component: self,
45
+ class_name: class_name,
46
+ collection: group ? collection : paginated_collection,
47
+ fields: Util::FieldMapping.new(self, fields, class_name).mapped_fields,
48
+ style: style,
49
+ new: new,
50
+ show: show,
51
+ edit: edit,
52
+ destroy: destroy,
53
+ group: group,
54
+ group_title: group_title,
55
+ display_pagination: display_pagination?,
56
+ page_sizes: [5, 10, 25, 50, 75, 100, 200],
57
+ page_size: page_size,
58
+ i18n: i18n,
59
+ row_class_mapper: row_class_mapper
60
+ }
61
+ end
62
+
63
+ def model
64
+ return collection.klass if collection.is_a?(ActiveRecord::Relation)
65
+
66
+ collection&.first.class.is_a?(ActiveModel::Naming) ? collection.first.class : nil
67
+ end
68
+
69
+ def grouped_collection
70
+ collection.group_by do |item|
71
+ item.class.is_a?(ActiveModel::Naming) ? item.public_send(group) : item.fetch(group)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :class_name, :collection, :fields, :style, :row_class,
78
+ :new, :show, :edit, :destroy,
79
+ :paginate, :params, :group, :group_title
80
+
81
+ def paginated_collection
82
+ return collection unless paginate && collection.respond_to?(:page)
83
+ return collection.page(page_number).per(page_size) if supports_pagination_but_not_yet_paginated?
84
+
85
+ @paginated_collection ||= collection.page(page_number).per(page_size)
86
+ end
87
+
88
+ def supports_pagination_but_not_yet_paginated?
89
+ collection.respond_to?(:page) && !collection.respond_to?(:current_per_page)
90
+ end
91
+
92
+ def page_number
93
+ params[:page].presence || 1
94
+ end
95
+
96
+ def page_size
97
+ params[:page_size].presence || DEFAULT_PAGE_SIZE
98
+ end
99
+
100
+ def display_pagination?
101
+ return false if group.present?
102
+ return false unless paginate && paginated_collection.respond_to?(:total_count)
103
+
104
+ paginated_collection.total_count > (params[:page_size].presence&.to_i || DEFAULT_PAGE_SIZE)
105
+ end
106
+
107
+ def verify_paginate_and_group
108
+ return unless paginate == false && group.present?
109
+
110
+ raise ArgumentError, 'Cannot specify both `paginate: false` and a `group` argument.'
111
+ end
112
+
113
+ def row_class_mapper
114
+ row_class.is_a?(Proc) ? row_class : proc { row_class }
115
+ end
116
+ end
117
+ end
118
+ end