active_element 0.0.10 → 0.0.12

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 (219) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +12 -2
  3. data/.strong_versions.yml +1 -0
  4. data/Gemfile +5 -0
  5. data/Gemfile.lock +115 -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 +38 -27
  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 +42 -24
  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 +8 -4
  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/_ungrouped_collection.html.erb +1 -0
  40. data/app/views/active_element/components/table/collection.html.erb +1 -1
  41. data/app/views/active_element/components/table/item.html.erb +6 -4
  42. data/app/views/active_element/default_views/edit.html.erb +5 -0
  43. data/app/views/active_element/default_views/forbidden.html.erb +7 -0
  44. data/app/views/active_element/default_views/index.html.erb +15 -0
  45. data/app/views/active_element/default_views/new.html.erb +4 -0
  46. data/app/views/active_element/default_views/show.html.erb +7 -0
  47. data/app/views/active_element/navbar/_menu.html.erb +1 -30
  48. data/app/views/active_element/theme/_select.html.erb +1 -1
  49. data/app/views/layouts/active_element.html.erb +16 -1
  50. data/config/brakeman.ignore +48 -0
  51. data/config/locales/en.yml +3 -0
  52. data/example_app/.gitattributes +7 -0
  53. data/example_app/.gitignore +35 -0
  54. data/example_app/.ruby-version +1 -0
  55. data/example_app/Gemfile +34 -0
  56. data/example_app/Gemfile.lock +296 -0
  57. data/example_app/README.md +24 -0
  58. data/example_app/Rakefile +6 -0
  59. data/example_app/app/assets/config/manifest.js +4 -0
  60. data/example_app/app/assets/images/.keep +0 -0
  61. data/example_app/app/assets/stylesheets/application.css +15 -0
  62. data/example_app/app/channels/application_cable/channel.rb +4 -0
  63. data/example_app/app/channels/application_cable/connection.rb +4 -0
  64. data/example_app/app/controllers/application_controller.rb +12 -0
  65. data/example_app/app/controllers/concerns/.keep +0 -0
  66. data/example_app/app/controllers/pets_controller.rb +7 -0
  67. data/example_app/app/controllers/users_controller.rb +7 -0
  68. data/example_app/app/helpers/application_helper.rb +2 -0
  69. data/example_app/app/javascript/application.js +3 -0
  70. data/example_app/app/javascript/controllers/application.js +9 -0
  71. data/example_app/app/javascript/controllers/hello_controller.js +7 -0
  72. data/example_app/app/javascript/controllers/index.js +11 -0
  73. data/example_app/app/jobs/application_job.rb +7 -0
  74. data/example_app/app/mailers/application_mailer.rb +4 -0
  75. data/example_app/app/models/application_record.rb +3 -0
  76. data/example_app/app/models/concerns/.keep +0 -0
  77. data/example_app/app/models/pet.rb +3 -0
  78. data/example_app/app/models/user.rb +8 -0
  79. data/example_app/app/views/layouts/application.html.erb +16 -0
  80. data/example_app/app/views/layouts/mailer.html.erb +13 -0
  81. data/example_app/app/views/layouts/mailer.text.erb +1 -0
  82. data/example_app/app/views/pets/index.html.erb +3 -0
  83. data/example_app/app/views/users/show.html.erb +3 -0
  84. data/example_app/bin/bundle +109 -0
  85. data/example_app/bin/importmap +4 -0
  86. data/example_app/bin/rails +4 -0
  87. data/example_app/bin/rake +4 -0
  88. data/example_app/bin/setup +33 -0
  89. data/example_app/config/application.rb +22 -0
  90. data/example_app/config/boot.rb +4 -0
  91. data/example_app/config/cable.yml +10 -0
  92. data/example_app/config/credentials.yml.enc +1 -0
  93. data/example_app/config/database.yml +25 -0
  94. data/example_app/config/environment.rb +5 -0
  95. data/example_app/config/environments/development.rb +70 -0
  96. data/example_app/config/environments/production.rb +93 -0
  97. data/example_app/config/environments/test.rb +60 -0
  98. data/example_app/config/importmap.rb +7 -0
  99. data/example_app/config/initializers/assets.rb +12 -0
  100. data/example_app/config/initializers/content_security_policy.rb +25 -0
  101. data/example_app/config/initializers/devise.rb +16 -0
  102. data/example_app/config/initializers/filter_parameter_logging.rb +8 -0
  103. data/example_app/config/initializers/inflections.rb +16 -0
  104. data/example_app/config/initializers/permissions_policy.rb +11 -0
  105. data/example_app/config/locales/devise.en.yml +65 -0
  106. data/example_app/config/locales/en.yml +33 -0
  107. data/example_app/config/puma.rb +43 -0
  108. data/example_app/config/routes.rb +8 -0
  109. data/example_app/config/storage.yml +34 -0
  110. data/example_app/config.ru +6 -0
  111. data/example_app/db/migrate/20230616210539_create_pet.rb +12 -0
  112. data/example_app/db/migrate/20230616211328_devise_create_users.rb +46 -0
  113. data/example_app/db/schema.rb +37 -0
  114. data/example_app/db/seeds.rb +33 -0
  115. data/example_app/lib/assets/.keep +0 -0
  116. data/example_app/lib/tasks/.keep +0 -0
  117. data/example_app/log/.keep +0 -0
  118. data/example_app/public/404.html +67 -0
  119. data/example_app/public/422.html +67 -0
  120. data/example_app/public/500.html +66 -0
  121. data/example_app/public/apple-touch-icon-precomposed.png +0 -0
  122. data/example_app/public/apple-touch-icon.png +0 -0
  123. data/example_app/public/favicon.ico +0 -0
  124. data/example_app/public/robots.txt +1 -0
  125. data/example_app/storage/.keep +0 -0
  126. data/example_app/test/application_system_test_case.rb +5 -0
  127. data/example_app/test/channels/application_cable/connection_test.rb +11 -0
  128. data/example_app/test/controllers/.keep +0 -0
  129. data/example_app/test/fixtures/files/.keep +0 -0
  130. data/example_app/test/fixtures/users.yml +11 -0
  131. data/example_app/test/helpers/.keep +0 -0
  132. data/example_app/test/integration/.keep +0 -0
  133. data/example_app/test/mailers/.keep +0 -0
  134. data/example_app/test/models/.keep +0 -0
  135. data/example_app/test/models/user_test.rb +7 -0
  136. data/example_app/test/system/.keep +0 -0
  137. data/example_app/test/test_helper.rb +13 -0
  138. data/example_app/tmp/.keep +0 -0
  139. data/example_app/tmp/pids/.keep +0 -0
  140. data/example_app/tmp/storage/.keep +0 -0
  141. data/example_app/vendor/.keep +0 -0
  142. data/example_app/vendor/javascript/.keep +0 -0
  143. data/lib/active_element/component.rb +9 -2
  144. data/lib/active_element/components/collection_table.rb +9 -2
  145. data/lib/active_element/components/email_fields.rb +14 -0
  146. data/lib/active_element/components/form.rb +48 -17
  147. data/lib/active_element/components/navbar.rb +64 -0
  148. data/lib/active_element/components/phone_fields.rb +14 -0
  149. data/lib/active_element/components/text_search/authorization.rb +9 -6
  150. data/lib/active_element/components/text_search/component.rb +4 -2
  151. data/lib/active_element/components/text_search.rb +13 -0
  152. data/lib/active_element/components/util/association_mapping.rb +74 -19
  153. data/lib/active_element/components/util/display_value_mapping.rb +13 -4
  154. data/lib/active_element/components/util/form_field_mapping.rb +139 -10
  155. data/lib/active_element/components/util/form_value_mapping.rb +3 -3
  156. data/lib/active_element/components/util/i18n.rb +1 -1
  157. data/lib/active_element/components/util/numeric_field.rb +73 -0
  158. data/lib/active_element/components/util/record_mapping.rb +43 -11
  159. data/lib/active_element/components/util/record_path.rb +21 -4
  160. data/lib/active_element/components/util.rb +13 -5
  161. data/lib/active_element/components.rb +3 -0
  162. data/lib/active_element/controller_action.rb +8 -2
  163. data/lib/active_element/controller_interface.rb +56 -18
  164. data/lib/active_element/controller_state.rb +44 -0
  165. data/lib/active_element/default_controller.rb +137 -0
  166. data/lib/active_element/default_record_params.rb +62 -0
  167. data/lib/active_element/default_search.rb +110 -0
  168. data/lib/active_element/json_field_schema.rb +59 -0
  169. data/lib/active_element/pre_render_processors/json.rb +98 -0
  170. data/lib/active_element/pre_render_processors.rb +11 -0
  171. data/lib/active_element/route.rb +12 -0
  172. data/lib/active_element/routes.rb +2 -1
  173. data/lib/active_element/version.rb +1 -1
  174. data/lib/active_element.rb +15 -32
  175. data/lib/tasks/active_element.rake +12 -1
  176. data/rspec-documentation/_head.html.erb +34 -0
  177. data/rspec-documentation/pages/000-Introduction.md +18 -0
  178. data/rspec-documentation/pages/005-Setup.md +75 -0
  179. data/rspec-documentation/pages/010-Components/Form Fields/Check Boxes.md +1 -0
  180. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Controller Params.md +97 -0
  181. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Schema.md +283 -0
  182. data/rspec-documentation/pages/010-Components/Form Fields/JSON/Types.md +36 -0
  183. data/rspec-documentation/pages/010-Components/Form Fields/JSON.md +70 -0
  184. data/rspec-documentation/pages/010-Components/Form Fields/Text Search.md +133 -0
  185. data/rspec-documentation/pages/010-Components/Form Fields.md +46 -0
  186. data/rspec-documentation/pages/010-Components/Forms.md +44 -0
  187. data/rspec-documentation/pages/010-Components/JSON Data.md +23 -0
  188. data/rspec-documentation/pages/010-Components/Navbar.md +56 -0
  189. data/rspec-documentation/pages/010-Components/Page Section Title.md +13 -0
  190. data/rspec-documentation/pages/010-Components/Page Subtitle.md +11 -0
  191. data/rspec-documentation/pages/010-Components/Page Title.md +11 -0
  192. data/rspec-documentation/pages/010-Components/Tables/Collection Table.md +29 -0
  193. data/rspec-documentation/pages/010-Components/Tables/Item Table.md +18 -0
  194. data/rspec-documentation/pages/010-Components/Tables/Options.md +19 -0
  195. data/rspec-documentation/pages/010-Components/Tables.md +29 -0
  196. data/rspec-documentation/pages/010-Components.md +15 -0
  197. data/rspec-documentation/pages/020-Access Control/010-Authentication.md +20 -0
  198. data/rspec-documentation/pages/020-Access Control/020-Authorization/Environments.md +9 -0
  199. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions/Custom Routes.md +41 -0
  200. data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions.md +58 -0
  201. data/rspec-documentation/pages/020-Access Control/020-Authorization/Setup.md +27 -0
  202. data/rspec-documentation/pages/020-Access Control/020-Authorization.md +11 -0
  203. data/rspec-documentation/pages/020-Access Control.md +31 -0
  204. data/rspec-documentation/pages/040-Decorators/Inline Decorators.md +24 -0
  205. data/rspec-documentation/pages/040-Decorators/View Decorators.md +55 -0
  206. data/rspec-documentation/pages/040-Decorators.md +12 -0
  207. data/rspec-documentation/pages/300-Alternatives.md +21 -0
  208. data/rspec-documentation/pages/900-License.md +11 -0
  209. data/rspec-documentation/spec_helper.rb +53 -16
  210. data/rspec-documentation/support.rb +84 -0
  211. metadata +159 -14
  212. data/rspec-documentation/pages/Components/Forms.md +0 -1
  213. data/rspec-documentation/pages/Components/Tables.md +0 -47
  214. data/rspec-documentation/pages/Components.md +0 -1
  215. data/rspec-documentation/pages/Decorators/Inline Decorators.md +0 -1
  216. data/rspec-documentation/pages/Decorators/View Decorators.md +0 -1
  217. data/rspec-documentation/pages/Index.md +0 -3
  218. data/rspec-documentation/pages/Util/I18n.md +0 -1
  219. /data/rspec-documentation/pages/{Components → 010-Components}/Tabs.md +0 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Stores various data for a controller, including various field definitions and authentication
