active_element 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -1
  3. data/Gemfile +1 -0
  4. data/Gemfile.lock +8 -1
  5. data/app/assets/javascripts/active_element/text_search_field.js +13 -1
  6. data/app/views/active_element/components/form/_label.html.erb +1 -1
  7. data/app/views/active_element/components/form/_templates.html.erb +8 -5
  8. data/app/views/active_element/components/form/_text_search.html.erb +1 -1
  9. data/app/views/active_element/components/table/_field.html.erb +1 -1
  10. data/app/views/active_element/components/table/_ungrouped_collection.html.erb +1 -0
  11. data/app/views/active_element/components/table/item.html.erb +1 -0
  12. data/app/views/active_element/default_views/edit.html.erb +2 -2
  13. data/app/views/active_element/default_views/forbidden.html.erb +7 -0
  14. data/app/views/active_element/default_views/index.html.erb +7 -7
  15. data/app/views/active_element/default_views/new.html.erb +1 -1
  16. data/app/views/active_element/default_views/show.html.erb +3 -3
  17. data/config/locales/en.yml +3 -0
  18. data/example_app/Gemfile.lock +1 -1
  19. data/example_app/app/controllers/pets_controller.rb +1 -0
  20. data/example_app/app/controllers/users_controller.rb +1 -0
  21. data/lib/active_element/components/text_search.rb +10 -1
  22. data/lib/active_element/components/util/form_field_mapping.rb +22 -10
  23. data/lib/active_element/components/util/numeric_field.rb +73 -0
  24. data/lib/active_element/components/util.rb +1 -0
  25. data/lib/active_element/controller_interface.rb +25 -29
  26. data/lib/active_element/controller_state.rb +44 -0
  27. data/lib/active_element/default_controller.rb +47 -3
  28. data/lib/active_element/default_record_params.rb +1 -1
  29. data/lib/active_element/{default_text_search.rb → default_search.rb} +6 -6
  30. data/lib/active_element/version.rb +1 -1
  31. data/lib/active_element.rb +2 -1
  32. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59c0c229f1a2c98d33fbcb36bfac256d0c41a03ae5149f2134736830eed704f5
4
- data.tar.gz: 25f487e038544884b13f224e7fbed2a831584b60b704900e5dae1f17082667c1
3
+ metadata.gz: b461a50c1dc5d99ecb2a98633424871c2ea25c99817cb0f269b0e55da9521732
4
+ data.tar.gz: 2d27482a5ffb5ea966c3f15dfa0e412ddb61b04cb63dbd628d272e95072d89c4
5
5
  SHA512:
6
- metadata.gz: ca8865cdf14f3f7aad1c7935661a31b61a8de6e97928262d6a39a468e195934958f766e87e021c110e7c23369aa15c61e304bf375fb3ed7adef89f4532d36962
7
- data.tar.gz: aaa94959dfaddb83889c62bc5899a109394a1688428186c12550b29fc21d013852deaa37d8167165e48e831a636ed58e50a90a88f71c9e77ca04595619435910
6
+ metadata.gz: d671afa4e3c6cea6eba9d3882bfbc3c21e2df90237d1afcd7c5c303fee433e8ce0a2a813402eeb79431ca2a33fcb737ed0879a0f3349b25ccba0b1b0763ec55b
7
+ data.tar.gz: 8e40f9498e58fd5430fa56290a5034c960dc5be81aa8b8e9e88fb790c138c7039a522e909a126bb6769d7cf680ce8937dfefa0e3c5942e5b1d541ac5b718d6ad
data/.rubocop.yml CHANGED
@@ -25,6 +25,6 @@ Style/SymbolArray:
25
25
  Exclude:
26
26
  - 'rspec-documentation/pages/**/*.md'
27
27
 
28
- Style/Style/WordArray:
28
+ Style/WordArray:
29
29
  Exclude:
30
30
  - 'rspec-documentation/pages/**/*.md'
data/Gemfile CHANGED
@@ -10,6 +10,7 @@ gem 'devise', '~> 4.9'
10
10
  gem 'devpack', '~> 0.4.1'
11
11
  gem 'factory_bot_rails', '~> 5.2'
12
12
  gem 'faker', '~> 2.23'
13
+ gem 'listen', '~> 3.8'
13
14
  gem 'rake', '~> 13.0'
14
15
  gem 'rspec', '~> 3.12'
15
16
  gem 'rspec-documentation', '~> 0.0.9'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_element (0.0.11)
