active_element 0.0.11 → 0.0.13

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