5
+ # configuration. Used throughout ActiveElement for generating dynamic content based on
6
+ # controller configuration.
7
+ class ControllerState
8
+ attr_reader :permissions, :listable_fields, :viewable_fields, :editable_fields, :searchable_fields
9
+ attr_accessor :sign_in_path, :sign_in, :sign_in_method, :sign_out_path, :sign_out_method,
10
+ :deletable, :authorizor, :authenticator
11
+
12
+ def initialize(controller:)
13
+ @controller = controller
14
+ @permissions = []
15
+ @authenticator = nil
16
+ @authorizor = nil
17
+ @deletable = false
18
+ @listable_fields = []
19
+ @viewable_fields = []
20
+ @editable_fields = []
21
+ @searchable_fields = []
22
+ end
23
+
24
+ def deletable?
25
+ !!deletable
26
+ end
27
+
28
+ def viewable?
29
+ viewable_fields.present? || controller.public_methods(false).include?(:show)
30
+ end
31
+
32
+ def editable?
33
+ editable_fields.present? || controller.public_methods(false).include?(:edit)
34
+ end
35
+
36
+ def creatable?
37
+ editable_fields.present? || controller.public_methods(false).include?(:new)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :controller
43
+ end
44
+ end
@@ -0,0 +1,137 @@
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
+ return render_forbidden(:listable) unless configured?(:listable)
13
+
14
+ controller.render 'active_element/default_views/index',
15
+ locals: {
16
+ collection: collection,
17
+ search_filters: default_text_search.search_filters
18
+ }
19
+ end
20
+
21
+ def show
22
+ return render_forbidden(:viewable) unless configured?(:viewable)
23
+
24
+ controller.render 'active_element/default_views/show', locals: { record: record }
25
+ end
26
+
27
+ def new
28
+ return render_forbidden(:editable) unless configured?(:editable)
29
+
30
+ controller.render 'active_element/default_views/new', locals: { record: model.new, namespace: namespace }
31
+ end
32
+
33
+ def create # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34
+ return render_forbidden(:editable) unless configured?(:editable)
35
+
36
+ new_record = model.new(default_record_params.params)
37
+ # XXX: Ensure associations are applied - there must be a better way.
38
+ if new_record.save && new_record.reload.update(default_record_params.params)
39
+ controller.flash.notice = "#{new_record.model_name.to_s.titleize} created successfully."
40
+ controller.redirect_to record_path(new_record, :show).path
41
+ else
42
+ controller.flash.now.alert = "Failed to create #{model.name.to_s.titleize}."
43
+ controller.render 'active_element/default_views/new', locals: { record: new_record, namespace: namespace }
44
+ end
45
+ rescue ActiveRecord::RangeError => e
46
+ render_range_error(error: e, action: :new)
47
+ end
48
+
49
+ def edit
50
+ return render_forbidden(:editable) unless configured?(:editable)
51
+
52
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
53
+ end
54
+
55
+ def update # rubocop:disable Metrics/AbcSize
56
+ return render_forbidden(:editable) unless configured?(:editable)
57
+
58
+ if record.update(default_record_params.params)
59
+ controller.flash.notice = "#{record.model_name.to_s.titleize} updated successfully."
60
+ controller.redirect_to record_path(record, :show).path
61
+ else
62
+ controller.flash.now.alert = "Failed to update #{model.name.to_s.titleize}."
63
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
64
+ end
65
+ rescue ActiveRecord::RangeError => e
66
+ render_range_error(error: e, action: :edit)
67
+ end
68
+
69
+ def destroy
70
+ return render_forbidden(:deletable) unless configured?(:deletable)
71
+
72
+ record.destroy
73
+ controller.flash.notice = "Deleted #{record.model_name.to_s.titleize}."
74
+ controller.redirect_to record_path(model, :index).path
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :controller
80
+
81
+ def render_forbidden(type)
82
+ controller.render 'active_element/default_views/forbidden', locals: { type: type }
83
+ end
84
+
85
+ def configured?(type)
86
+ return state.deletable? if type == :deletable
87
+
88
+ state.public_send("#{type}_fields").present?
89
+ end
90
+
91
+ def state
92
+ @state ||= controller.active_element.state
93
+ end
94
+
95
+ def default_record_params
96
+ @default_record_params ||= ActiveElement::DefaultRecordParams.new(controller: controller, model: model)
97
+ end
98
+
99
+ def default_text_search
100
+ @default_text_search ||= ActiveElement::DefaultSearch.new(controller: controller, model: model)
101
+ end
102
+
103
+ def record_path(record, type = nil)
104
+ ActiveElement::Components::Util::RecordPath.new(record: record, controller: controller, type: type)
105
+ end
106
+
107
+ def namespace
108
+ controller.controller_path.rpartition('/').first.presence&.to_sym
109
+ end
110
+
111
+ def model
112
+ controller.controller_name.classify.constantize
113
+ end
114
+
115
+ def record
116
+ @record ||= model.find(controller.params[:id])
117
+ end
118
+
119
+ def collection
120
+ return model.all unless default_text_search.text_search?
121
+
122
+ model.left_outer_joins(default_text_search.search_relations).where(*default_text_search.text_search)
123
+ end
124
+
125
+ def render_range_error(error:, action:)
126
+ controller.flash.now.alert = formatted_error(error)
127
+ controller.render "active_element/default_views/#{action}", locals: { record: record, namespace: namespace }
128
+ end
129
+
130
+ def formatted_error(error)
131
+ return error.cause.message.split("\n").join(', ') if error.try(:cause)&.try(:message).present?
132
+ return error.message if error.try(:message).present?
133
+
134
+ I18n.t('active_element.unexpected_error')
135
+ end
136
+ end
137
+ 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.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 and datetime querying for DefaultController, provides full text search
5
+ # filters for all controllers with configured searchable fields. Includes support for querying
6
+ # across relations.
7
+ class DefaultSearch
8
+ def initialize(controller:, model:)
9
+ @controller = controller
10
+ @model = model
11
+ end
12
+
13
+ def search_filters
14
+ @search_filters ||= controller.params.permit(*searchable_fields).transform_values do |value|
15
+ value.try(:compact_blank) || value
16
+ end.compact_blank
17
+ end
18
+
19
+ def text_search?
20
+ search_filters.present?
21
+ end
22
+
23
+ def text_search
24
+ conditions = search_filters.to_h.map do |key, value|
25
+ next relation_matches(key, value) if relation?(key)
26
+ next datetime_between(key, value) if datetime?(key)
27
+
28
+ model.arel_table[key].matches("#{value}%")
29
+ end
30
+ conditions[1..].reduce(conditions.first) do |accumulated, condition|
31
+ accumulated.and(condition)
32
+ end
33
+ end
34
+
35
+ def search_relations
36
+ search_filters.to_h.keys.map { |key| relation?(key) ? key.to_sym : nil }.compact
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :controller, :model
42
+
43
+ def searchable_fields
44
+ controller.active_element.state.searchable_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(value)
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.12'
5
5
  end
