active_element 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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 +11 -2
  5. data/Gemfile.lock +230 -3
  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 disabled'>
2
+ <%= link_to raw(t 'views.pagination.truncate'), '#', 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.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 '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