active_element 0.0.10 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
Files changed (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -2
  3. data/.strong_versions.yml +1 -0
  4. data/Gemfile +4 -0
  5. data/Gemfile.lock +108 -75
  6. data/Makefile +10 -0
  7. data/active_element.gemspec +1 -1
  8. data/app/assets/javascripts/active_element/application.js +1 -0
  9. data/app/assets/javascripts/active_element/form.js +16 -32
  10. data/app/assets/javascripts/active_element/json_field.js +391 -135
  11. data/app/assets/javascripts/active_element/setup.js +13 -8
  12. data/app/assets/javascripts/active_element/text_search_field.js +27 -28
  13. data/app/assets/javascripts/active_element/theme.js +1 -1
  14. data/app/assets/javascripts/active_element/timezones.js +6 -0
  15. data/app/assets/stylesheets/active_element/_dark.scss +86 -0
  16. data/app/assets/stylesheets/active_element/_variables.scss +2 -1
  17. data/app/assets/stylesheets/active_element/application.scss +166 -33
  18. data/app/controllers/active_element/application_controller.rb +5 -0
  19. data/app/controllers/concerns/active_element/default_controller_actions.rb +38 -0
  20. data/app/views/active_element/_user.html.erb +20 -0
  21. data/app/views/active_element/components/fields/_json.html.erb +24 -0
  22. data/app/views/active_element/components/form/_check_box.html.erb +1 -0
  23. data/app/views/active_element/components/form/_check_boxes.html.erb +1 -1
  24. data/app/views/active_element/components/form/_datetime_range_field.html.erb +14 -0
  25. data/app/views/active_element/components/form/_field.html.erb +10 -7
  26. data/app/views/active_element/components/form/_generic_field.html.erb +1 -0
  27. data/app/views/active_element/components/form/_json.html.erb +10 -2
  28. data/app/views/active_element/components/form/_label.html.erb +12 -1
  29. data/app/views/active_element/components/form/_select.html.erb +4 -1
  30. data/app/views/active_element/components/form/_summary.html.erb +11 -1
  31. data/app/views/active_element/components/form/_templates.html.erb +37 -22
  32. data/app/views/active_element/components/form/_text_area.html.erb +2 -1
  33. data/app/views/active_element/components/form/_text_search.html.erb +7 -3
  34. data/app/views/active_element/components/form.html.erb +20 -17
  35. data/app/views/active_element/components/json.html.erb +1 -0
  36. data/app/views/active_element/components/navbar.html.erb +26 -0
  37. data/app/views/active_element/components/table/_collection_row.html.erb +2 -1
  38. data/app/views/active_element/components/table/_field.html.erb +8 -0
  39. data/app/views/active_element/components/table/collection.html.erb +1 -1
  40. data/app/views/active_element/components/table/item.html.erb +5 -4
  41. data/app/views/active_element/default_views/edit.html.erb +5 -0
  42. data/app/views/active_element/default_views/index.html.erb +15 -0
  43. data/app/views/active_element/default_views/new.html.erb +4 -0
  44. data/app/views/active_element/default_views/show.html.erb +7 -0
  45. data/app/views/active_element/navbar/_menu.html.erb +1 -30
  46. data/app/views/active_element/theme/_select.html.erb +1 -1
  47. data/app/views/layouts/active_element.html.erb +16 -1
  48. data/config/brakeman.ignore +48 -0
  49. data/example_app/.gitattributes +7 -0
  50. data/example_app/.gitignore +35 -0
  51. data/example_app/.ruby-version +1 -0
  52. data/example_app/Gemfile +34 -0
  53. data/example_app/Gemfile.lock +296 -0
  54. data/example_app/README.md +24 -0
  55. data/example_app/Rakefile +6 -0
  56. data/example_app/app/assets/config/manifest.js +4 -0
  57. data/example_app/app/assets/images/.keep +0 -0
  58. data/example_app/app/assets/stylesheets/application.css +15 -0
  59. data/example_app/app/channels/application_cable/channel.rb +4 -0
  60. data/example_app/app/channels/application_cable/connection.rb +4 -0
  61. data/example_app/app/controllers/application_controller.rb +12 -0
  62. data/example_app/app/controllers/concerns/.keep +0 -0
  63. data/example_app/app/controllers/pets_controller.rb +6 -0
  64. data/example_app/app/controllers/users_controller.rb +6 -0
  65. data/example_app/app/helpers/application_helper.rb +2 -0
  66. data/example_app/app/javascript/application.js +3 -0
  67. data/example_app/app/javascript/controllers/application.js +9 -0
  68. data/example_app/app/javascript/controllers/hello_controller.js +7 -0
  69. data/example_app/app/javascript/controllers/index.js +11 -0
  70. data/example_app/app/jobs/application_job.rb +7 -0
  71. data/example_app/app/mailers/application_mailer.rb +4 -0
  72. data/example_app/app/models/application_record.rb +3 -0
  73. data/example_app/app/models/concerns/.keep +0 -0
  74. data/example_app/app/models/pet.rb +3 -0
  75. data/example_app/app/models/user.rb +8 -0
  76. data/example_app/app/views/layouts/application.html.erb +16 -0
  77. data/example_app/app/views/layouts/mailer.html.erb +13 -0
  78. data/example_app/app/views/layouts/mailer.text.erb +1 -0
  79. data/example_app/app/views/pets/index.html.erb +3 -0
  80. data/example_app/app/views/users/show.html.erb +3 -0
  81. data/example_app/bin/bundle +109 -0
  82. data/example_app/bin/importmap +4 -0
  83. data/example_app/bin/rails +4 -0
  84. data/example_app/bin/rake +4 -0
  85. data/example_app/bin/setup +33 -0
  86. data/example_app/config/application.rb +22 -0
  87. data/example_app/config/boot.rb +4 -0
  88. data/example_app/config/cable.yml +10 -0
  89. data/example_app/config/credentials.yml.enc +1 -0
  90. data/example_app/config/database.yml +25 -0
  91. data/example_app/config/environment.rb +5 -0
  92. data/example_app/config/environments/development.rb +70 -0
  93. data/example_app/config/environments/production.rb +93 -0
  94. data/example_app/config/environments/test.rb +60 -0
  95. data/example_app/config/importmap.rb +7 -0
  96. data/example_app/config/initializers/assets.rb +12 -0
  97. data/example_app/config/initializers/content_security_policy.rb +25 -0
  98. data/example_app/config/initializers/devise.rb +16 -0
  99. data/example_app/config/initializers/filter_parameter_logging.rb +8 -0
  100. data/example_app/config/initializers/inflections.rb +16 -0
  101. data/example_app/config/initializers/permissions_policy.rb +11 -0
  102. data/example_app/config/locales/devise.en.yml +65 -0
  103. data/example_app/config/locales/en.yml +33 -0
  104. data/example_app/config/puma.rb +43 -0
  105. data/example_app/config/routes.rb +8 -0
  106. data/example_app/config/storage.yml +34 -0
  107. data/example_app/config.ru +6 -0
  108. data/example_app/db/migrate/20230616210539_create_pet.rb +12 -0
  109. data/example_app/db/migrate/20230616211328_devise_create_users.rb +46 -0
  110. data/example_app/db/schema.rb +37 -0
  111. data/example_app/db/seeds.rb +33 -0
  112. data/example_app/lib/assets/.keep +0 -0
  113. data/example_app/lib/tasks/.keep +0 -0
  114. data/example_app/log/.keep +0 -0
  115. data/example_app/public/404.html +67 -0
  116. data/example_app/public/422.html +67 -0
  117. data/example_app/public/500.html +66 -0
  118. data/example_app/public/apple-touch-icon-precomposed.png +0 -0
  119. data/example_app/public/apple-touch-icon.png +0 -0
  120. data/example_app/public/favicon.ico +0 -0
  121. data/example_app/public/robots.txt +1 -0
  122. data/example_app/storage/.keep +0 -0
  123. data/example_app/test/application_system_test_case.rb +5 -0
  124. data/example_app/test/channels/application_cable/connection_test.rb +11 -0
  125. data/example_app/test/controllers/.keep +0 -0
  126. data/example_app/test/fixtures/files/.keep +0 -0
  127. data/example_app/test/fixtures/users.yml +11 -0
  128. data/example_app/test/helpers/.keep +0 -0
  129. data/example_app/test/integration/.keep +0 -0
  130. data/example_app/test/mailers/.keep +0 -0
  131. data/example_app/test/models/.keep +0 -0
  132. data/example_app/test/models/user_test.rb +7 -0
  133. data/example_app/test/system/.keep +0 -0
  134. data/example_app/test/test_helper.rb +13 -0
  135. data/example_app/tmp/.keep +0 -0
  136. data/example_app/tmp/pids/.keep +0 -0
  137. data/example_app/tmp/storage/.keep +0 -0
  138. data/example_app/vendor/.keep +0 -0
  139. data/example_app/vendor/javascript/.keep +0 -0
  140. data/lib/active_element/component.rb +9 -2
  141. data/lib/active_element/components/collection_table.rb +9 -2
  142. data/lib/active_element/components/email_fields.rb +14 -0
  143. data/lib/active_element/components/form.rb +48 -17
  144. data/lib/active_element/components/navbar.rb +64 -0
  145. data/lib/active_element/components/phone_fields.rb +14 -0
  146. data/lib/active_element/components/text_search/authorization.rb +9 -6
  147. data/lib/active_element/components/text_search/component.rb +4 -2
  148. data/lib/active_element/components/text_search.rb +4 -0
  149. data/lib/active_element/components/util/association_mapping.rb +74 -19
  150. data/lib/active_element/components/util/display_value_mapping.rb +13 -4
  151. data/lib/active_element/components/util/form_field_mapping.rb +127 -10
  152. data/lib/active_element/components/util/form_value_mapping.rb +3 -3
  153. data/lib/active_element/components/util/i18n.rb +1 -1
  154. data/lib/active_element/components/util/record_mapping.rb +43 -11
  155. data/lib/active_element/components/util/record_path.rb +21 -4
  156. data/lib/active_element/components/util.rb +12 -5
  157. data/lib/active_element/components.rb +3 -0
  158. data/lib/active_element/controller_action.rb +8 -2
  159. data/lib/active_element/controller_interface.rb +47 -5
  160. data/lib/active_element/default_controller.rb +93 -0
  161. data/lib/active_element/default_record_params.rb +62 -0
  162. data/lib/active_element/default_text_search.rb +110 -0
  163. data/lib/active_element/json_field_schema.rb +59 -0
  164. data/lib/active_element/pre_render_processors/json.rb +98 -0
  165. data/lib/active_element/pre_render_processors.rb +11 -0
  166. data/lib/active_element/route.rb +12 -0
  167. data/lib/active_element/routes.rb +2 -1
  168. data/lib/active_element/version.rb +1 -1
  169. data/lib/active_element.rb +14 -32
  170. data/lib/tasks/active_element.rake +12 -1
  171. data/rspec-documentation/_head.html.erb +34 -0
  172. data/rspec-documentation/pages/000-Introduction.md +18 -0
  173. data/rspec-documentation/pages/005-Setup.md +75 -0
  174. data/rspec-documentation/pages/010-Components/Form Fields/Check Boxes.md +1 -0
  175. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Controller Params.md +97 -0
  176. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Schema.md +283 -0
  177. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Types.md +36 -0
  178. data/rspec-documentation/pages/010-Components/Form Fields/JSON.md +70 -0
  179. data/rspec-documentation/pages/010-Components/Form Fields/Text Search.md +133 -0
  180. data/rspec-documentation/pages/010-Components/Form Fields.md +46 -0
  181. data/rspec-documentation/pages/010-Components/Forms.md +44 -0
  182. data/rspec-documentation/pages/010-Components/JSON Data.md +23 -0
  183. data/rspec-documentation/pages/010-Components/Navbar.md +56 -0
  184. data/rspec-documentation/pages/010-Components/Page Section Title.md +13 -0
  185. data/rspec-documentation/pages/010-Components/Page Subtitle.md +11 -0
  186. data/rspec-documentation/pages/010-Components/Page Title.md +11 -0
  187. data/rspec-documentation/pages/010-Components/Tables/Collection Table.md +29 -0
  188. data/rspec-documentation/pages/010-Components/Tables/Item Table.md +18 -0
  189. data/rspec-documentation/pages/010-Components/Tables/Options.md +19 -0
  190. data/rspec-documentation/pages/010-Components/Tables.md +29 -0
  191. data/rspec-documentation/pages/010-Components.md +15 -0
  192. data/rspec-documentation/pages/020-Access Control/010-Authentication.md +20 -0
  193. data/rspec-documentation/pages/020-Access Control/020-Authorization/Environments.md +9 -0
  194. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions/Custom Routes.md +41 -0
  195. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions.md +58 -0
  196. data/rspec-documentation/pages/020-Access Control/020-Authorization/Setup.md +27 -0
  197. data/rspec-documentation/pages/020-Access Control/020-Authorization.md +11 -0
  198. data/rspec-documentation/pages/020-Access Control.md +31 -0
  199. data/rspec-documentation/pages/040-Decorators/Inline Decorators.md +24 -0
  200. data/rspec-documentation/pages/040-Decorators/View Decorators.md +55 -0
  201. data/rspec-documentation/pages/040-Decorators.md +12 -0
  202. data/rspec-documentation/pages/300-Alternatives.md +21 -0
  203. data/rspec-documentation/pages/900-License.md +11 -0
  204. data/rspec-documentation/spec_helper.rb +53 -16
  205. data/rspec-documentation/support.rb +84 -0
  206. metadata +155 -14
  207. data/rspec-documentation/pages/Components/Forms.md +0 -1
  208. data/rspec-documentation/pages/Components/Tables.md +0 -47
  209. data/rspec-documentation/pages/Components.md +0 -1
  210. data/rspec-documentation/pages/Decorators/Inline Decorators.md +0 -1
  211. data/rspec-documentation/pages/Decorators/View Decorators.md +0 -1
  212. data/rspec-documentation/pages/Index.md +0 -3
  213. data/rspec-documentation/pages/Util/I18n.md +0 -1
  214. /data/rspec-documentation/pages/{Components → 010-Components}/Tabs.md +0 -0
@@ -13,6 +13,10 @@ module ActiveElement
13
13
 
14
14
  def path
15
15
  record_path || sti_record_path
16
+ rescue NoMethodError
17
+ raise Error,
18
+ "Unable to map #{record.inspect} to a Rails route. Tried:\n" \
19
+ "#{[default_record_path, sti_record_path].compact.join("\n")}"
16
20
  end
17
21
 
18
22
  private
@@ -36,9 +40,20 @@ module ActiveElement
36
40
  def record_path
37
41
  return nil if record.nil?
38
42
 
39
- controller.helpers.public_send(default_record_path, record)
43
+ controller.helpers.public_send(default_record_path, *path_arguments)
40
44
  rescue NoMethodError
41
- controller.helpers.public_send(sti_record_path, record)
45
+ raise NoMethodError if sti_record_name.nil?
46
+
47
+ controller.helpers.public_send(sti_record_path, *path_arguments)
48
+ end
49
+
50
+ def path_arguments
51
+ case type
52
+ when :edit, :update, :show, :destroy
53
+ [record]
54
+ when :new, :index
55
+ []
56
+ end
42
57
  end
43
58
 
44
59
  def default_record_path
@@ -46,6 +61,8 @@ module ActiveElement
46
61
  end
47
62
 
48
63
  def sti_record_path
64
+ return nil if sti_record_name.nil?
65
+
49
66
  "#{record_path_prefix}#{namespace_prefix}#{sti_record_name}_path"
50
67
  end
51
68
 
@@ -63,9 +80,9 @@ module ActiveElement
63
80
 
64
81
  def record_path_prefix
65
82
  case type
66
- when :edit, :update
83
+ when :edit
67
84
  'edit_'
68
- when :new, :create
85
+ when :new
69
86
  'new_'
70
87
  end
71
88
  end
@@ -29,18 +29,25 @@ module ActiveElement
29
29
  end
30
30
 
31
31
  def self.default_record_name(record)
32
- record.class.name.demodulize.underscore
32
+ (record.is_a?(Class) ? record.name : record.class.name).demodulize.underscore
33
+ end
34
+
35
+ def self.relation_controller(model, controller, relation)
36
+ namespace = controller.controller_path.rpartition('/').first.presence
37
+ base = "#{model.reflect_on_association(relation).klass.name.pluralize}Controller"
38
+ return base.safe_constantize if namespace.nil?
39
+
40
+ "#{namespace.classify}::#{base}".safe_constantize || base.safe_constantize
33
41
  end
34
42
 
35
43
  def self.json_pretty_print(json)
36
- theme = Rouge::Themes::Base16.mode(:light)
37
- formatter = Rouge::Formatters::HTMLLinewise.new(Rouge::Formatters::HTMLInline.new(theme))
44
+ formatter = Rouge::Formatters::HTML.new
38
45
  lexer = Rouge::Lexers::JSON.new
39
46
  content = JSON.pretty_generate(json.is_a?(String) ? JSON.parse(json) : json)
40
- formatted = formatter.format(lexer.lex(content)).gsub(' ', '  ')
47
+ formatted = formatter.format(lexer.lex(content)).gsub(' ', '&nbsp;&nbsp;').gsub("\n", '<br/>')
41
48
  # rubocop:disable Rails/OutputSafety
42
49
  # TODO: Move to a template.
43
- "<div style='font-family: monospace;'>#{formatted}</div>".html_safe
50
+ "<div class='json-highlight' style='font-family: monospace;'>#{formatted}</div>".html_safe
44
51
  # rubocop:enable Rails/OutputSafety
45
52
  end
46
53
  end
@@ -2,8 +2,11 @@
2
2
 
3
3
  require_relative 'components/translations'
4
4
  require_relative 'components/secret_fields'
5
+ require_relative 'components/email_fields'
6
+ require_relative 'components/phone_fields'
5
7
  require_relative 'components/util'
6
8
  require_relative 'components/link_helpers'
9
+ require_relative 'components/navbar'
7
10
  require_relative 'components/page_description'
8
11
  require_relative 'components/form'
9
12
  require_relative 'components/collection_table'
@@ -11,7 +11,7 @@ module ActiveElement
11
11
 
12
12
  def process_action
13
13
  Rails.logger.info("#{ActiveElement.log_tag} #{colorized_permissions_message}")
14
- return if verified_permissions?
14
+ process_pre_render and return if verified_permissions?
15
15
 
16
16
  warn "#{ActiveElement.log_tag} #{colorized_permissions_message}" if Rails.env.test?
17
17
  return controller.redirect_to redirect_path if redirect_to_default_landing_page?
@@ -30,6 +30,12 @@ module ActiveElement
30
30
  (@verified_permissions = permissions_check.permitted?)
31
31
  end
32
32
 
33
+ def process_pre_render
34
+ PreRenderProcessors::Json.new(controller: controller).process
35
+
36
+ true
37
+ end
38
+
33
39
  def redirect_path
34
40
  routes.alternative_routes.first.path
35
41
  end
@@ -72,7 +78,7 @@ module ActiveElement
72
78
  color = if permissions_check.permitted?
73
79
  :green
74
80
  else
75
- (rails_component.environment == 'test' ? :yellow : :red)
81
+ rails_component.environment == 'test' ? :yellow : :red
76
82
  end
77
83
  paintbrush { public_send(color, permissions_check.message) }
78
84
  end
@@ -5,7 +5,7 @@ module ActiveElement
5
5
  # Encapsulates core functionality such as `authenticate_with`, `permit_action`, and `component`
6
6
  # without polluting application controller namespace.
7
7
  class ControllerInterface
8
- attr_reader :missing_template_store, :current_user
8
+ attr_reader :missing_template_store, :current_user, :assigned_editable_fields
9
9
 
10
10
  @state = {}
11
11
 
@@ -25,6 +25,22 @@ module ActiveElement
25
25
  @authorize
26
26
  end
27
27
 
28
+ def listable_fields(*args)
29
+ state[:listable_fields] = args.map(&:to_sym)
30
+ end
31
+
32
+ def viewable_fields(*args)
33
+ state[:viewable_fields] = args.map(&:to_sym)
34
+ end
35
+
36
+ def editable_fields(*args)
37
+ state[:editable_fields] = args.map(&:to_sym)
38
+ end
39
+
40
+ def searchable_fields(*args)
41
+ state[:searchable_fields] = args.map(&:to_sym)
42
+ end
43
+
28
44
  def application_name
29
45
  RailsComponent.new(::Rails).application_name
30
46
  end
@@ -38,6 +54,32 @@ module ActiveElement
38
54
  state[:authorizor] = block
39
55
  end
40
56
 
57
+ def sign_out_with(method: :get, &block)
58
+ state[:sign_out_method] = method
59
+ state[:sign_out_path] = block
60
+ end
61
+
62
+ def sign_out_path
63
+ state[:sign_out_path]&.call
64
+ end
65
+
66
+ def sign_out_method
67
+ state[:sign_out_method]
68
+ end
69
+
70
+ def sign_in_with(method: :get, &block)
71
+ state[:sign_in_method] = method
72
+ state[:sign_in_path] = block
73
+ end
74
+
75
+ def sign_in_path
76
+ state[:sign_in_path]&.call
77
+ end
78
+
79
+ def sign_in_method
80
+ state[:sign_in_method]
81
+ end
82
+
41
83
  def authenticate
42
84
  authenticator&.call
43
85
  @current_user = state[:authorizor]&.call
@@ -66,6 +108,10 @@ module ActiveElement
66
108
  raise ArgumentError, 'Attempted to use ActiveElement component from a controller class method.'
67
109
  end
68
110
 
111
+ def state
112
+ self.class.state[controller_class]
113
+ end
114
+
69
115
  private
70
116
 
71
117
  attr_reader :controller_class, :controller_instance
@@ -73,9 +119,5 @@ module ActiveElement
73
119
  def initialize_state
74
120
  self.class.state[controller_class] ||= { permissions: [], authenticator: nil }
75
121
  end
76
-
77
- def state
78
- self.class.state[controller_class]
79
- end
80
122
  end
81
123
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Encapsulation of all logic performed for default controller actions when no action is defined
5
+ # by the current controller.
6
+ class DefaultController
7
+ def initialize(controller:)
8
+ @controller = controller
9
+ end
10
+
11
+ def index
12
+ controller.render 'active_element/default_views/index',
13
+ locals: {
14
+ collection: collection,
15
+ search_filters: default_text_search.search_filters
16
+ }
17
+ end
18
+
19
+ def show
20
+ controller.render 'active_element/default_views/show', locals: { record: record }
21
+ end
22
+
23
+ def new
24
+ controller.render 'active_element/default_views/new', locals: { record: model.new, namespace: namespace }
25
+ end
26
+
27
+ def create # rubocop:disable Metrics/AbcSize
28
+ new_record = model.new(default_record_params.params)
29
+ # Ensure associations are applied:
30
+ if new_record.save && new_record.reload.update(default_record_params.params)
31
+ controller.flash.notice = "#{new_record.model_name.to_s.titleize} created successfully."
32
+ controller.redirect_to record_path(new_record, :show).path
33
+ else
34
+ controller.flash.now.alert = "Failed to create #{model.name.to_s.titleize}."
35
+ controller.render 'active_element/default_views/new', locals: { record: new_record, namespace: namespace }
36
+ end
37
+ end
38
+
39
+ def edit
40
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
41
+ end
42
+
43
+ def update # rubocop:disable Metrics/AbcSize
44
+ if record.update(default_record_params.params)
45
+ controller.flash.notice = "#{record.model_name.to_s.titleize} updated successfully."
46
+ controller.redirect_to record_path(record, :show).path
47
+ else
48
+ controller.flash.now.alert = "Failed to update #{model.name.to_s.titleize}."
49
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
50
+ end
51
+ end
52
+
53
+ def destroy
54
+ record.destroy
55
+ controller.flash.notice = "Deleted #{record.model_name.to_s.titleize}."
56
+ controller.redirect_to record_path(model, :index).path
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :controller
62
+
63
+ def default_record_params
64
+ @default_record_params ||= ActiveElement::DefaultRecordParams.new(controller: controller, model: model)
65
+ end
66
+
67
+ def default_text_search
68
+ @default_text_search ||= ActiveElement::DefaultTextSearch.new(controller: controller, model: model)
69
+ end
70
+
71
+ def record_path(record, type = nil)
72
+ ActiveElement::Components::Util::RecordPath.new(record: record, controller: controller, type: type)
73
+ end
74
+
75
+ def namespace
76
+ controller.controller_path.rpartition('/').first.presence&.to_sym
77
+ end
78
+
79
+ def model
80
+ controller.controller_name.classify.constantize
81
+ end
82
+
83
+ def record
84
+ @record ||= model.find(controller.params[:id])
85
+ end
86
+
87
+ def collection
88
+ return model.all unless default_text_search.text_search?
89
+
90
+ model.left_outer_joins(default_text_search.search_relations).where(*default_text_search.text_search)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Provides params for ActiveRecord models when using the default boilerplate controller
5
+ # actions. Navigates input parameters and maps them to appropriate relations as needed.
6
+ class DefaultRecordParams
7
+ def initialize(controller:, model:)
8
+ @controller = controller
9
+ @model = model
10
+ end
11
+
12
+ def params
13
+ with_transformed_relations(
14
+ controller.params.require(controller.controller_name.singularize)
15
+ .permit(controller.active_element.state.fetch(:editable_fields, []))
16
+ )
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :controller, :model
22
+
23
+ def with_transformed_relations(params)
24
+ params.to_h.to_h do |key, value|
25
+ next [key, value] unless relation?(key)
26
+
27
+ relation_param(key, value)
28
+ end
29
+ end
30
+
31
+ def relation_param(key, value)
32
+ case relation(key).macro
33
+ when :belongs_to
34
+ belongs_to_param(key, value)
35
+ when :has_one
36
+ has_one_param(key, value)
37
+ when :has_many
38
+ has_many_param(key, value)
39
+ end
40
+ end
41
+
42
+ def belongs_to_param(key, value)
43
+ [relation(key).foreign_key, value]
44
+ end
45
+
46
+ def has_one_param(key, value) # rubocop:disable Naming/PredicateName
47
+ [relation(key).name, relation(key).klass.find_by(relation(key).klass.primary_key => value)]
48
+ end
49
+
50
+ def has_many_param(key, _value) # rubocop:disable Naming/PredicateName
51
+ [relation(key).name, relation(key).klass.where(relation(key).klass.primary_key => relation(key).value)]
52
+ end
53
+
54
+ def relation?(attribute)
55
+ relation(attribute.to_sym).present?
56
+ end
57
+
58
+ def relation(attribute)
59
+ model.reflect_on_association(attribute.to_sym)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Full text search querying for DefaultController, provides full text search filters for all
5
+ # controllers with configured searchable fields. Includes support for querying across relations.
6
+ class DefaultTextSearch
7
+ def initialize(controller:, model:)
8
+ @controller = controller
9
+ @model = model
10
+ end
11
+
12
+ def search_filters
13
+ @search_filters ||= controller.params.permit(*searchable_fields).transform_values do |value|
14
+ value.try(:compact_blank) || value
15
+ end.compact_blank
16
+ end
17
+
18
+ def text_search?
19
+ search_filters.present?
20
+ end
21
+
22
+ def text_search
23
+ conditions = search_filters.to_h.map do |key, value|
24
+ next relation_matches(key, value) if relation?(key)
25
+ next datetime_between(key, value) if datetime?(key)
26
+
27
+ model.arel_table[key].matches("#{value}%")
28
+ end
29
+ conditions[1..].reduce(conditions.first) do |accumulated, condition|
30
+ accumulated.and(condition)
31
+ end
32
+ end
33
+
34
+ def search_relations
35
+ search_filters.to_h.keys.map { |key| relation?(key) ? key.to_sym : nil }.compact
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :controller, :model
41
+
42
+ def searchable_fields
43
+ base_fields = controller.active_element.state.fetch(:searchable_fields, [])
44
+ base_fields.map do |field|
45
+ next field unless field.to_s.end_with?('_at')
46
+
47
+ { field => %i[from to] }
48
+ end
49
+ end
50
+
51
+ def noop
52
+ Arel::Nodes::True.new.eq(Arel::Nodes::True.new)
53
+ end
54
+
55
+ def datetime?(key)
56
+ model.columns.find { |column| column.name.to_s == key.to_s }&.type == :datetime
57
+ end
58
+
59
+ def datetime_between(key, value)
60
+ return noop if value[:from].blank? && value[:to].blank?
61
+
62
+ model.arel_table[key].between(range_begin(value)...range_end(value))
63
+ end
64
+
65
+ def range_begin(value)
66
+ value[:from].present? ? Time.zone.parse(value[:from]) + timezone_offset : -Float::INFINITY
67
+ end
68
+
69
+ def range_end
70
+ value[:to].present? ? Time.zone.parse(value[:to]) + timezone_offset : Float::INFINITY
71
+ end
72
+
73
+ def timezone_offset
74
+ controller.request.cookies['timezone_offset'].to_i.minutes
75
+ end
76
+
77
+ def relation_matches(key, value)
78
+ fields = searchable_relation_fields(key)
79
+ relation_model = relation(key).klass
80
+ fields.select! do |field|
81
+ relation_model.columns.find { |column| column.name.to_s == field.to_s }&.type == :string
82
+ end
83
+
84
+ return noop if fields.empty?
85
+
86
+ relation_conditions(fields, value, relation_model)
87
+ end
88
+
89
+ def relation_conditions(fields, value, relation_model)
90
+ fields[1..].reduce(relation_model.arel_table[fields.first].matches("#{value}%")) do |condition, field|
91
+ condition.or(relation_model.arel_table[field].matches("#{value}%"))
92
+ end
93
+ end
94
+
95
+ def searchable_relation_fields(key)
96
+ Components::Util.relation_controller(model, controller, key)
97
+ &.active_element
98
+ &.state
99
+ &.fetch(:searchable_fields, []) || []
100
+ end
101
+
102
+ def relation?(attribute)
103
+ relation(attribute.to_sym).present?
104
+ end
105
+
106
+ def relation(attribute)
107
+ model.reflect_on_association(attribute.to_sym)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Generates a schema for a JSON form field based on values stored in the database.
5
+ class JsonFieldSchema
6
+ def initialize(table:, column:)
7
+ @table = table
8
+ @column = column
9
+ end
10
+
11
+ def schema
12
+ data.map { |datum| structure(datum) }
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :table, :column, :initial_structure
18
+
19
+ def data
20
+ @data ||= ActiveRecord::Base.connection
21
+ .execute("select #{column} from #{table}")
22
+ .pluck(column)
23
+ .map { |datum| JSON.parse(datum) }
24
+ end
25
+
26
+ def structure(datum)
27
+ {
28
+ type: schema_type(datum),
29
+ shape: schema_shape(datum)&.compact,
30
+ fields: schema_fields(datum)&.compact
31
+ }
32
+ end
33
+
34
+ def schema_type(val)
35
+ case val
36
+ when Hash
37
+ 'object'
38
+ when Array
39
+ 'array'
40
+ when String
41
+ 'string'
42
+ end
43
+ end
44
+
45
+ def schema_shape(val)
46
+ return nil unless %w[array object].include?(schema_type(val))
47
+ return val.map { |item| structure(item).compact } if schema_type(val) == 'array'
48
+ return val.map { |_key, value| structure(value).compact } if schema_type(val) == 'object'
49
+
50
+ { type: schema_type(val) }
51
+ end
52
+
53
+ def schema_fields(val)
54
+ return nil unless schema_type(val) == 'object'
55
+
56
+ val.map { |key, value| { name: key }.merge(structure(value)).compact }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module PreRenderProcessors
5
+ # Selects fields from `__json_fields` param created by `Components::JsonField` and parses
6
+ # each field's JSON data back into request params to allow for transparent JSON data receipt.
7
+ # All params are permitted and converted to a Hash to allow them to be modified before
8
+ # converting back to ActionController::Params to avoid disrupting the Rails request flow.
9
+ class Json
10
+ def initialize(controller:)
11
+ @controller = controller
12
+ end
13
+
14
+ def process
15
+ return if json_fields.blank?
16
+
17
+ process_json_fields
18
+ delete_meta_params
19
+ rebuild_action_controller_parameters
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :controller
25
+
26
+ def process_json_fields
27
+ json_fields.zip(json_values).each do |field, value|
28
+ *nested_keys, field_key = field.split('.')
29
+ param = nested_keys.reduce(permitted_params) { |params, key| params[key] }
30
+ schema = schema_for(nested_keys + [field_key])
31
+ param[field_key] = coerced_with_default(value, schema)
32
+ end
33
+ end
34
+
35
+ def coerced_with_default(value, schema)
36
+ return coerced_value(JSON.parse(value), schema: schema) unless value == ''
37
+
38
+ { 'array' => [], 'object' => {} }.fetch(schema['type'])
39
+ end
40
+
41
+ def delete_meta_params
42
+ permitted_params.delete('__json_fields')
43
+ permitted_params.delete('__json_field_schemas')
44
+ end
45
+
46
+ def rebuild_action_controller_parameters
47
+ controller.params = ActionController::Parameters.new(permitted_params)
48
+ end
49
+
50
+ def json_fields
51
+ controller.params['__json_fields']
52
+ end
53
+
54
+ def json_values
55
+ json_fields.map do |json_field|
56
+ json_field.split('.').reduce(controller.params) { |params, field| params[field] }
57
+ end
58
+ end
59
+
60
+ def permitted_params
61
+ @permitted_params ||= controller.params.permit!.to_h
62
+ end
63
+
64
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
65
+ def coerced_value(val, schema:)
66
+ return val if val.nil?
67
+
68
+ case schema['type']
69
+ when 'array'
70
+ val.map { |item| coerced_value(item, schema: schema['shape']) }
71
+ when 'object'
72
+ val.to_h { |key, value| [key, coerced_value(value, schema: schema_field(schema, key))] }
73
+ when 'string', 'boolean', 'time'
74
+ val
75
+ when 'float'
76
+ Float(val)
77
+ when 'integer'
78
+ Integer(val)
79
+ when 'decimal'
80
+ BigDecimal(val)
81
+ when 'datetime'
82
+ DateTime.parse(val)
83
+ when 'date'
84
+ Date.parse(val)
85
+ end
86
+ end
87
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
88
+
89
+ def schema_for(path)
90
+ JSON.parse(path.reduce(permitted_params['__json_field_schemas']) { |schema, key| schema[key] })
91
+ end
92
+
93
+ def schema_field(schema, key)
94
+ schema['shape']['fields'].find { |each_field| each_field['name'] == key }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pre_render_processors/json'
4
+
5
+ module ActiveElement
6
+ # Collection of processors called before the controller flow is handed back to the host
7
+ # application, i.e. before actions within the main ActiveElement before action, e.g. used for
8
+ # processing JSON fields.
9
+ module PreRenderProcessors
10
+ end
11
+ end
@@ -36,6 +36,7 @@ module ActiveElement
36
36
  def primary?
37
37
  return false if rails_non_index_action?
38
38
  return false unless resourceless_get_request?
39
+ return false if excluded_ancestor?
39
40
 
40
41
  true
41
42
  end
@@ -93,6 +94,17 @@ module ActiveElement
93
94
  end
94
95
  end
95
96
 
97
+ def excluded_ancestor?
98
+ ancestors = controller.class.ancestors.map(&:name)
99
+ excluded_ancestors.any? { |excluded_ancestor| ancestors.include?(excluded_ancestor) }
100
+ end
101
+
102
+ def excluded_ancestors
103
+ # This will likely end up a config setting, for now we exclude Devise so its controllers
104
+ # don't appear in the Navbar.
105
+ %w[DeviseController]
106
+ end
107
+
96
108
  def permitted_action?
97
109
  permissions_check.permitted?
98
110
  rescue UnprotectedRouteError
@@ -33,7 +33,8 @@ module ActiveElement
33
33
 
34
34
  def available_routes
35
35
  @available_routes ||= descendants_with_permissions.map do |descendant, required_permissions|
36
- descendant.public_methods(false).map do |action|
36
+ action_methods = descendant.public_methods(false)
37
+ ([:index] + action_methods).uniq.map do |action|
37
38
  route(descendant, action, required_permissions)
38
39
  end
39
40
  end.flatten.compact.select(&:rails_route?).sort
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveElement
4
- VERSION = '0.0.10'
4
+ VERSION = '0.0.11'
5
5
  end