@@ -12,12 +12,18 @@ require_relative 'active_element/active_menu_link'
12
12
  require_relative 'active_element/permissions_check'
13
13
  require_relative 'active_element/permissions_report'
14
14
  require_relative 'active_element/controller_interface'
15
+ require_relative 'active_element/controller_state'
15
16
  require_relative 'active_element/controller_action'
17
+ require_relative 'active_element/default_controller'
18
+ require_relative 'active_element/default_record_params'
19
+ require_relative 'active_element/default_search'
20
+ require_relative 'active_element/pre_render_processors'
16
21
  require_relative 'active_element/rails_component'
17
22
  require_relative 'active_element/route'
18
23
  require_relative 'active_element/routes'
19
24
  require_relative 'active_element/component'
20
25
  require_relative 'active_element/components'
26
+ require_relative 'active_element/json_field_schema'
21
27
  require_relative 'active_element/engine'
22
28
 
23
29
  # ActiveElement API Admin UI template and menu system.
@@ -26,8 +32,11 @@ module ActiveElement
26
32
  class UnprotectedRouteError < Error; end
27
33
  class UnknownAttributeError < Error; end
28
34
 
35
+ @eager_loaded = {}
36
+
29
37
  class << self
30
- attr_writer :application_name, :navbar_items
38
+ attr_writer :application_name
39
+ attr_accessor :navbar_items
31
40
 
