active_element 0.0.12 → 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
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