active_element 0.0.11 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/Gemfile +2 -1
  4. data/Gemfile.lock +10 -3
  5. data/app/assets/javascripts/active_element/highlight.js +311 -0
  6. data/app/assets/javascripts/active_element/json_field.js +51 -20
  7. data/app/assets/javascripts/active_element/popover.js +6 -4
  8. data/app/assets/javascripts/active_element/text_search_field.js +13 -1
  9. data/app/assets/stylesheets/active_element/_dark.scss +1 -1
  10. data/app/assets/stylesheets/active_element/application.scss +39 -1
  11. data/app/controllers/concerns/active_element/default_controller_actions.rb +7 -7
  12. data/app/views/active_element/components/fields/_json.html.erb +3 -2
  13. data/app/views/active_element/components/form/_field.html.erb +2 -1
  14. data/app/views/active_element/components/form/_json.html.erb +2 -0
  15. data/app/views/active_element/components/form/_label.html.erb +8 -1
  16. data/app/views/active_element/components/form/_templates.html.erb +8 -5
  17. data/app/views/active_element/components/form/_text_search.html.erb +1 -1
  18. data/app/views/active_element/components/form.html.erb +2 -2
  19. data/app/views/active_element/components/table/_field.html.erb +1 -1
  20. data/app/views/active_element/components/table/_ungrouped_collection.html.erb +1 -0
  21. data/app/views/active_element/components/table/item.html.erb +1 -0
  22. data/app/views/active_element/default_views/edit.html.erb +2 -2
  23. data/app/views/active_element/default_views/forbidden.html.erb +7 -0
  24. data/app/views/active_element/default_views/index.html.erb +7 -7
  25. data/app/views/active_element/default_views/new.html.erb +1 -1
  26. data/app/views/active_element/default_views/show.html.erb +3 -3
  27. data/app/views/layouts/active_element.html.erb +25 -4
  28. data/config/locales/en.yml +3 -0
  29. data/example_app/Gemfile.lock +1 -1
  30. data/example_app/app/controllers/pets_controller.rb +1 -0
  31. data/example_app/app/controllers/users_controller.rb +1 -0
  32. data/lib/active_element/components/form.rb +1 -8
  33. data/lib/active_element/components/text_search.rb +10 -1
  34. data/lib/active_element/components/util/display_value_mapping.rb +0 -2
  35. data/lib/active_element/components/util/form_field_mapping.rb +23 -10
  36. data/lib/active_element/components/util/numeric_field.rb +73 -0
  37. data/lib/active_element/components/util.rb +8 -0
  38. data/lib/active_element/controller_interface.rb +27 -30
  39. data/lib/active_element/controller_state.rb +44 -0
  40. data/lib/active_element/default_controller/actions.rb +3 -0
  41. data/lib/active_element/default_controller/controller.rb +145 -0
  42. data/lib/active_element/default_controller/json_params.rb +48 -0
  43. data/lib/active_element/default_controller/params.rb +97 -0
  44. data/lib/active_element/default_controller/search.rb +112 -0
  45. data/lib/active_element/default_controller.rb +10 -88
  46. data/lib/active_element/version.rb +1 -1
  47. data/lib/active_element.rb +1 -2
  48. data/rspec-documentation/_head.html.erb +2 -0
  49. data/rspec-documentation/pages/000-Introduction.md +8 -5
  50. data/rspec-documentation/pages/005-Setup.md +21 -28
  51. data/rspec-documentation/pages/010-Components/Form Fields.md +35 -0
  52. data/rspec-documentation/pages/015-Custom Controllers.md +32 -0
  53. data/rspec-documentation/pages/016-Default Controller.md +132 -0
  54. data/rspec-documentation/pages/Themes.md +3 -0
  55. metadata +15 -4
  56. data/lib/active_element/default_record_params.rb +0 -62
  57. data/lib/active_element/default_text_search.rb +0 -110