32
41
  include Paintbrush
33
42
 
@@ -35,10 +44,6 @@ module ActiveElement
35
44
  @application_name || RailsComponent.new(Rails).application_name.titleize
36
45
  end
37
46
 
38
- def navbar_items(user)
39
- @navbar_items || inferred_navbar_items(user)
40
- end
41
-
42
47
  def warning(message)
43
48
  warn "#{log_tag} #{paintbrush { yellow(message) }}"
44
49
  end
@@ -47,19 +52,6 @@ module ActiveElement
47
52
  paintbrush { cyan "[#{blue 'ActiveElement'}]" }
48
53
  end
49
54
 
50
- def active_path_class(user:, current_navbar_item:, current_path:, controller_path:, action_name:)
51
- if ActiveMenuLink.new(
52
- rails_component: RailsComponent.new(Rails),
53
- navbar_items: navbar_items(user),
54
- current_path: current_path,
55
- current_navbar_item: current_navbar_item,
56
- controller_path: controller_path,
57
- action_name: action_name
58
- ).active?
59
- 'active'
60
- end
61
- end
62
-
63
55
  def json_pretty_print(json)
64
56
  Components::Util.json_pretty_print(json)
65
57
  end
@@ -89,24 +81,15 @@ module ActiveElement
89
81
  end
