active_element 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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