active_element 0.0.9 → 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 +111 -78
  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 +170 -31
  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 +17 -2
  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 -7
  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
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ # A navigation bar providing links to areas within the application.
6
+ class Navbar
7
+ include Translations
8
+
9
+ attr_reader :controller
10
+
11
+ def initialize(controller, items: nil, fixed: true)
12
+ @controller = controller
13
+ @items = items
14
+ @fixed = fixed
15
+ end
16
+
17
+ def template
18
+ 'active_element/components/navbar'
19
+ end
20
+
21
+ def locals
22
+ {
23
+ component: self,
24
+ items: items,
25
+ fixed: fixed
26
+ }
27
+ end
28
+
29
+ def active_path_class(current_navbar_item:)
30
+ if ActiveMenuLink.new(
31
+ rails_component: RailsComponent.new(Rails),
32
+ navbar_items: items,
33
+ current_path: controller.request.path,
34
+ current_navbar_item: current_navbar_item,
35
+ controller_path: controller.controller_path,
36
+ action_name: controller.action_name
37
+ ).active?
38
+ 'active'
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :fixed
45
+
46
+ def items
47
+ return @items unless @items.nil?
48
+
49
+ ActiveElement.eager_load_controllers
50
+
51
+ @items ||= user_routes(controller.active_element.current_user).available.select(&:primary?).map do |route|
52
+ { path: route.path, title: route.title, spec: route.spec }
53
+ end
54
+ end
55
+
56
+ def user_routes(user)
57
+ ActiveElement::Routes.new(
58
+ permissions: user&.permissions,
59
+ rails_component: ActiveElement::RailsComponent.new(Rails)
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ # Provides a convenience method for detecting a field should be classified as a phone number.
6
+ module PhoneFields
7
+ PHONE_FIELDS = %w[phone telephone tel mobile].freeze
8
+
9
+ def phone_field?(field)
10
+ PHONE_FIELDS.any? { |phone_field| field.to_s.downcase.split('_').include?(phone_field) }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -20,12 +20,12 @@ module ActiveElement
20
20
  end
21
21
  end
22
22
 
23
- def initialize(model:, params:, user:, search_columns:, result_columns:)
23
+ def initialize(model:, params:, user:, search_columns:, value_column:)
24
24
  @model = model
25
25
  @params = params
26
26
  @user = user
27
27
  @search_columns = search_columns
28
- @result_columns = result_columns
28
+ @value_column = value_column
29
29
  end
30
30
 
31
31
  def authorized?
@@ -45,14 +45,15 @@ module ActiveElement
45
45
 
46
46
  private
47
47
 
48
- attr_reader :model, :params, :user, :search_columns, :result_columns
48
+ attr_reader :model, :params, :user, :search_columns, :value_column
49
49
 
50
50
  def development_message
51
51
  paintbrush { green "Bypassed text search authorization in development environment: #{yellow message}" }
52
52
  end
53
53
 
54
54
  def missing_permissions
55
- (search_columns + result_columns).reject { |column| user_permitted?(column) }
55
+ (search_columns + [value_column]).reject { |column| user_permitted?(column) }
56
+ .compact_blank
56
57
  .map { |column| permission_for(column.name) }
57
58
  .uniq
58
59
  .sort
@@ -87,6 +88,8 @@ module ActiveElement
87
88
  end
88
89
 
89
90
  def user_permitted?(column)
91
+ return false if column.blank?
92
+
90
93
  user&.permissions&.include?(permission_for(column.name))
91
94
  end
92
95
 
@@ -99,10 +102,10 @@ module ActiveElement
99
102
  end
100
103
 
101
104
  def authorized_model?
102
- TextSearch.authorized_text_searches.any? do |authorized_model, search_fields, result_fields|
105
+ TextSearch.authorized_text_searches.any? do |authorized_model, search_fields, value_field|
103
106
  next false unless authorized_model == model
104
107
  next false unless authorized_fields?(search_columns, Array(search_fields))
105
- next false unless authorized_fields?(result_columns, Array(result_fields))
108
+ next false unless authorized_fields?(Array(value_column), Array(value_field))
106
109
 
107
110
  true
108
111
  end
@@ -92,7 +92,9 @@ module ActiveElement
92
92
  end
93
93
 
94
94
  def model
95
- @model ||= params[:model].camelize(:upper).safe_constantize
95
+ @model ||= ActiveRecord::Base.descendants.find do |descendant|
96
+ descendant.name == params[:model].camelize(:upper)
97
+ end
96
98
  end
97
99
 
98
100
  def authorization
@@ -101,7 +103,7 @@ module ActiveElement
101
103
  params: params,
102
104
  user: controller.active_element.current_user,
103
105
  search_columns: sql.search_columns.compact,
104
- result_columns: (sql.search_columns + [sql.value_column]).compact
106
+ value_column: sql.value_column
105
107
  )