4
+ active_element (0.0.12)
5
5
  bootstrap (~> 5.3.0alpha3)
6
6
  kaminari (~> 1.2)
7
7
  paintbrush (~> 0.1.2)
@@ -135,6 +135,9 @@ GEM
135
135
  rexml
136
136
  kramdown-parser-gfm (1.1.0)
137
137
  kramdown (~> 2.0)
138
+ listen (3.8.0)
139
+ rb-fsevent (~> 0.10, >= 0.10.3)
140
+ rb-inotify (~> 0.9, >= 0.9.10)
138
141
  loofah (2.21.3)
139
142
  crass (~> 1.0.2)
140
143
  nokogiri (>= 1.12.0)
@@ -203,6 +206,9 @@ GEM
203
206
  zeitwerk (~> 2.5)
204
207
  rainbow (3.1.1)
205
208
  rake (13.0.6)
209
+ rb-fsevent (0.11.2)
210
+ rb-inotify (0.10.1)
211
+ ffi (~> 1.0)
206
212
  redcarpet (3.6.0)
207
213
  regexp_parser (2.8.1)
208
214
  responders (3.1.0)
@@ -325,6 +331,7 @@ DEPENDENCIES
325
331
  devpack (~> 0.4.1)
326
332
  factory_bot_rails (~> 5.2)
327
333
  faker (~> 2.23)
334
+ listen (~> 3.8)
328
335
  rake (~> 13.0)
329
336
  rspec (~> 3.12)
330
337
  rspec-documentation (~> 0.0.9)
@@ -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
  });
@@ -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 %>
@@ -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 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
 
