active_element 0.0.12 → 0.0.13

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +3 -3
  4. data/app/assets/javascripts/active_element/highlight.js +311 -0
  5. data/app/assets/javascripts/active_element/json_field.js +51 -20
  6. data/app/assets/javascripts/active_element/popover.js +6 -4
  7. data/app/assets/stylesheets/active_element/_dark.scss +1 -1
  8. data/app/assets/stylesheets/active_element/application.scss +39 -1
  9. data/app/controllers/concerns/active_element/default_controller_actions.rb +7 -7
  10. data/app/views/active_element/components/fields/_json.html.erb +3 -2
  11. data/app/views/active_element/components/form/_field.html.erb +2 -1
  12. data/app/views/active_element/components/form/_json.html.erb +2 -0
  13. data/app/views/active_element/components/form/_label.html.erb +7 -0
  14. data/app/views/active_element/components/form.html.erb +2 -2
  15. data/app/views/layouts/active_element.html.erb +25 -4
  16. data/example_app/Gemfile.lock +1 -1
  17. data/lib/active_element/components/form.rb +1 -8
  18. data/lib/active_element/components/util/display_value_mapping.rb +0 -2
  19. data/lib/active_element/components/util/form_field_mapping.rb +2 -1
  20. data/lib/active_element/components/util.rb +7 -0
  21. data/lib/active_element/controller_interface.rb +2 -1
  22. data/lib/active_element/controller_state.rb +1 -1
  23. data/lib/active_element/default_controller/actions.rb +3 -0
  24. data/lib/active_element/default_controller/controller.rb +145 -0
  25. data/lib/active_element/default_controller/json_params.rb +48 -0
  26. data/lib/active_element/default_controller/params.rb +97 -0
  27. data/lib/active_element/default_controller/search.rb +112 -0
  28. data/lib/active_element/default_controller.rb +10 -132
  29. data/lib/active_element/version.rb +1 -1
  30. data/lib/active_element.rb +0 -2
  31. data/rspec-documentation/_head.html.erb +2 -0
  32. data/rspec-documentation/pages/000-Introduction.md +8 -5
  33. data/rspec-documentation/pages/005-Setup.md +21 -28
  34. data/rspec-documentation/pages/010-Components/Form Fields.md +35 -0
  35. data/rspec-documentation/pages/015-Custom Controllers.md +32 -0
  36. data/rspec-documentation/pages/016-Default Controller.md +132 -0
  37. data/rspec-documentation/pages/Themes.md +3 -0
  38. metadata +11 -4
  39. data/lib/active_element/default_record_params.rb +0 -62
  40. data/lib/active_element/default_search.rb +0 -110
@@ -2,12 +2,23 @@
2
2
  @import "bootstrap";
3
3
  @import "dark";
4
4
 