106
108
  end
107
109
 
@@ -17,6 +17,10 @@ module ActiveElement
17
17
  def register_authorized_text_search(model:, with:, providing:)
18
18
  authorized_text_searches << [model, with, providing]
19
19
  end
20
+
21
+ def text_search_options(model:, with:, providing:)
22
+ { search: { model: model.name.underscore, with: with, providing: providing } }
23
+ end
20
24
  end
21
25
  end
22
26
  end
@@ -3,10 +3,11 @@
3
3
  module ActiveElement
4
4
  module Components
5
5
  module Util
6
- # Utility class for mapping an association to a linked URL (e.g. for displaying associations in a table).
6
+ # Utility class for mapping an association to a linked URL display value, (e.g. for
7
+ # displaying associations in a table, form, etc.).
7
8
  class AssociationMapping
8
- def initialize(component:, field:, record:, associated_record:, options:)
9
- @component = component
9
+ def initialize(controller:, field:, record:, associated_record:, options: {})
10
+ @controller = controller
10
11
  @field = field
11
12
  @record = record
12
13
  @associated_record = associated_record
@@ -15,19 +16,49 @@ module ActiveElement
15
16
 
16
17
  def link_tag
17
18
  verify_display_attribute
18
- return display_value if associated_record_path.blank?
19
+ return associated_record.map { |value| link_to(value) } if multiple_association?
20
+ return link_to(associated_record) if single_association?
21
+ end
19
22
 
20
- component.controller.helpers.link_to(display_value, associated_record_path)
23
+ def relation_id # rubocop:disable Metrics/CyclomaticComplexity
24
+ case relation.macro
25
+ when :has_one
26
+ associated_record&.public_send(relation_key)
27
+ when :has_many
28
+ associated_record&.map(&relation_key)
29
+ when :belongs_to
30
+ record&.public_send(relation_key)
31
+ end
32
+ end
33
+
34
+ def relation_key
35
+ case relation.macro
36
+ when :has_one, :has_many
37
+ relation.klass.primary_key
38
+ when :belongs_to
39
+ relation.foreign_key
40
+ end
41
+ end
42
+
43
+ def display_value(value = associated_record)
44
+ return value&.public_send(display_field) if display_field.present?
45
+ return nil if associated_model&.primary_key.blank?
46
+
47
+ value.public_send(associated_model.primary_key)
21
48
  end
22
49
 
23
50
  private
24
51
 
25
- attr_reader :component, :field, :record, :associated_record, :options
52
+ attr_reader :controller, :field, :record, :associated_record, :options
53
+
54
+ def relation
55
+ @relation ||= record.class.reflect_on_association(field)
56
+ end
26
57
 
27
- def associated_record_path
28
- return nil unless component.controller.helpers.respond_to?(path_helper)
58
+ def associated_record_path(path_for)
59
+ return nil unless controller.helpers.respond_to?(path_helper)
29
60
 
30
- component.controller.helpers.public_send(path_helper, associated_record)
61
+ controller.helpers.public_send(path_helper, path_for)
31
62
  end
32
63
 
33
64
  def verify_display_attribute
@@ -38,42 +69,66 @@ module ActiveElement
38
69
  "`#{associated_record.class.name}.default_display_attribute`"
39
70
  end
40
71
 
41
- def display_value
42
- associated_record.public_send(display_field)
43
- end
44
-
45
72
  def display_field
46
73
  @display_field ||= options.fetch(:attribute) do
47
- next associated_record.class.default_display_attribute if defined_display_attribute?
74
+ next associated_model.default_display_attribute if defined_display_attribute?
48
75
  next default_display_attribute if default_display_attribute.present?
49
76
 
50
- associated_record.class.primary_key
77
+ associated_model.primary_key
51
78
  end
52
79
  end
53
80
 
54
81
  def defined_display_attribute?
55
- associated_record.class.respond_to?(:default_display_attribute)
82
+ associated_model.respond_to?(:default_display_attribute)
56
83
  end
57
84
 
58
85
  def namespace
59
- component.controller.class.name.deconstantize.underscore
86
+ controller.class.name.deconstantize.underscore
60
87
  end
61
88
 
62
89
  def resource_name
63
- record.public_send(field).model_name.singular
90
+ record.public_send(field)&.model_name&.singular
64
91
  end
65
92
 
66
93
  def default_display_attribute
