active_element 0.0.10 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +12 -2
- data/.strong_versions.yml +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +115 -75
- data/Makefile +10 -0
- data/active_element.gemspec +1 -1
- data/app/assets/javascripts/active_element/application.js +1 -0
- data/app/assets/javascripts/active_element/form.js +16 -32
- data/app/assets/javascripts/active_element/json_field.js +391 -135
- data/app/assets/javascripts/active_element/setup.js +13 -8
- data/app/assets/javascripts/active_element/text_search_field.js +38 -27
- data/app/assets/javascripts/active_element/theme.js +1 -1
- data/app/assets/javascripts/active_element/timezones.js +6 -0
- data/app/assets/stylesheets/active_element/_dark.scss +86 -0
- data/app/assets/stylesheets/active_element/_variables.scss +2 -1
- data/app/assets/stylesheets/active_element/application.scss +166 -33
- data/app/controllers/active_element/application_controller.rb +5 -0
- data/app/controllers/concerns/active_element/default_controller_actions.rb +38 -0
- data/app/views/active_element/_user.html.erb +20 -0
- data/app/views/active_element/components/fields/_json.html.erb +24 -0
- data/app/views/active_element/components/form/_check_box.html.erb +1 -0
- data/app/views/active_element/components/form/_check_boxes.html.erb +1 -1
- data/app/views/active_element/components/form/_datetime_range_field.html.erb +14 -0
- data/app/views/active_element/components/form/_field.html.erb +10 -7
- data/app/views/active_element/components/form/_generic_field.html.erb +1 -0
- data/app/views/active_element/components/form/_json.html.erb +10 -2
- data/app/views/active_element/components/form/_label.html.erb +12 -1
- data/app/views/active_element/components/form/_select.html.erb +4 -1
- data/app/views/active_element/components/form/_summary.html.erb +11 -1
- data/app/views/active_element/components/form/_templates.html.erb +42 -24
- data/app/views/active_element/components/form/_text_area.html.erb +2 -1
- data/app/views/active_element/components/form/_text_search.html.erb +8 -4
- data/app/views/active_element/components/form.html.erb +20 -17
- data/app/views/active_element/components/json.html.erb +1 -0
- data/app/views/active_element/components/navbar.html.erb +26 -0
- data/app/views/active_element/components/table/_collection_row.html.erb +2 -1
- data/app/views/active_element/components/table/_field.html.erb +8 -0
- data/app/views/active_element/components/table/_ungrouped_collection.html.erb +1 -0
- data/app/views/active_element/components/table/collection.html.erb +1 -1
- data/app/views/active_element/components/table/item.html.erb +6 -4
- data/app/views/active_element/default_views/edit.html.erb +5 -0
- data/app/views/active_element/default_views/forbidden.html.erb +7 -0
- data/app/views/active_element/default_views/index.html.erb +15 -0
- data/app/views/active_element/default_views/new.html.erb +4 -0
- data/app/views/active_element/default_views/show.html.erb +7 -0
- data/app/views/active_element/navbar/_menu.html.erb +1 -30
- data/app/views/active_element/theme/_select.html.erb +1 -1
- data/app/views/layouts/active_element.html.erb +16 -1
- data/config/brakeman.ignore +48 -0
- data/config/locales/en.yml +3 -0
- data/example_app/.gitattributes +7 -0
- data/example_app/.gitignore +35 -0
- data/example_app/.ruby-version +1 -0
- data/example_app/Gemfile +34 -0
- data/example_app/Gemfile.lock +296 -0
- data/example_app/README.md +24 -0
- data/example_app/Rakefile +6 -0
- data/example_app/app/assets/config/manifest.js +4 -0
- data/example_app/app/assets/images/.keep +0 -0
- data/example_app/app/assets/stylesheets/application.css +15 -0
- data/example_app/app/channels/application_cable/channel.rb +4 -0
- data/example_app/app/channels/application_cable/connection.rb +4 -0
- data/example_app/app/controllers/application_controller.rb +12 -0
- data/example_app/app/controllers/concerns/.keep +0 -0
- data/example_app/app/controllers/pets_controller.rb +7 -0
- data/example_app/app/controllers/users_controller.rb +7 -0
- data/example_app/app/helpers/application_helper.rb +2 -0
- data/example_app/app/javascript/application.js +3 -0
- data/example_app/app/javascript/controllers/application.js +9 -0
- data/example_app/app/javascript/controllers/hello_controller.js +7 -0
- data/example_app/app/javascript/controllers/index.js +11 -0
- data/example_app/app/jobs/application_job.rb +7 -0
- data/example_app/app/mailers/application_mailer.rb +4 -0
- data/example_app/app/models/application_record.rb +3 -0
- data/example_app/app/models/concerns/.keep +0 -0
- data/example_app/app/models/pet.rb +3 -0
- data/example_app/app/models/user.rb +8 -0
- data/example_app/app/views/layouts/application.html.erb +16 -0
- data/example_app/app/views/layouts/mailer.html.erb +13 -0
- data/example_app/app/views/layouts/mailer.text.erb +1 -0
- data/example_app/app/views/pets/index.html.erb +3 -0
- data/example_app/app/views/users/show.html.erb +3 -0
- data/example_app/bin/bundle +109 -0
- data/example_app/bin/importmap +4 -0
- data/example_app/bin/rails +4 -0
- data/example_app/bin/rake +4 -0
- data/example_app/bin/setup +33 -0
- data/example_app/config/application.rb +22 -0
- data/example_app/config/boot.rb +4 -0
- data/example_app/config/cable.yml +10 -0
- data/example_app/config/credentials.yml.enc +1 -0
- data/example_app/config/database.yml +25 -0
- data/example_app/config/environment.rb +5 -0
- data/example_app/config/environments/development.rb +70 -0
- data/example_app/config/environments/production.rb +93 -0
- data/example_app/config/environments/test.rb +60 -0
- data/example_app/config/importmap.rb +7 -0
- data/example_app/config/initializers/assets.rb +12 -0
- data/example_app/config/initializers/content_security_policy.rb +25 -0
- data/example_app/config/initializers/devise.rb +16 -0
- data/example_app/config/initializers/filter_parameter_logging.rb +8 -0
- data/example_app/config/initializers/inflections.rb +16 -0
- data/example_app/config/initializers/permissions_policy.rb +11 -0
- data/example_app/config/locales/devise.en.yml +65 -0
- data/example_app/config/locales/en.yml +33 -0
- data/example_app/config/puma.rb +43 -0
- data/example_app/config/routes.rb +8 -0
- data/example_app/config/storage.yml +34 -0
- data/example_app/config.ru +6 -0
- data/example_app/db/migrate/20230616210539_create_pet.rb +12 -0
- data/example_app/db/migrate/20230616211328_devise_create_users.rb +46 -0
- data/example_app/db/schema.rb +37 -0
- data/example_app/db/seeds.rb +33 -0
- data/example_app/lib/assets/.keep +0 -0
- data/example_app/lib/tasks/.keep +0 -0
- data/example_app/log/.keep +0 -0
- data/example_app/public/404.html +67 -0
- data/example_app/public/422.html +67 -0
- data/example_app/public/500.html +66 -0
- data/example_app/public/apple-touch-icon-precomposed.png +0 -0
- data/example_app/public/apple-touch-icon.png +0 -0
- data/example_app/public/favicon.ico +0 -0
- data/example_app/public/robots.txt +1 -0
- data/example_app/storage/.keep +0 -0
- data/example_app/test/application_system_test_case.rb +5 -0
- data/example_app/test/channels/application_cable/connection_test.rb +11 -0
- data/example_app/test/controllers/.keep +0 -0
- data/example_app/test/fixtures/files/.keep +0 -0
- data/example_app/test/fixtures/users.yml +11 -0
- data/example_app/test/helpers/.keep +0 -0
- data/example_app/test/integration/.keep +0 -0
- data/example_app/test/mailers/.keep +0 -0
- data/example_app/test/models/.keep +0 -0
- data/example_app/test/models/user_test.rb +7 -0
- data/example_app/test/system/.keep +0 -0
- data/example_app/test/test_helper.rb +13 -0
- data/example_app/tmp/.keep +0 -0
- data/example_app/tmp/pids/.keep +0 -0
- data/example_app/tmp/storage/.keep +0 -0
- data/example_app/vendor/.keep +0 -0
- data/example_app/vendor/javascript/.keep +0 -0
- data/lib/active_element/component.rb +9 -2
- data/lib/active_element/components/collection_table.rb +9 -2
- data/lib/active_element/components/email_fields.rb +14 -0
- data/lib/active_element/components/form.rb +48 -17
- data/lib/active_element/components/navbar.rb +64 -0
- data/lib/active_element/components/phone_fields.rb +14 -0
- data/lib/active_element/components/text_search/authorization.rb +9 -6
- data/lib/active_element/components/text_search/component.rb +4 -2
- data/lib/active_element/components/text_search.rb +13 -0
- data/lib/active_element/components/util/association_mapping.rb +74 -19
- data/lib/active_element/components/util/display_value_mapping.rb +13 -4
- data/lib/active_element/components/util/form_field_mapping.rb +139 -10
- data/lib/active_element/components/util/form_value_mapping.rb +3 -3
- data/lib/active_element/components/util/i18n.rb +1 -1
- data/lib/active_element/components/util/numeric_field.rb +73 -0
- data/lib/active_element/components/util/record_mapping.rb +43 -11
- data/lib/active_element/components/util/record_path.rb +21 -4
- data/lib/active_element/components/util.rb +13 -5
- data/lib/active_element/components.rb +3 -0
- data/lib/active_element/controller_action.rb +8 -2
- data/lib/active_element/controller_interface.rb +56 -18
- data/lib/active_element/controller_state.rb +44 -0
- data/lib/active_element/default_controller.rb +137 -0
- data/lib/active_element/default_record_params.rb +62 -0
- data/lib/active_element/default_search.rb +110 -0
- data/lib/active_element/json_field_schema.rb +59 -0
- data/lib/active_element/pre_render_processors/json.rb +98 -0
- data/lib/active_element/pre_render_processors.rb +11 -0
- data/lib/active_element/route.rb +12 -0
- data/lib/active_element/routes.rb +2 -1
- data/lib/active_element/version.rb +1 -1
- data/lib/active_element.rb +15 -32
- data/lib/tasks/active_element.rake +12 -1
- data/rspec-documentation/_head.html.erb +34 -0
- data/rspec-documentation/pages/000-Introduction.md +18 -0
- data/rspec-documentation/pages/005-Setup.md +75 -0
- data/rspec-documentation/pages/010-Components/Form Fields/Check Boxes.md +1 -0
- data/rspec-documentation/pages/010-Components/Form Fields/JSON/Controller Params.md +97 -0
- data/rspec-documentation/pages/010-Components/Form Fields/JSON/Schema.md +283 -0
- data/rspec-documentation/pages/010-Components/Form Fields/JSON/Types.md +36 -0
- data/rspec-documentation/pages/010-Components/Form Fields/JSON.md +70 -0
- data/rspec-documentation/pages/010-Components/Form Fields/Text Search.md +133 -0
- data/rspec-documentation/pages/010-Components/Form Fields.md +46 -0
- data/rspec-documentation/pages/010-Components/Forms.md +44 -0
- data/rspec-documentation/pages/010-Components/JSON Data.md +23 -0
- data/rspec-documentation/pages/010-Components/Navbar.md +56 -0
- data/rspec-documentation/pages/010-Components/Page Section Title.md +13 -0
- data/rspec-documentation/pages/010-Components/Page Subtitle.md +11 -0
- data/rspec-documentation/pages/010-Components/Page Title.md +11 -0
- data/rspec-documentation/pages/010-Components/Tables/Collection Table.md +29 -0
- data/rspec-documentation/pages/010-Components/Tables/Item Table.md +18 -0
- data/rspec-documentation/pages/010-Components/Tables/Options.md +19 -0
- data/rspec-documentation/pages/010-Components/Tables.md +29 -0
- data/rspec-documentation/pages/010-Components.md +15 -0
- data/rspec-documentation/pages/020-Access Control/010-Authentication.md +20 -0
- data/rspec-documentation/pages/020-Access Control/020-Authorization/Environments.md +9 -0
- data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions/Custom Routes.md +41 -0
- data/rspec-documentation/pages/020-Access Control/020-Authorization/Permissions.md +58 -0
- data/rspec-documentation/pages/020-Access Control/020-Authorization/Setup.md +27 -0
- data/rspec-documentation/pages/020-Access Control/020-Authorization.md +11 -0
- data/rspec-documentation/pages/020-Access Control.md +31 -0
- data/rspec-documentation/pages/040-Decorators/Inline Decorators.md +24 -0
- data/rspec-documentation/pages/040-Decorators/View Decorators.md +55 -0
- data/rspec-documentation/pages/040-Decorators.md +12 -0
- data/rspec-documentation/pages/300-Alternatives.md +21 -0
- data/rspec-documentation/pages/900-License.md +11 -0
- data/rspec-documentation/spec_helper.rb +53 -16
- data/rspec-documentation/support.rb +84 -0
- metadata +159 -14
- data/rspec-documentation/pages/Components/Forms.md +0 -1
- data/rspec-documentation/pages/Components/Tables.md +0 -47
- data/rspec-documentation/pages/Components.md +0 -1
- data/rspec-documentation/pages/Decorators/Inline Decorators.md +0 -1
- data/rspec-documentation/pages/Decorators/View Decorators.md +0 -1
- data/rspec-documentation/pages/Index.md +0 -3
- data/rspec-documentation/pages/Util/I18n.md +0 -1
- /data/rspec-documentation/pages/{Components → 010-Components}/Tabs.md +0 -0
@@ -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
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
# Encapsulation of all logic performed for default controller actions when no action is defined
|
5
|
+
# by the current controller.
|
6
|
+
class DefaultController
|
7
|
+
def initialize(controller:)
|
8
|
+
@controller = controller
|
9
|
+
end
|
10
|
+
|
11
|
+
def index
|
12
|
+
return render_forbidden(:listable) unless configured?(:listable)
|
13
|
+
|
14
|
+
controller.render 'active_element/default_views/index',
|
15
|
+
locals: {
|
16
|
+
collection: collection,
|
17
|
+
search_filters: default_text_search.search_filters
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def show
|
22
|
+
return render_forbidden(:viewable) unless configured?(:viewable)
|
23
|
+
|
24
|
+
controller.render 'active_element/default_views/show', locals: { record: record }
|
25
|
+
end
|
26
|
+
|
27
|
+
def new
|
28
|
+
return render_forbidden(:editable) unless configured?(:editable)
|
29
|
+
|
30
|
+
controller.render 'active_element/default_views/new', locals: { record: model.new, namespace: namespace }
|
31
|
+
end
|
32
|
+
|
33
|
+
def create # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
34
|
+
return render_forbidden(:editable) unless configured?(:editable)
|
35
|
+
|
36
|
+
new_record = model.new(default_record_params.params)
|
37
|
+
# XXX: Ensure associations are applied - there must be a better way.
|
38
|
+
if new_record.save && new_record.reload.update(default_record_params.params)
|
39
|
+
controller.flash.notice = "#{new_record.model_name.to_s.titleize} created successfully."
|
40
|
+
controller.redirect_to record_path(new_record, :show).path
|
41
|
+
else
|
42
|
+
controller.flash.now.alert = "Failed to create #{model.name.to_s.titleize}."
|
43
|
+
controller.render 'active_element/default_views/new', locals: { record: new_record, namespace: namespace }
|
44
|
+
end
|
45
|
+
rescue ActiveRecord::RangeError => e
|
46
|
+
render_range_error(error: e, action: :new)
|
47
|
+
end
|
48
|
+
|
49
|
+
def edit
|
50
|
+
return render_forbidden(:editable) unless configured?(:editable)
|
51
|
+
|
52
|
+
controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
|
53
|
+
end
|
54
|
+
|
55
|
+
def update # rubocop:disable Metrics/AbcSize
|
56
|
+
return render_forbidden(:editable) unless configured?(:editable)
|
57
|
+
|
58
|
+
if record.update(default_record_params.params)
|
59
|
+
controller.flash.notice = "#{record.model_name.to_s.titleize} updated successfully."
|
60
|
+
controller.redirect_to record_path(record, :show).path
|
61
|
+
else
|
62
|
+
controller.flash.now.alert = "Failed to update #{model.name.to_s.titleize}."
|
63
|
+
controller.render 'active_element/default_views/edit', locals: { record: record, namespace: namespace }
|
64
|
+
end
|
65
|
+
rescue ActiveRecord::RangeError => e
|
66
|
+
render_range_error(error: e, action: :edit)
|
67
|
+
end
|
68
|
+
|
69
|
+
def destroy
|
70
|
+
return render_forbidden(:deletable) unless configured?(:deletable)
|
71
|
+
|
72
|
+
record.destroy
|
73
|
+
controller.flash.notice = "Deleted #{record.model_name.to_s.titleize}."
|
74
|
+
controller.redirect_to record_path(model, :index).path
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
attr_reader :controller
|
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
|
+
|
95
|
+
def default_record_params
|
96
|
+
@default_record_params ||= ActiveElement::DefaultRecordParams.new(controller: controller, model: model)
|
97
|
+
end
|
98
|
+
|
99
|
+
def default_text_search
|
100
|
+
@default_text_search ||= ActiveElement::DefaultSearch.new(controller: controller, model: model)
|
101
|
+
end
|
102
|
+
|
103
|
+
def record_path(record, type = nil)
|
104
|
+
ActiveElement::Components::Util::RecordPath.new(record: record, controller: controller, type: type)
|
105
|
+
end
|
106
|
+
|
107
|
+
def namespace
|
108
|
+
controller.controller_path.rpartition('/').first.presence&.to_sym
|
109
|
+
end
|
110
|
+
|
111
|
+
def model
|
112
|
+
controller.controller_name.classify.constantize
|
113
|
+
end
|
114
|
+
|
115
|
+
def record
|
116
|
+
@record ||= model.find(controller.params[:id])
|
117
|
+
end
|
118
|
+
|
119
|
+
def collection
|
120
|
+
return model.all unless default_text_search.text_search?
|
121
|
+
|
122
|
+
model.left_outer_joins(default_text_search.search_relations).where(*default_text_search.text_search)
|
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
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
# Provides params for ActiveRecord models when using the default boilerplate controller
|
5
|
+
# actions. Navigates input parameters and maps them to appropriate relations as needed.
|
6
|
+
class DefaultRecordParams
|
7
|
+
def initialize(controller:, model:)
|
8
|
+
@controller = controller
|
9
|
+
@model = model
|
10
|
+
end
|
11
|
+
|
12
|
+
def params
|
13
|
+
with_transformed_relations(
|
14
|
+
controller.params.require(controller.controller_name.singularize)
|
15
|
+
.permit(controller.active_element.state.editable_fields)
|
16
|
+
)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_reader :controller, :model
|
22
|
+
|
23
|
+
def with_transformed_relations(params)
|
24
|
+
params.to_h.to_h do |key, value|
|
25
|
+
next [key, value] unless relation?(key)
|
26
|
+
|
27
|
+
relation_param(key, value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def relation_param(key, value)
|
32
|
+
case relation(key).macro
|
33
|
+
when :belongs_to
|
34
|
+
belongs_to_param(key, value)
|
35
|
+
when :has_one
|
36
|
+
has_one_param(key, value)
|
37
|
+
when :has_many
|
38
|
+
has_many_param(key, value)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def belongs_to_param(key, value)
|
43
|
+
[relation(key).foreign_key, value]
|
44
|
+
end
|
45
|
+
|
46
|
+
def has_one_param(key, value) # rubocop:disable Naming/PredicateName
|
47
|
+
[relation(key).name, relation(key).klass.find_by(relation(key).klass.primary_key => value)]
|
48
|
+
end
|
49
|
+
|
50
|
+
def has_many_param(key, _value) # rubocop:disable Naming/PredicateName
|
51
|
+
[relation(key).name, relation(key).klass.where(relation(key).klass.primary_key => relation(key).value)]
|
52
|
+
end
|
53
|
+
|
54
|
+
def relation?(attribute)
|
55
|
+
relation(attribute.to_sym).present?
|
56
|
+
end
|
57
|
+
|
58
|
+
def relation(attribute)
|
59
|
+
model.reflect_on_association(attribute.to_sym)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
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
|
8
|
+
def initialize(controller:, model:)
|
9
|
+
@controller = controller
|
10
|
+
@model = model
|
11
|
+
end
|
12
|
+
|
13
|
+
def search_filters
|
14
|
+
@search_filters ||= controller.params.permit(*searchable_fields).transform_values do |value|
|
15
|
+
value.try(:compact_blank) || value
|
16
|
+
end.compact_blank
|
17
|
+
end
|
18
|
+
|
19
|
+
def text_search?
|
20
|
+
search_filters.present?
|
21
|
+
end
|
22
|
+
|
23
|
+
def text_search
|
24
|
+
conditions = search_filters.to_h.map do |key, value|
|
25
|
+
next relation_matches(key, value) if relation?(key)
|
26
|
+
next datetime_between(key, value) if datetime?(key)
|
27
|
+
|
28
|
+
model.arel_table[key].matches("#{value}%")
|
29
|
+
end
|
30
|
+
conditions[1..].reduce(conditions.first) do |accumulated, condition|
|
31
|
+
accumulated.and(condition)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def search_relations
|
36
|
+
search_filters.to_h.keys.map { |key| relation?(key) ? key.to_sym : nil }.compact
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :controller, :model
|
42
|
+
|
43
|
+
def searchable_fields
|
44
|
+
controller.active_element.state.searchable_fields.map do |field|
|
45
|
+
next field unless field.to_s.end_with?('_at')
|
46
|
+
|
47
|
+
{ field => %i[from to] }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def noop
|
52
|
+
Arel::Nodes::True.new.eq(Arel::Nodes::True.new)
|
53
|
+
end
|
54
|
+
|
55
|
+
def datetime?(key)
|
56
|
+
model.columns.find { |column| column.name.to_s == key.to_s }&.type == :datetime
|
57
|
+
end
|
58
|
+
|
59
|
+
def datetime_between(key, value)
|
60
|
+
return noop if value[:from].blank? && value[:to].blank?
|
61
|
+
|
62
|
+
model.arel_table[key].between(range_begin(value)...range_end(value))
|
63
|
+
end
|
64
|
+
|
65
|
+
def range_begin(value)
|
66
|
+
value[:from].present? ? Time.zone.parse(value[:from]) + timezone_offset : -Float::INFINITY
|
67
|
+
end
|
68
|
+
|
69
|
+
def range_end(value)
|
70
|
+
value[:to].present? ? Time.zone.parse(value[:to]) + timezone_offset : Float::INFINITY
|
71
|
+
end
|
72
|
+
|
73
|
+
def timezone_offset
|
74
|
+
controller.request.cookies['timezone_offset'].to_i.minutes
|
75
|
+
end
|
76
|
+
|
77
|
+
def relation_matches(key, value)
|
78
|
+
fields = searchable_relation_fields(key)
|
79
|
+
relation_model = relation(key).klass
|
80
|
+
fields.select! do |field|
|
81
|
+
relation_model.columns.find { |column| column.name.to_s == field.to_s }&.type == :string
|
82
|
+
end
|
83
|
+
|
84
|
+
return noop if fields.empty?
|
85
|
+
|
86
|
+
relation_conditions(fields, value, relation_model)
|
87
|
+
end
|
88
|
+
|
89
|
+
def relation_conditions(fields, value, relation_model)
|
90
|
+
fields[1..].reduce(relation_model.arel_table[fields.first].matches("#{value}%")) do |condition, field|
|
91
|
+
condition.or(relation_model.arel_table[field].matches("#{value}%"))
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def searchable_relation_fields(key)
|
96
|
+
Components::Util.relation_controller(model, controller, key)
|
97
|
+
&.active_element
|
98
|
+
&.state
|
99
|
+
&.fetch(:searchable_fields, []) || []
|
100
|
+
end
|
101
|
+
|
102
|
+
def relation?(attribute)
|
103
|
+
relation(attribute.to_sym).present?
|
104
|
+
end
|
105
|
+
|
106
|
+
def relation(attribute)
|
107
|
+
model.reflect_on_association(attribute.to_sym)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
# Generates a schema for a JSON form field based on values stored in the database.
|
5
|
+
class JsonFieldSchema
|
6
|
+
def initialize(table:, column:)
|
7
|
+
@table = table
|
8
|
+
@column = column
|
9
|
+
end
|
10
|
+
|
11
|
+
def schema
|
12
|
+
data.map { |datum| structure(datum) }
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
attr_reader :table, :column, :initial_structure
|
18
|
+
|
19
|
+
def data
|
20
|
+
@data ||= ActiveRecord::Base.connection
|
21
|
+
.execute("select #{column} from #{table}")
|
22
|
+
.pluck(column)
|
23
|
+
.map { |datum| JSON.parse(datum) }
|
24
|
+
end
|
25
|
+
|
26
|
+
def structure(datum)
|
27
|
+
{
|
28
|
+
type: schema_type(datum),
|
29
|
+
shape: schema_shape(datum)&.compact,
|
30
|
+
fields: schema_fields(datum)&.compact
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
def schema_type(val)
|
35
|
+
case val
|
36
|
+
when Hash
|
37
|
+
'object'
|
38
|
+
when Array
|
39
|
+
'array'
|
40
|
+
when String
|
41
|
+
'string'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def schema_shape(val)
|
46
|
+
return nil unless %w[array object].include?(schema_type(val))
|
47
|
+
return val.map { |item| structure(item).compact } if schema_type(val) == 'array'
|
48
|
+
return val.map { |_key, value| structure(value).compact } if schema_type(val) == 'object'
|
49
|
+
|
50
|
+
{ type: schema_type(val) }
|
51
|
+
end
|
52
|
+
|
53
|
+
def schema_fields(val)
|
54
|
+
return nil unless schema_type(val) == 'object'
|
55
|
+
|
56
|
+
val.map { |key, value| { name: key }.merge(structure(value)).compact }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module PreRenderProcessors
|
5
|
+
# Selects fields from `__json_fields` param created by `Components::JsonField` and parses
|
6
|
+
# each field's JSON data back into request params to allow for transparent JSON data receipt.
|
7
|
+
# All params are permitted and converted to a Hash to allow them to be modified before
|
8
|
+
# converting back to ActionController::Params to avoid disrupting the Rails request flow.
|
9
|
+
class Json
|
10
|
+
def initialize(controller:)
|
11
|
+
@controller = controller
|
12
|
+
end
|
13
|
+
|
14
|
+
def process
|
15
|
+
return if json_fields.blank?
|
16
|
+
|
17
|
+
process_json_fields
|
18
|
+
delete_meta_params
|
19
|
+
rebuild_action_controller_parameters
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :controller
|
25
|
+
|
26
|
+
def process_json_fields
|
27
|
+
json_fields.zip(json_values).each do |field, value|
|
28
|
+
*nested_keys, field_key = field.split('.')
|
29
|
+
param = nested_keys.reduce(permitted_params) { |params, key| params[key] }
|
30
|
+
schema = schema_for(nested_keys + [field_key])
|
31
|
+
param[field_key] = coerced_with_default(value, schema)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def coerced_with_default(value, schema)
|
36
|
+
return coerced_value(JSON.parse(value), schema: schema) unless value == ''
|
37
|
+
|
38
|
+
{ 'array' => [], 'object' => {} }.fetch(schema['type'])
|
39
|
+
end
|
40
|
+
|
41
|
+
def delete_meta_params
|
42
|
+
permitted_params.delete('__json_fields')
|
43
|
+
permitted_params.delete('__json_field_schemas')
|
44
|
+
end
|
45
|
+
|
46
|
+
def rebuild_action_controller_parameters
|
47
|
+
controller.params = ActionController::Parameters.new(permitted_params)
|
48
|
+
end
|
49
|
+
|
50
|
+
def json_fields
|
51
|
+
controller.params['__json_fields']
|
52
|
+
end
|
53
|
+
|
54
|
+
def json_values
|
55
|
+
json_fields.map do |json_field|
|
56
|
+
json_field.split('.').reduce(controller.params) { |params, field| params[field] }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def permitted_params
|
61
|
+
@permitted_params ||= controller.params.permit!.to_h
|
62
|
+
end
|
63
|
+
|
64
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
65
|
+
def coerced_value(val, schema:)
|
66
|
+
return val if val.nil?
|
67
|
+
|
68
|
+
case schema['type']
|
69
|
+
when 'array'
|
70
|
+
val.map { |item| coerced_value(item, schema: schema['shape']) }
|
71
|
+
when 'object'
|
72
|
+
val.to_h { |key, value| [key, coerced_value(value, schema: schema_field(schema, key))] }
|
73
|
+
when 'string', 'boolean', 'time'
|
74
|
+
val
|
75
|
+
when 'float'
|
76
|
+
Float(val)
|
77
|
+
when 'integer'
|
78
|
+
Integer(val)
|
79
|
+
when 'decimal'
|
80
|
+
BigDecimal(val)
|
81
|
+
when 'datetime'
|
82
|
+
DateTime.parse(val)
|
83
|
+
when 'date'
|
84
|
+
Date.parse(val)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
88
|
+
|
89
|
+
def schema_for(path)
|
90
|
+
JSON.parse(path.reduce(permitted_params['__json_field_schemas']) { |schema, key| schema[key] })
|
91
|
+
end
|
92
|
+
|
93
|
+
def schema_field(schema, key)
|
94
|
+
schema['shape']['fields'].find { |each_field| each_field['name'] == key }
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'pre_render_processors/json'
|
4
|
+
|
5
|
+
module ActiveElement
|
6
|
+
# Collection of processors called before the controller flow is handed back to the host
|
7
|
+
# application, i.e. before actions within the main ActiveElement before action, e.g. used for
|
8
|
+
# processing JSON fields.
|
9
|
+
module PreRenderProcessors
|
10
|
+
end
|
11
|
+
end
|
data/lib/active_element/route.rb
CHANGED
@@ -36,6 +36,7 @@ module ActiveElement
|
|
36
36
|
def primary?
|
37
37
|
return false if rails_non_index_action?
|
38
38
|
return false unless resourceless_get_request?
|
39
|
+
return false if excluded_ancestor?
|
39
40
|
|
40
41
|
true
|
41
42
|
end
|
@@ -93,6 +94,17 @@ module ActiveElement
|
|
93
94
|
end
|
94
95
|
end
|
95
96
|
|
97
|
+
def excluded_ancestor?
|
98
|
+
ancestors = controller.class.ancestors.map(&:name)
|
99
|
+
excluded_ancestors.any? { |excluded_ancestor| ancestors.include?(excluded_ancestor) }
|
100
|
+
end
|
101
|
+
|
102
|
+
def excluded_ancestors
|
103
|
+
# This will likely end up a config setting, for now we exclude Devise so its controllers
|
104
|
+
# don't appear in the Navbar.
|
105
|
+
%w[DeviseController]
|
106
|
+
end
|
107
|
+
|
96
108
|
def permitted_action?
|
97
109
|
permissions_check.permitted?
|
98
110
|
rescue UnprotectedRouteError
|
@@ -33,7 +33,8 @@ module ActiveElement
|
|
33
33
|
|
34
34
|
def available_routes
|
35
35
|
@available_routes ||= descendants_with_permissions.map do |descendant, required_permissions|
|
36
|
-
descendant.public_methods(false)
|
36
|
+
action_methods = descendant.public_methods(false)
|
37
|
+
([:index] + action_methods).uniq.map do |action|
|
37
38
|
route(descendant, action, required_permissions)
|
38
39
|
end
|
39
40
|
end.flatten.compact.select(&:rails_route?).sort
|
data/lib/active_element.rb
CHANGED
@@ -12,12 +12,18 @@ 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'
|
17
|
+
require_relative 'active_element/default_controller'
|
18
|
+
require_relative 'active_element/default_record_params'
|
19
|
+
require_relative 'active_element/default_search'
|
20
|
+
require_relative 'active_element/pre_render_processors'
|
16
21
|
require_relative 'active_element/rails_component'
|
17
22
|
require_relative 'active_element/route'
|
18
23
|
require_relative 'active_element/routes'
|
19
24
|
require_relative 'active_element/component'
|
20
25
|
require_relative 'active_element/components'
|
26
|
+
require_relative 'active_element/json_field_schema'
|
21
27
|
require_relative 'active_element/engine'
|
22
28
|
|
23
29
|
# ActiveElement API Admin UI template and menu system.
|
@@ -26,8 +32,11 @@ module ActiveElement
|
|
26
32
|
class UnprotectedRouteError < Error; end
|
27
33
|
class UnknownAttributeError < Error; end
|
28
34
|
|
35
|
+
@eager_loaded = {}
|
36
|
+
|
29
37
|
class << self
|
30
|
-
attr_writer :application_name
|
38
|
+
attr_writer :application_name
|
39
|
+
attr_accessor :navbar_items
|
31
40
|
|
32
41
|
include Paintbrush
|
33
42
|
|
@@ -35,10 +44,6 @@ module ActiveElement
|
|
35
44
|
@application_name || RailsComponent.new(Rails).application_name.titleize
|
36
45
|
end
|
37
46
|
|
38
|
-
def navbar_items(user)
|
39
|
-
@navbar_items || inferred_navbar_items(user)
|
40
|
-
end
|
41
|
-
|
42
47
|
def warning(message)
|
43
48
|
warn "#{log_tag} #{paintbrush { yellow(message) }}"
|
44
49
|
end
|
@@ -47,19 +52,6 @@ module ActiveElement
|
|
47
52
|
paintbrush { cyan "[#{blue 'ActiveElement'}]" }
|
48
53
|
end
|
49
54
|
|
50
|
-
def active_path_class(user:, current_navbar_item:, current_path:, controller_path:, action_name:)
|
51
|
-
if ActiveMenuLink.new(
|
52
|
-
rails_component: RailsComponent.new(Rails),
|
53
|
-
navbar_items: navbar_items(user),
|
54
|
-
current_path: current_path,
|
55
|
-
current_navbar_item: current_navbar_item,
|
56
|
-
controller_path: controller_path,
|
57
|
-
action_name: action_name
|
58
|
-
).active?
|
59
|
-
'active'
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
55
|
def json_pretty_print(json)
|
64
56
|
Components::Util.json_pretty_print(json)
|
65
57
|
end
|
@@ -89,24 +81,15 @@ module ActiveElement
|
|
89
81
|
end
|
90
82
|
|
91
83
|
def eager_load(resource)
|
84
|
+
return if @eager_loaded[resource]
|
85
|
+
|
92
86
|
suffix = resource == :controllers ? '_controller' : nil
|
93
87
|
Rails.root.join("app/#{resource}").glob("**/*#{suffix}.rb").each { |path| require path }
|
88
|
+
@eager_loaded[resource] = true unless Rails.env.development?
|
94
89
|
end
|
95
90
|
|
96
|
-
|
97
|
-
|
98
|
-
def inferred_navbar_items(user)
|
99
|
-
eager_load_controllers
|
100
|
-
user_routes(user).available.select(&:primary?).map do |route|
|
101
|
-
{ path: route.path, title: route.title, spec: route.spec }
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
def user_routes(user)
|
106
|
-
ActiveElement::Routes.new(
|
107
|
-
permissions: user&.permissions,
|
108
|
-
rails_component: ActiveElement::RailsComponent.new(Rails)
|
109
|
-
)
|
91
|
+
def element_id
|
92
|
+
"active-element-#{SecureRandom.uuid}"
|
110
93
|
end
|
111
94
|
end
|
112
95
|
end
|
@@ -1,8 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
namespace :active_element do
|
4
|
-
desc '
|
4
|
+
desc 'Display all permissions used by this application'
|
5
5
|
task permissions: :environment do
|
6
6
|
$stdout.puts ActiveElement::PermissionsReport.new.report
|
7
7
|
end
|
8
|
+
|
9
|
+
namespace :json do
|
10
|
+
desc 'Generate JSON form field schema from database values'
|
11
|
+
task schema: :environment do
|
12
|
+
if ENV.key?('table') && ENV.key?('column')
|
13
|
+
ActiveElement::JsonFieldSchema.new(table: ENV.fetch('table'), column: ENV.fetch('column'))
|
14
|
+
else
|
15
|
+
warn(Paintbrush.paintbrush { red "Expected #{cyan 'table'} and #{cyan 'column'} environment variables." })
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
8
19
|
end
|