5
+
6
+ @keyframes fade-in {
7
+ from {
8
+ opacity: 0;
9
+ }
10
+
11
+ to {
12
+ opacity: 1;
13
+ }
14
+ }
15
+
5
16
  .application-menu {
6
17
  height: 5rem;
7
18
  padding-left: 2rem;
8
19
  top: 0;
9
20
  transition: height 0.5s ease-in-out, background-position 0.8s ease-in-out;
10
- background-color: #456060 !important;
21
+ background-color: #508ea1 !important;
11
22
  z-index: 2000;
12
23
  .dropdown-toggle::after {
13
24
  color: #{$blue};
@@ -77,6 +88,12 @@ form {
77
88
  margin-top: 5rem;
78
89
  }
79
90
 
91
+ .modal-content pre, .modal-content div.json-highlight {
92
+ font-size: 0.875rem;
93
+ line-height: 1.2rem;
94
+ font-family: monospace;
95
+ }
96
+
80
97
  .json-field {
81
98
  ol.json-array-field {
82
99
  margin-top: 1rem;
@@ -105,6 +122,11 @@ form {
105
122
  .form-group {
106
123
  padding: 1rem;
107
124
  background-color: #58575755;
125
+
126
+ &.depth-1 {
127
+ padding: 0;
128
+ background-color: transparent;
129
+ }
108
130
  }
109
131
 
110
132
  .form-check {
@@ -176,6 +198,16 @@ form {
176
198
  }
177
199
 
178
200
 
201
+ .json-array-field {
202
+ li, .json-delete-button {
203
+ opacity: 0;
204
+ animation: fade-in ease-in 1;
205
+ animation-fill-mode: forwards;
206
+ animation-duration: 0.5s;
207
+ animation-delay: 0;
208
+ }
209
+ }
210
+
179
211
  .json-array-field {
180
212
  li .json-text-field,
181
213
  li .json-select-field,
@@ -185,12 +217,18 @@ form {
185
217
  li .json-date-field,
186
218
  li .json-time-field,
187
219
  li .json-datetime-field {
220
+
188
221
  &.deletable {
189
222
  width: calc(100% - 3.2rem);
190
223
  }
191
224
  }
192
225
  }
193
226
 
227
+ .append-button {
228
+ position: relative;
229
+ z-index: 300;
230
+ }
231
+
194
232
  .form-control, .form-select {
195
233
  display: inline;
196
234
  .append-button {
@@ -8,31 +8,31 @@ module ActiveElement
8
8
  extend ActiveSupport::Concern
9
9
 
10
10
  def index
11
- ActiveElement::DefaultController.new(controller: self).index
11
+ ActiveElement::DefaultController::Controller.new(controller: self).index
12
12
  end
13
13
 
14
14
  def show
15
- ActiveElement::DefaultController.new(controller: self).show
15
+ ActiveElement::DefaultController::Controller.new(controller: self).show
16
16
  end
17
17
 
18
18
  def new
19
- ActiveElement::DefaultController.new(controller: self).new
19
+ ActiveElement::DefaultController::Controller.new(controller: self).new
20
20
  end
21
21
 
22
22
  def create
23
- ActiveElement::DefaultController.new(controller: self).create
23
+ ActiveElement::DefaultController::Controller.new(controller: self).create
24
24
  end
25
25
 
26
26
  def edit
27
- ActiveElement::DefaultController.new(controller: self).edit
27
+ ActiveElement::DefaultController::Controller.new(controller: self).edit
28
28
  end
29
29
 
30
30
  def update
31
- ActiveElement::DefaultController.new(controller: self).update
31
+ ActiveElement::DefaultController::Controller.new(controller: self).update
32
32
  end
33
33
 
34
34
  def destroy
35
- ActiveElement::DefaultController.new(controller: self).destroy
35
+ ActiveElement::DefaultController::Controller.new(controller: self).destroy
36
36
  end
37
37
  end
38
38
  end
@@ -1,9 +1,10 @@
1
1
  <a data-modal-id="<%= "#json-modal-#{field_id}" %>"
2
+ id="<%= "json-view-modal-trigger-#{field_id}" %>"
2
3
  data-json-modal-link="true"
3
4
  data-bs-toggle="modal"
4
5
  data-bs-target="#json-modal-<%= field_id %>"
5
- class="text-decoration-none"
6
- href="#">Inspect JSON <i class="fa-solid fa-magnifying-glass"></i></a>
6
+ class="text-decoration-none text-nowrap"
7
+ href="#">JSON <i class="fa-solid fa-magnifying-glass"></i></a>
7
8
  <div id="json-modal-<%= field_id %>" class="modal fade"
8
9
  tabindex="-1"
9
10
  aria-hidden="true">
@@ -6,7 +6,8 @@
6
6
  locals: { form_id: id, form: form, field: field, options: options, component: component } %>
7
7
  <% elsif type == :json_field %>
8
8
  <%= render partial: 'active_element/components/form/json',
9
- locals: { form_id: id, form: form, field: field, field_id: ActiveElement.element_id, options: options, component: component } %>
9
+ locals: { form_id: id, form: form, field: field, field_id: "#{id}-json-field-#{field}",
10
+ options: options, component: component } %>
10
11
  <% elsif type == :text_search_field %>
11
12
  <%= render partial: 'active_element/components/form/text_search',
12
13
  locals: { form_id: id, form: form, field: field, options: options, component: component } %>
@@ -4,6 +4,8 @@
4
4
  data-form-id="<%= form_id %>"
5
5
  data-field-id="<%= field_id %>"
6
6
  data-schema-field-id="<%= field_id %>-schema"
7
+ data-json-view-modal-id="<%= "json-modal-#{form_id}-#{field}-json-view" %>"
8
+ data-json-view-modal-trigger-id="<%= "json-view-modal-trigger-#{form_id}-#{field}-json-view" %>"
7
9
  >
8
10
 
9
11
  </div>
@@ -26,3 +26,10 @@
26
26
  <%= render partial: 'active_element/components/form/option_groups_summary',
27
27
  locals: { option_groups: options[:option_groups] } %>
28
28
  <% end %>
29
+
30
+ <% if type == :json_field %>
31
+ <div>
32
+ <%= render partial: 'active_element/components/fields/json',
33
+ locals: { value: component.value_for(field), field_id: "#{id}-#{field}-json-view" } %>
34
+ </div>
35
+ <% end %>
@@ -1,5 +1,5 @@
1
1
  <% if destroy %>
2
- <div class="container w-100 text-end">
2
+ <div class="container me-0 w-100 text-end">
3
3
  <%= active_element.component.destroy_button(record) %>
4
4
  </div>
5
5
  <% end %>
@@ -51,7 +51,7 @@
51
51
  <% field_group.each do |field, type, options| %>
52
52
  <div class="col-sm-3">
53
53
  <%= render partial: 'active_element/components/form/label',
54
- locals: { type: type, form: form, field: field, options: options } %>
54
+ locals: { component: component, id: id, type: type, form: form, field: field, options: options } %>
55
55
  </div>
56
56
 
57
57
 
@@ -2,6 +2,13 @@
2
2
  <head>
3
3
  <%= render_active_element_hook 'active_element/before_head' %>
4
4
 
5
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
6
+ <link rel="stylesheet"
7
+ href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" integrity="sha512-fD9DI5bZwQxOi7MhYWnnNPlvXdp/2Pj3XSTRrFs5FQa4mizyGLnJcN6tuvUS6LbmgN1ut+XGSABKvjN0H6Aoow==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js" integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
11
+
5
12
  <style>
6
13
  <%= Rouge::Theme.find('tulip').render(scope: '.json-highlight') %>
7
14
  .json-highlight .p {
@@ -12,14 +19,28 @@
12
19
  color: #6b7399
13
20
  }
14
21
 
22
+ .json-highlight .kc, .json-highlight .c {
23
+ color: #695;
24
+ }
25
+
15
26
  .json-highlight {
16
27
  background-color: transparent;
17
28
  }
18
- </style>
19
29
 
20
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
21
- <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" integrity="sha512-fD9DI5bZwQxOi7MhYWnnNPlvXdp/2Pj3XSTRrFs5FQa4mizyGLnJcN6tuvUS6LbmgN1ut+XGSABKvjN0H6Aoow==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
22
- <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js" integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
30
+ .hljs-punctuation {
31
+ color: #7e4b6f;
32
+ }
33
+
34
+ .hljs-attr {
35
+ color: #9f93e6;
36
+ font-weight: bold;
37
+ }
38
+
39
+ .hljs-string {
40
+ color: #6b7399;
41
+ font-weight: bold;
42
+ }
43
+ </style>
23
44
 
24
45
  <script>
25
46
  window.ActiveElement = window.ActiveElement || {};
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_element (0.0.12)
4
+ active_element (0.0.13)
5
5
  bootstrap (~> 5.3.0alpha3)
6
6
  kaminari (~> 1.2)
7
7
  paintbrush (~> 0.1.2)
@@ -81,14 +81,7 @@ module ActiveElement
81
81
  end
82
82
 
83
83
  def schema_for(field, options)
84
- options.key?(:schema) ? options.fetch(:schema) : schema_from_yaml(field)
85
- end
86
-
87
- def schema_from_yaml(field)
88
- YAML.safe_load(
89
- Rails.root.join("config/forms/#{record.class.name.underscore}/#{field}.yml").read,
90
- symbolize_names: true
91
- )
84
+ options.key?(:schema) ? options.fetch(:schema) : Util.json_schema(model: record.class, field: field)
92
85
  end
93
86
 
94
87
  def display_value_for_select(field, options)
@@ -16,8 +16,6 @@ module ActiveElement
16
16
  end
17
17
 
18
18
  def json_value
19
- return ActiveElement.json_pretty_print(value_from_record) unless component.is_a?(CollectionTable)
20
-
21
19
  component.controller.render_to_string(
22
20
  partial: 'active_element/components/fields/json',
23
21
  locals: { value: value_from_record, field_id: ActiveElement.element_id }
@@ -136,8 +136,9 @@ module ActiveElement
136
136
  end
137
137
 
138
138
  def searchable_fields(field)
139
+ # FIXME: Use database column type to only include strings/numbers.
139
140
  (Util.relation_controller(model, controller, field)&.active_element&.state&.searchable_fields || [])
140
- .reject { |field| field.to_s.end_with?('_at') } # FIXME: Select strings/numbers only.
141
+ .reject { |searchable_field| searchable_field.to_s.end_with?('_at') }
141
142
  end
142
143
 
143
144
  def relation_primary_key(field)
@@ -41,6 +41,13 @@ module ActiveElement
41
41
  "#{namespace.classify}::#{base}".safe_constantize || base.safe_constantize
42
42
  end
43
43
 
44
+ def self.json_schema(model:, field:)
45
+ YAML.safe_load(
46
+ Rails.root.join("config/forms/#{model.name.underscore}/#{field}.yml").read,
47
+ symbolize_names: true
48
+ )
49
+ end
50
+
44
51
  def self.json_pretty_print(json)
45
52
  formatter = Rouge::Formatters::HTML.new
46
53
  lexer = Rouge::Lexers::JSON.new
@@ -25,7 +25,8 @@ module ActiveElement
25
25
  @authorize
26
26
  end
27
27
 
28
- def listable_fields(*args)
28
+ def listable_fields(*args, order: nil)
29
+ state.list_order = order
29
30
  state.listable_fields.concat(args.map(&:to_sym)).uniq!
30
31
  end
31
32
 
@@ -7,7 +7,7 @@ module ActiveElement
7
7
  class ControllerState
8
8
  attr_reader :permissions, :listable_fields, :viewable_fields, :editable_fields, :searchable_fields
9
9
  attr_accessor :sign_in_path, :sign_in, :sign_in_method, :sign_out_path, :sign_out_method,
10
- :deletable, :authorizor, :authenticator
10
+ :deletable, :authorizor, :authenticator, :list_order
11
11
 
12
12
  def initialize(controller:)
13
13
  @controller = controller
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ # TODO: Move each default controller action into individual classes inside
3
+ # ActiveElement::DefaultController::Actions
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module DefaultController
5
+ # Encapsulation of all logic performed for default controller actions when no action is defined
6
+ # by the current controller.
7
+ class Controller # rubocop:disable Metrics/ClassLength
8
+ def initialize(controller:)
9
+ @controller = controller
10
+ end
11
+
12
+ def index
13
+ return render_forbidden(:listable) unless configured?(:listable)
14
+
15
+ controller.render 'active_element/default_views/index',
16
+ locals: {
17
+ collection: ordered(collection),
18
+ search_filters: default_text_search.search_filters
19
+ }
20
+ end
21
+
22
+ def show
23
+ return render_forbidden(:viewable) unless configured?(:viewable)
24
+
25
+ controller.render 'active_element/default_views/show', locals: { record: record }
26
+ end
27
+
28
+ def new
29
+ return render_forbidden(:editable) unless configured?(:editable)
30
+
31
+ controller.render 'active_element/default_views/new', locals: { record: model.new, namespace: namespace }
32
+ end
33
+
34
+ def create # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
35
+ return render_forbidden(:editable) unless configured?(:editable)
36
+
37
+ new_record = model.new(default_record_params.params)
38
+ # XXX: Ensure associations are applied - there must be a better way.
39
+ if new_record.save && new_record.reload.update(default_record_params.params)
40
+ controller.flash.notice = "#{new_record.model_name.to_s.titleize} created successfully."
41
+ controller.redirect_to record_path(new_record, :show).path
42
+ else
43
+ controller.flash.now.alert = "Failed to create #{model.name.to_s.titleize}."
44
+ controller.render 'active_element/default_views/new', locals: { record: new_record, namespace: namespace }
45
+ end
46
+ rescue ActiveRecord::RangeError => e
47
+ render_range_error(error: e, action: :new)
48
+ end
49
+
50
+ def edit
51
+ return render_forbidden(:editable) unless configured?(:editable)
52
+
53
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
54
+ end
55
+
56
+ def update # rubocop:disable Metrics/AbcSize
57
+ return render_forbidden(:editable) unless configured?(:editable)
58
+
59
+ if record.update(default_record_params.params)
60
+ controller.flash.notice = "#{record.model_name.to_s.titleize} updated successfully."
61
+ controller.redirect_to record_path(record, :show).path
62
+ else
63
+ controller.flash.now.alert = "Failed to update #{model.name.to_s.titleize}."
64
+ controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
65
+ end
66
+ rescue ActiveRecord::RangeError => e
67
+ render_range_error(error: e, action: :edit)
68
+ end
69
+
70
+ def destroy
71
+ return render_forbidden(:deletable) unless configured?(:deletable)
72
+
73
+ record.destroy
74
+ controller.flash.notice = "Deleted #{record.model_name.to_s.titleize}."
75
+ controller.redirect_to record_path(model, :index).path
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :controller
81
+
82
+ def ordered(unordered_collection)
83
+ return unordered_collection if state.list_order.blank?
84
+
85
+ unordered_collection.order(state.list_order)
86
+ end
87
+
88
+ def render_forbidden(type)
89
+ controller.render 'active_element/default_views/forbidden', locals: { type: type }
90
+ end
91
+
92
+ def configured?(type)
93
+ return state.deletable? if type == :deletable
94
+
95
+ state.public_send("#{type}_fields").present?
96
+ end
97
+
98
+ def state
99
+ @state ||= controller.active_element.state
100
+ end
101
+
102
+ def default_record_params
103
+ @default_record_params ||= DefaultController::Params.new(controller: controller, model: model)
104
+ end
105
+
106
+ def default_text_search
107
+ @default_text_search ||= DefaultController::Search.new(controller: controller, model: model)
108
+ end
109
+
110
+ def record_path(record, type = nil)
111
+ ActiveElement::Components::Util::RecordPath.new(record: record, controller: controller, type: type)
112
+ end
113
+
114
+ def namespace
115
+ controller.controller_path.rpartition('/').first.presence&.to_sym
116
+ end
117
+
118
+ def model
119
+ controller.controller_name.classify.constantize
120
+ end
121
+
122
+ def record
123
+ @record ||= model.find(controller.params[:id])
124
+ end
125
+
126
+ def collection
127
+ return model.all unless default_text_search.text_search?
128
+
129
+ model.left_outer_joins(default_text_search.search_relations).where(*default_text_search.text_search)
130
+ end
131
+
132
+ def render_range_error(error:, action:)
133
+ controller.flash.now.alert = formatted_error(error)
134
+ controller.render "active_element/default_views/#{action}", locals: { record: record, namespace: namespace }
135
+ end
136
+
137
+ def formatted_error(error)
138
+ return error.cause.message.split("\n").join(', ') if error.try(:cause)&.try(:message).present?
139
+ return error.message if error.try(:message).present?
140
+
141
+ I18n.t('active_element.unexpected_error')
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module DefaultController
5
+ # Provides permitted parameters for fields generated from a JSON schema file.
6
+ class JsonParams
7
+ def initialize(schema:)
8
+ @base_schema = schema
9
+ end
10
+
11
+ def params(schema = base_schema)
12
+ return simple_object_field(schema) if simple_object_field?(schema)
13
+ return simple_array_field(schema) if simple_array_field?(schema)
14
+ return complex_array_field(schema) if complex_array_field?(schema)
15
+
16
+ schema[:name]
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :fields, :base_schema
22
+
23
+ def simple_object_field(schema)
24
+ schema.key?(:name) ? { schema[:name] => {} } : {}
25
+ end
26
+
27
+ def simple_array_field(schema)
28
+ schema.key?(:name) ? { schema[:name] => [] } : []
29
+ end
30
+
31
+ def simple_object_field?(schema)
32
+ schema[:type] == 'object'
33
+ end
34
+
35
+ def simple_array_field?(schema)
36
+ schema[:type] == 'array' && schema.dig(:shape, :type) != 'object'
37
+ end
38
+
39
+ def complex_array_field?(schema)
40
+ schema[:type] == 'array' && schema.dig(:shape, :type) == 'object'
41
+ end
42
+
43
+ def complex_array_field(schema)
44
+ schema.dig(:shape, :shape, :fields).map { |field| params(field) }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module DefaultController
5
+ # Provides params for ActiveRecord models when using the default boilerplate controller
6
+ # actions. Navigates input parameters and maps them to appropriate relations as needed.
7
+ class Params
8
+ def initialize(controller:, model:)
9
+ @controller = controller
10
+ @model = model
11
+ end
12
+
13
+ def params
14
+ with_transformed_relations(
15
+ controller.params.require(controller.controller_name.singularize)
16
+ .permit(*permitted_fields)
17
+ )
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :controller, :model
23
+
24
+ def with_transformed_relations(params)
25
+ params.to_h.to_h do |key, value|
26
+ next [key, value] unless relation?(key)
27
+
28
+ relation_param(key, value)
29
+ end
30
+ end
31
+
32
+ def permitted_fields
33
+ scalar, json = controller.active_element.state.editable_fields.partition do |field|
34
+ scalar?(field)
35
+ end
36
+ scalar + [json_params(json)]
37
+ end
38
+
39
+ def scalar?(field)
40
+ return true if relation?(field)
41
+ return true if %i[json jsonb].exclude?(column(field)&.type)
42
+
43
+ false
44
+ end
45
+
46
+ def json_params(fields)
47
+ # XXX: We assume all non-scalar fields are JSON fields, i.e. they must have a definition
48
+ # defined as `config/forms/<model>/<field>.yml`. If that file does not exist, allow
49
+ # Errno::ENOENT to raise to let the form submission fail and avoid losing data. This
50
+ # would need to be adjusted if we start allowing non-JSON nested fields in the default
51
+ # controller.
52
+ fields.index_with do |field|
53
+ DefaultController::JsonParams.new(schema: schema_for(field)).params
54
+ end
55
+ end
56
+
57
+ def schema_for(field)
58
+ ActiveElement::Components::Util.json_schema(model: model, field: field)
59
+ end
60
+
61
+ def relation_param(key, value)
62
+ case relation(key).macro
63
+ when :belongs_to
64
+ belongs_to_param(key, value)
65
+ when :has_one
66
+ has_one_param(key, value)
67
+ when :has_many
68
+ has_many_param(key, value)
69
+ end
70
+ end
71
+
72
+ def belongs_to_param(key, value)
73
+ [relation(key).foreign_key, value]
74
+ end
75
+
76
+ def has_one_param(key, value) # rubocop:disable Naming/PredicateName
77
+ [relation(key).name, relation(key).klass.find_by(relation(key).klass.primary_key => value)]
78
+ end
79
+
80
+ def has_many_param(key, _value) # rubocop:disable Naming/PredicateName
81
+ [relation(key).name, relation(key).klass.where(relation(key).klass.primary_key => relation(key).value)]
82
+ end
83
+
84
+ def relation?(attribute)
85
+ relation(attribute.to_sym).present?
86
+ end
87
+
88
+ def relation(attribute)
89
+ model.reflect_on_association(attribute.to_sym)
90
+ end
91
+
92
+ def column(attribute)
93
+ model.columns.find { |column| column.name.to_s == attribute.to_s }
94
+ end
95
+ end
96
+ end
97
+ end