67
94
  %i[name email display_name username].find do |display_field|
68
- associated_record.respond_to?(display_field) && associated_record.method(display_field).arity.zero?
95
+ next true if associated_model_callable_method?(display_field.to_sym)
96
+ next true if associated_model.columns.map(&:name).map(&:to_sym).include?(display_field.to_sym)
97
+
98
+ false
69
99
  end
70
100
  end
71
101
 
102
+ def associated_model
103
+ record.association(field).klass
104
+ end
105
+
106
+ def associated_model_callable_method?(name)
107
+ return false unless associated_model.public_instance_methods.include?(name)
108
+ return false unless associated_model.public_instance_method(name).arity.zero?
109
+
110
+ true
111
+ end
112
+
113
+ def single_association?
114
+ %i[has_one belongs_to].include?(relation.macro)
115
+ end
116
+
117
+ def multiple_association?
118
+ relation.macro == :has_many
119
+ end
120
+
72
121
  def path_helper
73
122
  return "#{resource_name}_path" if namespace.blank?
74
123
 
75
124
  "#{namespace}_#{resource_name}_path"
76
125
  end
126
+
127
+ def link_to(value)
128
+ return display_value(value) if associated_record_path(value).blank?
129
+
130
+ controller.helpers.link_to(display_value(value), associated_record_path(value))
131
+ end
77
132
  end
78
133
  end
79
134
  end
@@ -7,12 +7,21 @@ module ActiveElement
7
7
  class DisplayValueMapping
8
8
  include RecordMapping
9
9
 
10
+ def mapped_association_from_record
11
+ association_mapping.link_tag
12
+ end
13
+
10
14
  def numeric_value
11
15
  value_from_record
12
16
  end
13
17
 
14
18
  def json_value
15
- ActiveElement.json_pretty_print(value_from_record)
19
+ return ActiveElement.json_pretty_print(value_from_record) unless component.is_a?(CollectionTable)
20
+
21
+ component.controller.render_to_string(
22
+ partial: 'active_element/components/fields/json',
23
+ locals: { value: value_from_record, field_id: ActiveElement.element_id }
24
+ )
16
25
  end
17
26
 
18
27
  def string_value
@@ -20,15 +29,15 @@ module ActiveElement
20
29
  end
21
30
 
22
31
  def datetime_value
23
- value_from_record.strftime('%Y-%m-%d %H:%M:%S')
32
+ with_timezone_offset(value_from_record).strftime('%Y-%m-%d %H:%M:%S')
24
33
  end
25
34
 
26
35
  def time_value
27
- value_from_record.strftime('%H:%M:%S')
36
+ with_timezone_offset(value_from_record).strftime('%H:%M:%S')
28
37
  end
29
38
 
30
39
  def date_value
31
- value_from_record.strftime('%Y-%m-%d')
40
+ with_timezone_offset(value_from_record).strftime('%Y-%m-%d')
32
41
  end
33
42
 
34
43
  def boolean_value
@@ -4,13 +4,17 @@ module ActiveElement
4
4
  module Components
5
5
  module Util
6
6
  # Normalizes Form `fields` parameter from various supported input formats.
7
- class FormFieldMapping
7
+ class FormFieldMapping # rubocop:disable Metrics/ClassLength
8
8
  include SecretFields
9
+ include PhoneFields
10
+ include EmailFields
9
11
 
10
- def initialize(record, fields, i18n)
12
+ def initialize(record:, fields:, controller:, i18n:, search: false)
11
13
  @record = record
12
14
  @fields = fields
15
+ @controller = controller
13
16
  @i18n = i18n
17
+ @search = search
14
18
  end
15
19
 
16
20
  def fields_with_types_and_options
@@ -28,7 +32,7 @@ module ActiveElement
28
32
 
29
33
  private
30
34
 
31
- attr_reader :fields, :i18n, :record
35
+ attr_reader :fields, :i18n, :record, :controller, :search
32
36
 
33
37
  def normalized_field?(field)
34
38
  (field.size == 3) && field.last.is_a?(Hash)
@@ -43,29 +47,116 @@ module ActiveElement
43
47
  end
44
48
 
45
49
  def field_with_default_type_and_default_options(field)
46
- [field, default_type_from_model(field), {}]
50
+ return [field, type_from_file(field).to_sym, options_from_file(field)] if file_configuration?(field)
51
+ return relation_text_search_field(field) if relation?(field) && record.present? && !search?
52
+
53
+ [field, default_type_from_model(field), default_options(field)]
47
54
  end
48
55
 
49
56
  def field_with_type(field)