@@ -71,7 +71,16 @@
71
71
  const token = ActiveElement.getAntiCsrfToken();
72
72
  const responseErrorContainer = cloneElement('response-error');
73
73
  const searchResultsContainer = cloneElement('results');
74
- const spinner = cloneElement('spinner');
74
+ const icons = cloneElement('icons');
75
+ const spinner = icons.querySelector('[data-item-class="spinner"]');
76
+ const clearButton = icons.querySelector('[data-item-class="clear"]');
77
+
78
+ if (element.value) clearButton.classList.remove('invisible');
79
+ clearButton.addEventListener('click', () => {
80
+ element.value = '';
81
+ hiddenInput.value = '';
82
+ clearButton.classList.add('invisible');
83
+ });
75
84
 
76
85
  document.addEventListener('click', () => {
77
86
  searchResultsContainer.classList.add('d-none');
@@ -79,6 +88,7 @@
79
88
 
80
89
  element.addEventListener('change', () => {
81
90
  hiddenInput.value = element.value;
91
+ if (element.value) clearButton.classList.remove('invisible');
82
92
  });
83
93
 
84
94
  element.addEventListener('keyup', () => {
@@ -87,6 +97,7 @@
87
97
  lastRequestId = requestId;
88
98
 
89
99
  spinner.classList.remove('invisible');
100
+ if (element.value) clearButton.classList.remove('invisible');
90
101
  searchResultsContainer.classList.add('d-none');
91
102
 
92
103
  if (!query || query.length < 3) {
@@ -119,6 +130,7 @@
119
130
 
120
131
  form.append(hiddenInput);
121
132
  element.parentElement.append(searchResultsContainer);
133
+ element.parentElement.append(clearButton);
122
134
  element.parentElement.append(spinner);
123
135
  element.parentElement.append(responseErrorContainer);
124
136
  });
@@ -25,7 +25,7 @@
25
25
 
26
26
  .json-field {
27
27
  .form-group {
28
- background-color: #58575755;
28
+ background-color: #5857571f;
29
29
  }
30
30
  }
31
31
 
@@ -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>
@@ -6,7 +6,7 @@
6
6
  data-bs-trigger="focus"
7
7
  data-bs-toggle="popover"
8
8
  title="Required"
9
- data-bs-content="<%= "#{options.fetch(:label)} is a required field." %>"
9
+ data-bs-content="<%= "#{options.fetch(:label)} is a required field." %>">
10
10
  <i class="text-secondary fa-solid fa-star-of-life"></i>
11
11
  </button>
12
12
  <% end %>
@@ -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 %>
@@ -18,8 +18,8 @@
18
18
  <input id="json-time-field-template" type="time" class="form-control m-1 d-inline-block json-time-field" />
19
19
  <input id="json-datetime-field-template" type="datetime-local" class="form-control m-1 d-inline-block json-datetime-field" />
20
20
  <input id="json-integer-field-template" type="number" class="form-control m-1 json-integer-field" />
21
- <input id="json-float-field-template" type="number" class="form-control m-1 json-float-field" />
22
- <input id="json-decimal-field-template" type="number" class="form-control m-1 json-decimal-field" />
21
+ <input id="json-float-field-template" type="number" step="any" class="form-control m-1 json-float-field" />
22
+ <input id="json-decimal-field-template" type="number" step="any" class="form-control m-1 json-decimal-field" />
23
23
 
24
24
  <select id="json-select-template" class="form-select m-1 json-select-field"></select>
25
25
 
@@ -65,9 +65,12 @@
65
65
  <p id="form-search-field-response-error-template" class="text-danger validation-error-message pt-1 m-0"></p>
66
66
  <div id="form-search-field-results-template" class="search-field-results d-none border border-top-0"></div>
67
67
  <div id="form-search-field-results-item-template" class="search-field-result p-2"></div>
68
- <div id="form-search-field-spinner-template" class="invisible text-end">
69
- <div style="position: relative; float: right; width: auto; right: 0.5rem; top: -1.7rem;">
70
- <i class="fa-solid fa-spinner fa-spin"></i>
68
+ <div id="form-search-field-icons-template" class="text-end">
69
+ <div data-item-class="spinner" class="invisible" style="position: relative; float: right; width: auto; right: 0.5rem; top: -1.7rem;">
70
+ <i class="fa-fw fa-solid fa-spinner fa-spin"></i>
71
+ </div>
72
+ <div data-item-class="clear" class="invisible" style="position: relative; float: right; width: auto; right: 0.5rem; top: -1.7rem; cursor: pointer;">
73
+ <i class="fa-solid fa-fw fa-xmark"></i>
71
74
  </div>
72
75
  </div>
73
76
  </div>
@@ -3,7 +3,7 @@
3
3
  id: "#{form_id}-#{field}-text-search",
4
4
  class: "form-control #{component.valid?(field) ? nil : 'is-invalid'}",
5
5
  autocomplete: 'off',
6
- placeholder: options[:placeholder].presence || 'Search...',
6
+ placeholder: options[:placeholder].presence || "Search...",
7
7
  tabindex: component.tabindex,
8
8
  data: {
9
9
  field_type: 'text-search',
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  <% if value.is_a?(Array) %>
2
- <% value.each_with_index do |each_value, index| %>
2
+ <% value.sort.each_with_index do |each_value, index| %>
3
3
  <%= each_value %>
4
4
  <%= index < value.size - 1 ? '|' : nil %>
5
5
  <% end %>
@@ -8,6 +8,7 @@
8
8
  <button type="button"
9
9
  style="background: none; border: none; outline: 0; position: absolute; margin-top: 0.3rem"
10
10
  data-bs-toggle="popover"
11
+ data-bs-trigger="focus"
11
12
  data-bs-content="<%= options[:description] %>">
12
13
  <i class="text-secondary fa-solid fa-circle-info"></i>
13
14
  </button>
@@ -20,6 +20,7 @@
20
20
  <button type="button"
21
21
  style="background: none; border: none; outline: 0; position: absolute; margin-top: 0.3rem"
22
22
  data-bs-toggle="popover"
23
+ data-bs-trigger="focus"
23
24
  data-bs-content="<%= options[:description] %>">
24
25
  <i class="text-secondary fa-solid fa-circle-info"></i>
25
26
  </button>
@@ -1,5 +1,5 @@
1
1
  <%= active_element.component.page_title record.model_name.to_s.titleize %>
2
2
 
3
3
  <%= active_element.component.form model: [namespace, record].compact,
4
- destroy: true,
5
- fields: active_element.state.fetch(:editable_fields, []) %>
4
+ destroy: active_element.state.deletable?,
5
+ fields: active_element.state.editable_fields %>
@@ -0,0 +1,7 @@
1
+ <h1>Forbidden</h1>
2
+
3
+ <h2 class="text-danger">Access to this resource has not been configured</h2>
4
+
5
+ <p>The <span class="text-primary font-monospace"><%= type %></span> resource for <span class="text-primary font-monospace"><%= controller_name.titleize %></span> has not been configured, please contact your administrator.</p>
6
+
7
+ <hr/>
@@ -1,15 +1,15 @@
1
- <% if active_element.state.key?(:searchable_fields) %>
1
+ <% if active_element.state.searchable_fields.present? %>
2
2
  <%= active_element.component.form title: 'Search Filters',
3
3
  submit: 'Search',
4
4
  modal: true,
5
5
  search: true,
6
6
  item: search_filters,
7
- fields: active_element.state.fetch(:searchable_fields) %>
7
+ fields: active_element.state.searchable_fields %>
8
8
  <% end %>
9
9
 
10
- <%= active_element.component.table new: true,
11
- show: true,
12
- edit: true,
13
- destroy: true,
10
+ <%= active_element.component.table new: active_element.state.creatable?,
11
+ show: active_element.state.viewable?,
12
+ edit: active_element.state.editable?,
13
+ destroy: active_element.state.deletable?,
14
14
  collection: collection,
15
- fields: active_element.state.fetch(:listable_fields, []) %>
15
+ fields: active_element.state.listable_fields %>
@@ -1,4 +1,4 @@
1
1
  <%= active_element.component.page_title record.model_name.to_s.titleize %>
2
2
 
3
3
  <%= active_element.component.form model: [namespace, record].compact,
4
- fields: active_element.state.fetch(:editable_fields, []) %>
4
+ fields: active_element.state.editable_fields %>
@@ -1,7 +1,7 @@
1
1
  <%= active_element.component.page_title record.model_name.to_s.titleize %>
2
2
 
3
3
  <%= active_element.component.table item: record,
4
- edit: true,
5
- destroy: true,
6
- fields: active_element.state.fetch(:viewable_fields, []) %>
4
+ edit: active_element.state.editable?,
5
+ destroy: active_element.state.deletable?,
6
+ fields: active_element.state.viewable_fields %>
7
7
 
@@ -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 || {};
@@ -0,0 +1,3 @@
1
+ en:
2
+ active_element:
3
+ unexpected_error: 'Unexpected error'
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_element (0.0.11)
4
+ active_element (0.0.13)
5
5
  bootstrap (~> 5.3.0alpha3)
6
6
  kaminari (~> 1.2)
7
7
  paintbrush (~> 0.1.2)
@@ -3,4 +3,5 @@ class PetsController < ApplicationController
3
3
  active_element.viewable_fields :name, :animal, :owner, :created_at, :updated_at
4
4
  active_element.editable_fields :name, :animal, :owner
5
5
  active_element.searchable_fields :name, :animal, :owner, :created_at, :updated_at
6
+ active_element.deletable
6
7
  end
@@ -3,4 +3,5 @@ class UsersController < ApplicationController
3
3
  active_element.viewable_fields :name, :email, :created_at, :updated_at, :pets
4
4
  active_element.listable_fields :name, :email, :created_at, :updated_at
5
5
  active_element.searchable_fields :name, :email, :created_at, :updated_at
6
+ active_element.deletable
6
7
  end
@@ -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)
@@ -19,7 +19,16 @@ module ActiveElement
19
19
  end
20
20
 
21
21
  def text_search_options(model:, with:, providing:)
22
- { search: { model: model.name.underscore, with: with, providing: providing } }
22
+ {
23
+ search: { model: model.name.underscore, with: with, providing: providing },
24
+ placeholder: "Search for #{model.name.titleize} by #{humanized_names(with).join(', ')}..."
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def humanized_names(names)
31
+ Array(names).compact.map.map(&:to_s).map(&:humanize)
23
32
  end
24
33
  end
25
34
  end
@@ -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 }
@@ -127,21 +127,22 @@ module ActiveElement
127
127
  end
128
128
 
129
129
  def relation_text_search_field(field)
130
- relation_model = relation(field).klass
131
- record.public_send(field)
132
130
  [field, :text_search_field,
133
131
  TextSearch.text_search_options(
134
- model: relation_model,
132
+ model: relation(field).klass,
135
133
  with: searchable_fields(field),
136
- providing: relation_model.primary_key
137
- ).merge({ display_value: association_mapping(field).display_value })]
134
+ providing: relation(field).klass.primary_key
135
+ ).merge({ display_value: association_mapping(field).display_value, label: i18n.label(field) })]
138
136
  end
139
137
 
140
138
  def searchable_fields(field)
141
- Util.relation_controller(model, controller, field)
142
- .active_element
143
- .state
144
- .fetch(:searchable_fields, [])
139
+ # FIXME: Use database column type to only include strings/numbers.
140
+ (Util.relation_controller(model, controller, field)&.active_element&.state&.searchable_fields || [])
141
+ .reject { |searchable_field| searchable_field.to_s.end_with?('_at') }
142
+ end
143
+
144
+ def relation_primary_key(field)
145
+ relation(field).options.fetch(:primary_key) { relation_model.primary_key }
145
146
  end
146
147
 
147
148
  def default_type_from_column_type(field, column_type) # rubocop:disable Metrics/MethodLength
@@ -204,7 +205,9 @@ module ActiveElement
204
205
  end
205
206
 
206
207
  def default_options(field)
207
- { required: required?(field) }
208
+ {
209
+ required: required?(field)
210
+ }.merge(field_options(field))
208
211
  end
209
212
 
210
213
  def required?(field)
@@ -215,6 +218,16 @@ module ActiveElement
215
218
  validator.kind == :presence && validator.attributes.include?(field.to_sym)
216
219
  end
217
220
  end
221
+
222
+ def field_options(field)
223
+ return NumericField.new(field: field, column: column(field)).options if numeric?(field)
224
+
225
+ {}
226
+ end
227
+
228
+ def numeric?(field)
229
+ %i[float decimal integer].include?(column(field)&.type)
230
+ end
218
231
  end
219
232
  end
220
233
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ module Util
6
+ # Provides options for a `<input type="number" />` element based on database column properties.
7
+ class NumericField
8
+ def initialize(field:, column:)
9
+ @field = field
10
+ @column = column
11
+ end
12
+
13
+ def options
14
+ {
15
+ step: step,
16
+ min: min,
17
+ max: max
18
+ }.compact
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :field, :column
24
+
25
+ def step
26
+ return 'any' if column.blank?
27
+ return '1' if column.type == :integer
28
+ return 'any' if column.try(:scale).blank?
29
+
30
+ "0.#{'1'.rjust(column.scale, '0')}"
31
+ end
32
+
33
+ def min
34
+ return min_decimal if column.try(:precision).present?
35
+ return min_integer if column.try(:limit).present?
36
+
37
+ nil
38
+ end
39
+
40
+ def max
41
+ return max_decimal if column.try(:precision).present?
42
+ return max_integer if column.try(:limit).present?
43
+
44
+ nil
45
+ end
46
+
47
+ # XXX: This is the theoretical maximum value for a column with a given precision but,
48
+ # since the maximum database value is constrained by total significant figures (i.e.
49
+ # before and after the decimal point), an input can still be provided that would cause an
50
+ # error, so the default controller rescues `ActiveRecord::RangeError` to deal with this.
51
+ def max_decimal
52
+ '9' * column.precision
53
+ end
54
+
55
+ def min_decimal
56
+ "-#{'9' * column.precision}"
57
+ end
58
+
59
+ # `limit` represents available bytes for storing a signed integer e.g.
60
+ # 2**(8 * 8) / 2 - 1 == 9223372036854775807
61
+ # which matches PostgreSQL's `bigint` max value:
62
+ # https://www.postgresql.org/docs/current/datatype-numeric.html
63
+ def max_integer
64
+ ((2**(column.limit * 8)) / 2) - 1
65
+ end
66
+
67
+ def min_integer
68
+ -2**(column.limit * 8) / 2
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -9,6 +9,7 @@ require_relative 'util/form_value_mapping'
9
9
  require_relative 'util/display_value_mapping'
10
10
  require_relative 'util/association_mapping'
11
11
  require_relative 'util/decorator'
12
+ require_relative 'util/numeric_field'
12
13
 
13
14
  module ActiveElement
14
15
  module Components
@@ -40,6 +41,13 @@ module ActiveElement
40
41
  "#{namespace.classify}::#{base}".safe_constantize || base.safe_constantize
41
42
  end
42
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
+
43
51
  def self.json_pretty_print(json)
44
52
  formatter = Rouge::Formatters::HTML.new
45
53
  lexer = Rouge::Lexers::JSON.new