90
82
 
91
83
  def eager_load(resource)
84
+ return if @eager_loaded[resource]
85
+
92
86
  suffix = resource == :controllers ? '_controller' : nil
93
87
  Rails.root.join("app/#{resource}").glob("**/*#{suffix}.rb").each { |path| require path }
88
+ @eager_loaded[resource] = true unless Rails.env.development?
94
89
  end
95
90
 
96
- private
97
-
98
- def inferred_navbar_items(user)
99
- eager_load_controllers
100
- user_routes(user).available.select(&:primary?).map do |route|
101
- { path: route.path, title: route.title, spec: route.spec }
102
- end
103
- end
104
-
105
- def user_routes(user)
106
- ActiveElement::Routes.new(
107
- permissions: user&.permissions,
108
- rails_component: ActiveElement::RailsComponent.new(Rails)
109
- )
91
+ def element_id
92
+ "active-element-#{SecureRandom.uuid}"
110
93
  end
111
94
  end
112
95
  end
@@ -1,8 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :active_element do
4
- desc 'Displays all permissions used by this application'
4
+ desc 'Display all permissions used by this application'
5
5
  task permissions: :environment do
6
6
  $stdout.puts ActiveElement::PermissionsReport.new.report
7
7
  end
8
+
9
+ namespace :json do
10
+ desc 'Generate JSON form field schema from database values'
11
+ task schema: :environment do
12
+ if ENV.key?('table') && ENV.key?('column')
13
+ ActiveElement::JsonFieldSchema.new(table: ENV.fetch('table'), column: ENV.fetch('column'))
14
+ else
15
+ warn(Paintbrush.paintbrush { red "Expected #{cyan 'table'} and #{cyan 'column'} environment variables." })
16
+ end
17
+ end
18
+ end
8
19
  end