50
- [field.first, field.last, {}]
57
+ [field.first, field.last, default_options(field)]
58
+ end
59
+
60
+ def association_mapping(field)
61
+ @association_mapping ||= AssociationMapping.new(
62
+ controller: controller,
63
+ field: field,
64
+ record: record,
65
+ associated_record: record.public_send(field)
66
+ )
67
+ end
68
+
69
+ def file_configuration?(field)
70
+ # XXX: json_field schema is loaded separately so we ignore file configuration here.
71
+ # This does prevent specifying an alternative field type in a config file but can still
72
+ # be defined inline if e.g. a user wants a text_area instead of a json_field:
73
+ #
74
+ # active_element.component.form fields: [:foo, :bar, [:some_json_field, :text_area]]
75
+ #
76
+ file_configuration_path(field).file? && default_type_from_model(field) != :json_field
77
+ end
78
+
79
+ def type_from_file(field)
80
+ YAML.safe_load(file_configuration_path(field).read, symbolize_names: true)
81
+ .fetch(:type, default_type_from_model(field))
82
+ end
83
+
84
+ def options_from_file(field)
85
+ YAML.safe_load(file_configuration_path(field).read, symbolize_names: true).fetch(:options, {})
86
+ end
87
+
88
+ def file_configuration_path(field)
89
+ record_field_configuration_path(field) || sti_field_configuration_path(field)
90
+ end
91
+
92
+ def record_field_configuration_path(field)
93
+ record_name = Util.record_name(record)
94
+ return nil if record_name.blank?
95
+
96
+ Rails.root.join('config/forms', record_name, "#{field}.yml")
97
+ end
98
+
99
+ def sti_record_field_configuration_path(field)
100
+ sti_record_name = Util.sti_record_name(record)
101
+ return nil if sti_record_name.blank?
102
+
103
+ Rails.root.join('config/forms', sti_record_name, "#{field}.yml")
51
104
  end
52
105
 
53
106
  def default_type_from_model(field)
54
107
  return default_field_type(field) if record.blank?
108
+ return default_field_type(field) if column(field).blank?
109
+
110
+ default_type_from_column_type(field, column(field).type)
111
+ end
112
+
113
+ def column(field)
114
+ model&.columns&.find { |model_column| model_column.name.to_s == field.to_s }
115
+ end
116
+
117
+ def model
118
+ record&.class || controller.controller_name.classify.safe_constantize
119
+ end
120
+
121
+ def relation?(field)
122
+ relation(field).present?
123
+ end
55
124
 
56
- column = record.class.columns.find { |model_column| model_column.name.to_s == field.to_s }
57
- return default_field_type(field) if column.blank?
125
+ def relation(field)
126
+ model&.reflect_on_association(field)
127
+ end
128
+
129
+ def relation_text_search_field(field)
130
+ relation_model = relation(field).klass
131
+ record.public_send(field)
132
+ [field, :text_search_field,
133
+ TextSearch.text_search_options(
134
+ model: relation_model,
135
+ with: searchable_fields(field),
136
+ providing: relation_model.primary_key
137
+ ).merge({ display_value: association_mapping(field).display_value })]
138
+ end
58
139
 
59
- default_type_from_column_type(field, column.type)
140
+ def searchable_fields(field)
141
+ Util.relation_controller(model, controller, field)
142
+ .active_element
143
+ .state
144
+ .fetch(:searchable_fields, [])
60
145
  end
61
146
 
62
- def default_type_from_column_type(field, column_type)
147
+ def default_type_from_column_type(field, column_type) # rubocop:disable Metrics/MethodLength
63
148
  {
64
149
  string: default_field_type(field),
65
150
  boolean: :check_box,
66
151
  json: :json_field,
67
152
  jsonb: :json_field,
68
- geometry: :text_area
153
+ geometry: :text_area,
154
+ datetime: :datetime_field,
155
+ date: :date_field,
156
+ time: :time_field,
157
+ integer: :number_field,
158
+ decimal: :number_field,
159
+ float: :number_field
69
160
  }.fetch(column_type.to_sym, default_field_type(field))
70
161
  end
71
162
 
@@ -94,10 +185,36 @@ module ActiveElement
94
185
  end
95
186
 
96
187
  def default_field_type(field)
188
+ return default_search_field_type(field) if search?
97
189
  return :password_field if secret_field?(field)
190
+ return :email_field if email_field?(field)
191
+ return :phone_field if phone_field?(field)
192
+
193
+ :text_field
194
+ end
195
+
196
+ def search?
197
+ search
198
+ end
199
+
200
+ def default_search_field_type(field)
201
+ return :datetime_range_field if column(field)&.type == :datetime
98
202
 