@@ -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.12)
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
@@ -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
@@ -127,21 +127,21 @@ 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
+ (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
+ end
142
+
143
+ def relation_primary_key(field)
144
+ relation(field).options.fetch(:primary_key) { relation_model.primary_key }
145
145
  end
146
146
 
147
147
  def default_type_from_column_type(field, column_type) # rubocop:disable Metrics/MethodLength
@@ -204,7 +204,9 @@ module ActiveElement
204
204
  end
205
205
 
206
206
  def default_options(field)
207
- { required: required?(field) }
207
+ {
208
+ required: required?(field)
209
+ }.merge(field_options(field))
208
210
  end
209
211
 
210
212
  def required?(field)
@@ -215,6 +217,16 @@ module ActiveElement
215
217
  validator.kind == :presence && validator.attributes.include?(field.to_sym)
216
218
  end
217
219
  end
220
+
221
+ def field_options(field)
222
+ return NumericField.new(field: field, column: column(field)).options if numeric?(field)
223
+
224
+ {}
225
+ end
226
+
227
+ def numeric?(field)
228
+ %i[float decimal integer].include?(column(field)&.type)
229
+ end
218
230
  end
219
231
  end
220
232
  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
@@ -16,7 +16,7 @@ module ActiveElement
16
16
  def initialize(controller_class, controller_instance = nil)
17
17
  @controller_class = controller_class
18
18
  @controller_instance = controller_instance
19
- initialize_state
19
+ initialize_state(controller_class)
20
20
  @missing_template_store = {}
21
21
  @authorize = false
22
22
  end
@@ -26,19 +26,23 @@ module ActiveElement
26
26
  end
27
27
 
28
28
  def listable_fields(*args)
29
- state[:listable_fields] = args.map(&:to_sym)
29
+ state.listable_fields.concat(args.map(&:to_sym)).uniq!
30
30
  end
31
31
 
32
32
  def viewable_fields(*args)
33
- state[:viewable_fields] = args.map(&:to_sym)
33
+ state.viewable_fields.concat(args.map(&:to_sym)).uniq!
34
34
  end
35
35
 
36
36
  def editable_fields(*args)
37
- state[:editable_fields] = args.map(&:to_sym)
37
+ state.editable_fields.concat(args.map(&:to_sym)).uniq!
38
38
  end
39
39
 
40
40
  def searchable_fields(*args)
41
- state[:searchable_fields] = args.map(&:to_sym)
41
+ state.searchable_fields.concat(args.map(&:to_sym)).uniq!
42
+ end
43
+
44
+ def deletable
45
+ state.deletable = true
42
46
  end
43
47
 
44
48
  def application_name
@@ -46,43 +50,39 @@ module ActiveElement
46
50
  end
47
51
 
48
52
  def authenticate_with(&block)
49
- state[:authenticator] = block
53
+ state.authenticator = block
50
54
  end
51
55
 
52
56
  def authorize_with(&block)
53
57
  @authorize = true
54
- state[:authorizor] = block
58
+ state.authorizor = block
55
59
  end
56
60
 
57
61
  def sign_out_with(method: :get, &block)
58
- state[:sign_out_method] = method
59
- state[:sign_out_path] = block
62
+ state.sign_out_method = method
63
+ state.sign_out_path = block
60
64
  end
61
65
 
62
66
  def sign_out_path
63
- state[:sign_out_path]&.call
67
+ state.sign_out_path&.call
64
68
  end
65
69
 
66
- def sign_out_method
67
- state[:sign_out_method]
68
- end
70
+ delegate :sign_out_method, to: :state
69
71
 
70
72
  def sign_in_with(method: :get, &block)
71
- state[:sign_in_method] = method
72
- state[:sign_in_path] = block
73
+ state.sign_in_method = method
74
+ state.sign_in_path = block
73
75
  end
74
76
 
75
77
  def sign_in_path
76
- state[:sign_in_path]&.call
78
+ state.sign_in_path&.call
77
79
  end
78
80
 
79
- def sign_in_method
80
- state[:sign_in_method]
81
- end
81
+ delegate :sign_in_method, to: :state
82
82
 
83
83
  def authenticate
84
84
  authenticator&.call
85
- @current_user = state[:authorizor]&.call
85
+ @current_user = state.authorizor&.call
86
86
 
87
87
  nil
88
88
  end
@@ -91,16 +91,12 @@ module ActiveElement
91
91
  raise ArgumentError, "Must specify `with: '<permission>'` or `always: true`" unless with.present? || always
92
92
  raise ArgumentError, 'Cannot specify both `with` and `always: true`' if with.present? && always
93
93
 
94
- state[:permissions] << { with: with, always: always, action: action }
94
+ state.permissions << { with: with, always: always, action: action }
95
95
  end
96
96
 
97
- def authenticator
98
- state[:authenticator]
99
- end
97
+ delegate :authenticator, to: :state
100
98
 
101
- def permissions
102
- state.fetch(:permissions)
103
- end
99
+ delegate :permissions, to: :state
104
100
 
105
101
  def component
106
102
  return (@component ||= ActiveElement::Component.new(controller_instance)) unless controller_instance.nil?
@@ -116,8 +112,8 @@ module ActiveElement
116
112
 
117
113
  attr_reader :controller_class, :controller_instance
118
114
 
119
- def initialize_state
120
- self.class.state[controller_class] ||= { permissions: [], authenticator: nil }
115
+ def initialize_state(key)
116
+ self.class.state[key] ||= ControllerState.new(controller: controller_instance)
121
117
  end
122
118
  end
123
119
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Stores various data for a controller, including various field definitions and authentication
5
+ # configuration. Used throughout ActiveElement for generating dynamic content based on
6
+ # controller configuration.
7
+ class ControllerState
8
+ attr_reader :permissions, :listable_fields, :viewable_fields, :editable_fields, :searchable_fields
9
+ attr_accessor :sign_in_path, :sign_in, :sign_in_method, :sign_out_path, :sign_out_method,
10
+ :deletable, :authorizor, :authenticator
11
+
12
+ def initialize(controller:)
13
+ @controller = controller
14
+ @permissions = []
15
+ @authenticator = nil
16
+ @authorizor = nil
17
+ @deletable = false
18
+ @listable_fields = []
19
+ @viewable_fields = []
20
+ @editable_fields = []
21
+ @searchable_fields = []
22
+ end
23
+
24
+ def deletable?
25
+ !!deletable
26
+ end
27
+
28
+ def viewable?
29
+ viewable_fields.present? || controller.public_methods(false).include?(:show)
30
+ end
31
+
32
+ def editable?
33
+ editable_fields.present? || controller.public_methods(false).include?(:edit)
34
+ end
35
+
36
+ def creatable?
37
+ editable_fields.present? || controller.public_methods(false).include?(:new)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :controller
43
+ end
44
+ end
@@ -9,6 +9,8 @@ module ActiveElement
9
9
  end
10
10
 
11
11
  def index
12
+ return render_forbidden(:listable) unless configured?(:listable)
13
+
12
14
  controller.render 'active_element/default_views/index',
13
15
  locals: {
14
16
  collection: collection,
@@ -17,16 +19,22 @@ module ActiveElement
17
19
  end
18
20
 
19
21
  def show
22
+ return render_forbidden(:viewable) unless configured?(:viewable)
23
+
20
24
  controller.render 'active_element/default_views/show', locals: { record: record }
21
25
  end
22
26
 
23
27
  def new
28
+ return render_forbidden(:editable) unless configured?(:editable)
29
+
24
30
  controller.render 'active_element/default_views/new', locals: { record: model.new, namespace: namespace }
25
31
  end
26
32
 
27
- def create # rubocop:disable Metrics/AbcSize
33
+ def create # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
34
+ return render_forbidden(:editable) unless configured?(:editable)
35
+
28
36
  new_record = model.new(default_record_params.params)
29
- # Ensure associations are applied:
37
+ # XXX: Ensure associations are applied - there must be a better way.
30
38
  if new_record.save && new_record.reload.update(default_record_params.params)
31
39
  controller.flash.notice = "#{new_record.model_name.to_s.titleize} created successfully."
32
40
  controller.redirect_to record_path(new_record, :show).path
@@ -34,13 +42,19 @@ module ActiveElement
34
42
  controller.flash.now.alert = "Failed to create #{model.name.to_s.titleize}."
35
43
  controller.render 'active_element/default_views/new', locals: { record: new_record, namespace: namespace }
36
44
  end
45
+ rescue ActiveRecord::RangeError => e
46
+ render_range_error(error: e, action: :new)
37
47
  end
38
48
 
39
49
  def edit
50
+ return render_forbidden(:editable) unless configured?(:editable)
51
+
40
52
  controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
41
53
  end
42
54
 
43
55
  def update # rubocop:disable Metrics/AbcSize
56
+ return render_forbidden(:editable) unless configured?(:editable)
57
+
44
58
  if record.update(default_record_params.params)
45
59
  controller.flash.notice = "#{record.model_name.to_s.titleize} updated successfully."
46
60
  controller.redirect_to record_path(record, :show).path
@@ -48,9 +62,13 @@ module ActiveElement
48
62
  controller.flash.now.alert = "Failed to update #{model.name.to_s.titleize}."
49
63
  controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
50
64
  end
65
+ rescue ActiveRecord::RangeError => e
66
+ render_range_error(error: e, action: :edit)
51
67
  end
52
68
 
53
69
  def destroy
70
+ return render_forbidden(:deletable) unless configured?(:deletable)
71
+
54
72
  record.destroy
55
73
  controller.flash.notice = "Deleted #{record.model_name.to_s.titleize}."
56
74
  controller.redirect_to record_path(model, :index).path
@@ -60,12 +78,26 @@ module ActiveElement
60
78
 
61
79
  attr_reader :controller
62
80
 
81
+ def render_forbidden(type)
82
+ controller.render 'active_element/default_views/forbidden', locals: { type: type }
83
+ end
84
+
85
+ def configured?(type)
86
+ return state.deletable? if type == :deletable
87
+
88
+ state.public_send("#{type}_fields").present?
89
+ end
90
+
91
+ def state
92
+ @state ||= controller.active_element.state
93
+ end
94
+
63
95
  def default_record_params
64
96
  @default_record_params ||= ActiveElement::DefaultRecordParams.new(controller: controller, model: model)
65
97
  end
66
98
 
67
99
  def default_text_search
68
- @default_text_search ||= ActiveElement::DefaultTextSearch.new(controller: controller, model: model)
100
+ @default_text_search ||= ActiveElement::DefaultSearch.new(controller: controller, model: model)
69
101
  end
70
102
 
71
103
  def record_path(record, type = nil)
@@ -89,5 +121,17 @@ module ActiveElement
89
121
 
90
122
  model.left_outer_joins(default_text_search.search_relations).where(*default_text_search.text_search)
91
123
  end
124
+
125
+ def render_range_error(error:, action:)
126
+ controller.flash.now.alert = formatted_error(error)
127
+ controller.render "active_element/default_views/#{action}", locals: { record: record, namespace: namespace }
128
+ end
129
+
130
+ def formatted_error(error)
131
+ return error.cause.message.split("\n").join(', ') if error.try(:cause)&.try(:message).present?
132
+ return error.message if error.try(:message).present?
133
+
134
+ I18n.t('active_element.unexpected_error')
135
+ end
92
136
  end
93
137
  end
@@ -12,7 +12,7 @@ module ActiveElement
12
12
  def params
13
13
  with_transformed_relations(
14
14
  controller.params.require(controller.controller_name.singularize)
15
- .permit(controller.active_element.state.fetch(:editable_fields, []))
15
+ .permit(controller.active_element.state.editable_fields)
16
16
  )
17
17
  end
18
18
 
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveElement
4
- # Full text search querying for DefaultController, provides full text search filters for all
5
- # controllers with configured searchable fields. Includes support for querying across relations.
6
- class DefaultTextSearch
4
+ # Full text search and datetime querying for DefaultController, provides full text search
5
+ # filters for all controllers with configured searchable fields. Includes support for querying
6
+ # across relations.
7
+ class DefaultSearch
7
8
  def initialize(controller:, model:)
8
9
  @controller = controller
9
10
  @model = model
@@ -40,8 +41,7 @@ module ActiveElement
40
41
  attr_reader :controller, :model
41
42
 
42
43
  def searchable_fields
43
- base_fields = controller.active_element.state.fetch(:searchable_fields, [])
44
- base_fields.map do |field|
44
+ controller.active_element.state.searchable_fields.map do |field|
45
45
  next field unless field.to_s.end_with?('_at')
46
46
 
47
47
  { field => %i[from to] }
@@ -66,7 +66,7 @@ module ActiveElement
66
66
  value[:from].present? ? Time.zone.parse(value[:from]) + timezone_offset : -Float::INFINITY
67
67
  end
68
68
 
69
- def range_end
69
+ def range_end(value)
70
70
  value[:to].present? ? Time.zone.parse(value[:to]) + timezone_offset : Float::INFINITY
71
71
  end
72
72
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveElement
4
- VERSION = '0.0.11'
4
+ VERSION = '0.0.12'
5
5
  end
@@ -12,10 +12,11 @@ require_relative 'active_element/active_menu_link'
12
12
  require_relative 'active_element/permissions_check'
13
13
  require_relative 'active_element/permissions_report'
14
14
  require_relative 'active_element/controller_interface'
15
+ require_relative 'active_element/controller_state'
15
16
  require_relative 'active_element/controller_action'
16
17
  require_relative 'active_element/default_controller'
17
18
  require_relative 'active_element/default_record_params'
18
- require_relative 'active_element/default_text_search'
19
+ require_relative 'active_element/default_search'
19
20
  require_relative 'active_element/pre_render_processors'
20
21
  require_relative 'active_element/rails_component'
21
22
  require_relative 'active_element/route'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_element
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bob Farrell
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-06-18 00:00:00.000000000 Z
11
+ date: 2023-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bootstrap
@@ -168,6 +168,7 @@ files:
168
168
  - app/views/active_element/decorators/_datetime.html.erb
169
169
  - app/views/active_element/decorators/_time.html.erb
170
170
  - app/views/active_element/default_views/edit.html.erb
171
+ - app/views/active_element/default_views/forbidden.html.erb
171
172
  - app/views/active_element/default_views/index.html.erb
172
173
  - app/views/active_element/default_views/new.html.erb
173
174
  - app/views/active_element/default_views/show.html.erb
@@ -186,6 +187,7 @@ files:
186
187
  - app/views/layouts/active_element.html.erb
187
188
  - app/views/layouts/active_element_error.html.erb
188
189
  - config/brakeman.ignore
190
+ - config/locales/en.yml
189
191
  - config/routes.rb
190
192
  - example_app/.gitattributes
191
193
  - example_app/.gitignore
@@ -309,13 +311,15 @@ files:
309
311
  - lib/active_element/components/util/form_field_mapping.rb
310
312
  - lib/active_element/components/util/form_value_mapping.rb
311
313
  - lib/active_element/components/util/i18n.rb
314
+ - lib/active_element/components/util/numeric_field.rb
312
315
  - lib/active_element/components/util/record_mapping.rb
313
316
  - lib/active_element/components/util/record_path.rb
314
317
  - lib/active_element/controller_action.rb
315
318
  - lib/active_element/controller_interface.rb
319
+ - lib/active_element/controller_state.rb
316
320
  - lib/active_element/default_controller.rb
317
321
  - lib/active_element/default_record_params.rb
318
- - lib/active_element/default_text_search.rb
322
+ - lib/active_element/default_search.rb
319
323
  - lib/active_element/engine.rb
320
324
  - lib/active_element/json_field_schema.rb
321
325
  - lib/active_element/permissions_check.rb