active_element 0.0.11 → 0.0.12

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