99
203
  :text_field
100
204
  end
205
+
206
+ def default_options(field)
207
+ { required: required?(field) }
208
+ end
209
+
210
+ def required?(field)
211
+ return false if record.blank?
212
+ return false unless record.class.respond_to?(:validators)
213
+
214
+ record.class.validators.find do |validator|
215
+ validator.kind == :presence && validator.attributes.include?(field.to_sym)
216
+ end
217
+ end
101
218
  end
102
219
  end
103
220
  end
@@ -20,23 +20,19 @@ module ActiveElement
20
20
  end
21
21
 
22
22
  def datetime_value
23
- value_from_record.strftime('%Y-%m-%d %H:%M:%S')
23
+ with_timezone_offset(value_from_record).strftime('%Y-%m-%d %H:%M:%S')
24
24
  end
25
25
 
26
26
  def time_value
27
- value_from_record.strftime('%H:%M:%S')
27
+ with_timezone_offset(value_from_record).strftime('%H:%M:%S')
28
28
  end
29
29
 
30
30
  def date_value
31
- value_from_record.strftime('%Y-%m-%d')
31
+ with_timezone_offset(value_from_record).strftime('%Y-%m-%d')
32
32
  end
33
33
 
34
34
  def boolean_value
35
35
  value_from_record
36
- component.controller.render_to_string(
37
- partial: 'active_element/components/fields/boolean',
38
- locals: { value: value_from_record }
39
- )
40
36
  end
41
37
 
42
38
  def geometry_value
@@ -7,7 +7,7 @@ module ActiveElement
7
7
  # descriptions, placeholders, etc. for various components in locales files.
8
8
  class I18n
9
9
  def self.class_name(val, plural: false)
10
- base = val&.to_s&.underscore&.tr('_', '-')&.tr('/', '-')
10
+ base = val&.to_s&.underscore&.tr('/', '_')
11
11
  plural ? base&.pluralize : base
12
12
  end
13
13
 
@@ -49,41 +49,60 @@ module ActiveElement
49
49
  end
50
50
 
51
51
  def mapped_value_from_record
52
- return mapped_association_from_record if value_from_record.class.is_a?(ActiveModel::Naming)
52
+ return mapped_association_from_record if association?
53
53
  return nil if value_from_record.nil?
54
54
  return value_from_record unless DATABASE_TYPES.include?(column.type) || value_from_record.blank?
55
55
 
56
56
  send("#{column.type}_value")
57
57
  end
58
58
 
59
- def mapped_association_from_record
60
- AssociationMapping.new(
61
- component: component,
59
+ def association_mapping
60
+ @association_mapping ||= AssociationMapping.new(
61
+ controller: component.controller,
62
62
  field: field,
63
63
  record: record,
64
64
  associated_record: value_from_record,
65
65
  options: options
66
- ).link_tag
66
+ )
67
67
  end
68
68
 
69
69
  # Override these methods as required in a class that includes this module:
70
70
 
71
+ def mapped_association_from_record
72
+ association_mapping.relation_id
73
+ end
74
+
71
75
  def numeric_value
72
76
  value_from_record
73
77
  end
74
- alias integer_value numeric_value
75
- alias decimal_value numeric_value
76
- alias float_value numeric_value
78
+
79
+ def integer_value
80
+ numeric_value
81
+ end
82
+
83
+ def decimal_value
84
+ numeric_value
85
+ end
86
+
87
+ def float_value
88
+ numeric_value
89
+ end
77
90
 
78
91
  def json_value
79
92
  value_from_record
80
93
  end
81
- alias jsonb_value json_value
94
+
95
+ def jsonb_value
96
+ json_value
97
+ end
82
98
 
83
99
  def string_value
84
100
  value_from_record
85
101
  end
86
- alias text_value string_value
102
+
103
+ def text_value
104
+ string_value
105
+ end
87
106
 
88
107
  def datetime_value
89
108
  value_from_record
@@ -100,11 +119,24 @@ module ActiveElement
100
119
  def boolean_value
101
120
  value_from_record
102
121
  end
103
- alias binary_value boolean_value
122
+
123
+ def binary_value
124
+ boolean_value
125
+ end
104
126
 
105
127
  def geometry_value
106
128
  value_from_record
107
129
  end
130
+
131
+ def timezone_offset
132
+ component.controller.request.cookies['timezone_offset'].to_i.minutes
133
+ end
134
+
135
+ def with_timezone_offset(val)
136
+ return val if val.blank?
137
+
138
+ val - timezone_offset
139
+ end
108
140
  end
109
141
  end
110
142
  end