active_element 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +12 -0
- data/.strong_versions.yml +2 -0
- data/Gemfile +10 -2
- data/Gemfile.lock +229 -4
- data/Rakefile +1 -0
- data/active_element.gemspec +7 -0
- data/app/assets/config/active_element/manifest.js +2 -0
- data/app/assets/javascripts/active_element/application.js +10 -0
- data/app/assets/javascripts/active_element/confirm.js +67 -0
- data/app/assets/javascripts/active_element/form.js +61 -0
- data/app/assets/javascripts/active_element/json_field.js +316 -0
- data/app/assets/javascripts/active_element/pagination.js +18 -0
- data/app/assets/javascripts/active_element/search_field.js +127 -0
- data/app/assets/javascripts/active_element/secret.js +40 -0
- data/app/assets/javascripts/active_element/setup.js +36 -0
- data/app/assets/javascripts/active_element/theme.js +42 -0
- data/app/assets/stylesheets/active_element/_variables.scss +142 -0
- data/app/assets/stylesheets/active_element/application.scss +77 -0
- data/app/controllers/active_element/application_controller.rb +41 -0
- data/app/controllers/active_element/text_searches_controller.rb +189 -0
- data/app/views/active_element/components/_horizontal_tabs.html.erb +32 -0
- data/app/views/active_element/components/_vertical_tabs.html.erb +38 -0
- data/app/views/active_element/components/button.html.erb +27 -0
- data/app/views/active_element/components/fields/_boolean.html.erb +11 -0
- data/app/views/active_element/components/form/_check_box.html.erb +3 -0
- data/app/views/active_element/components/form/_check_boxes.html.erb +33 -0
- data/app/views/active_element/components/form/_field.html.erb +28 -0
- data/app/views/active_element/components/form/_generic_field.html.erb +3 -0
- data/app/views/active_element/components/form/_json.html.erb +12 -0
- data/app/views/active_element/components/form/_label.html.erb +17 -0
- data/app/views/active_element/components/form/_option_groups_summary.html.erb +17 -0
- data/app/views/active_element/components/form/_select.html.erb +4 -0
- data/app/views/active_element/components/form/_summary.html.erb +40 -0
- data/app/views/active_element/components/form/_templates.html.erb +85 -0
- data/app/views/active_element/components/form/_text_area.html.erb +4 -0
- data/app/views/active_element/components/form/_text_search.html.erb +16 -0
- data/app/views/active_element/components/form.html.erb +78 -0
- data/app/views/active_element/components/json.html.erb +8 -0
- data/app/views/active_element/components/page_description.html.erb +3 -0
- data/app/views/active_element/components/secret/_field.html.erb +1 -0
- data/app/views/active_element/components/secret/_templates.html.erb +11 -0
- data/app/views/active_element/components/table/_collection_row.html.erb +30 -0
- data/app/views/active_element/components/table/_grouped_collection.html.erb +88 -0
- data/app/views/active_element/components/table/_pagination.html.erb +17 -0
- data/app/views/active_element/components/table/_ungrouped_collection.html.erb +49 -0
- data/app/views/active_element/components/table/collection.html.erb +39 -0
- data/app/views/active_element/components/table/item.html.erb +39 -0
- data/app/views/active_element/components/tabs.html.erb +7 -0
- data/app/views/active_element/decorators/_boolean.html.erb +5 -0
- data/app/views/active_element/decorators/_date.html.erb +3 -0
- data/app/views/active_element/decorators/_datetime.html.erb +3 -0
- data/app/views/active_element/decorators/_time.html.erb +3 -0
- data/app/views/active_element/forbidden.html.erb +33 -0
- data/app/views/active_element/main_menu/_item.html.erb +9 -0
- data/app/views/active_element/navbar/_menu.html.erb +30 -0
- data/app/views/active_element/theme/_select.html.erb +1 -0
- data/app/views/active_element/theme/_templates.html.erb +6 -0
- data/app/views/kaminari/_first_page.html.erb +3 -0
- data/app/views/kaminari/_gap.html.erb +3 -0
- data/app/views/kaminari/_last_page.html.erb +3 -0
- data/app/views/kaminari/_next_page.html.erb +3 -0
- data/app/views/kaminari/_page.html.erb +9 -0
- data/app/views/kaminari/_paginator.html.erb +17 -0
- data/app/views/kaminari/_prev_page.html.erb +3 -0
- data/app/views/layouts/active_element.html.erb +65 -0
- data/app/views/layouts/active_element_error.html.erb +40 -0
- data/config/routes.rb +5 -0
- data/lib/active_element/active_menu_link.rb +80 -0
- data/lib/active_element/active_record_text_search_authorization.rb +12 -0
- data/lib/active_element/colorized_string.rb +33 -0
- data/lib/active_element/component.rb +122 -0
- data/lib/active_element/components/button.rb +156 -0
- data/lib/active_element/components/collection_table.rb +118 -0
- data/lib/active_element/components/form.rb +210 -0
- data/lib/active_element/components/item_table.rb +57 -0
- data/lib/active_element/components/json.rb +31 -0
- data/lib/active_element/components/link_helpers.rb +9 -0
- data/lib/active_element/components/page_description.rb +28 -0
- data/lib/active_element/components/secret_fields.rb +15 -0
- data/lib/active_element/components/tab.rb +37 -0
- data/lib/active_element/components/tabs.rb +35 -0
- data/lib/active_element/components/translations.rb +18 -0
- data/lib/active_element/components/util/association_mapping.rb +80 -0
- data/lib/active_element/components/util/decorator.rb +107 -0
- data/lib/active_element/components/util/display_value_mapping.rb +48 -0
- data/lib/active_element/components/util/field_mapping.rb +144 -0
- data/lib/active_element/components/util/form_field_mapping.rb +104 -0
- data/lib/active_element/components/util/form_value_mapping.rb +49 -0
- data/lib/active_element/components/util/i18n.rb +66 -0
- data/lib/active_element/components/util/record_mapping.rb +111 -0
- data/lib/active_element/components/util.rb +43 -0
- data/lib/active_element/components.rb +20 -0
- data/lib/active_element/controller_action.rb +91 -0
- data/lib/active_element/engine.rb +26 -0
- data/lib/active_element/permissions_check.rb +101 -0
- data/lib/active_element/rails_component.rb +40 -0
- data/lib/active_element/route.rb +112 -0
- data/lib/active_element/routes.rb +62 -0
- data/lib/active_element/version.rb +1 -1
- data/lib/active_element.rb +91 -1
- data/lib/tasks/active_element.rake +23 -0
- data/rspec-documentation/dummy +1 -0
- data/rspec-documentation/pages/Components/Forms.md +1 -0
- data/rspec-documentation/pages/Components/Tables.md +47 -0
- data/rspec-documentation/pages/Components/Tabs.md +1 -0
- data/rspec-documentation/pages/Components.md +1 -0
- data/rspec-documentation/pages/Decorators/Inline Decorators.md +1 -0
- data/rspec-documentation/pages/Decorators/View Decorators.md +1 -0
- data/rspec-documentation/pages/Index.md +3 -0
- data/rspec-documentation/pages/Util/I18n.md +1 -0
- data/rspec-documentation/spec_helper.rb +35 -0
- metadata +191 -3
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# A form component for rendering a standard form with various inputs in a uniform manner,
|
6
|
+
# includes validation errors.
|
7
|
+
class Form # rubocop:disable Metrics/ClassLength
|
8
|
+
include Translations
|
9
|
+
|
10
|
+
attr_reader :controller
|
11
|
+
|
12
|
+
# rubocop:disable Metrics/MethodLength
|
13
|
+
def initialize(controller, fields:, submit:, item:, title: nil, destroy: false,
|
14
|
+
modal: false, columns: 1, expanded: true, **kwargs)
|
15
|
+
@controller = controller
|
16
|
+
@fields = fields
|
17
|
+
@title = title
|
18
|
+
@submit = submit
|
19
|
+
@destroy = destroy
|
20
|
+
@expanded = expanded
|
21
|
+
@item = item
|
22
|
+
@modal = modal
|
23
|
+
@kwargs = kwargs
|
24
|
+
@columns = columns
|
25
|
+
@method = kwargs.delete(:method) { default_method }.to_s.downcase.to_sym
|
26
|
+
end
|
27
|
+
# rubocop:enable Metrics/MethodLength
|
28
|
+
|
29
|
+
def template
|
30
|
+
'active_element/components/form'
|
31
|
+
end
|
32
|
+
|
33
|
+
def locals # rubocop:disable Metrics/MethodLength
|
34
|
+
{
|
35
|
+
component: self,
|
36
|
+
fields: Util::FormFieldMapping.new(record, fields, i18n).fields_with_types_and_options,
|
37
|
+
record: record,
|
38
|
+
submit_label: submit_label,
|
39
|
+
submit_position: submit_position,
|
40
|
+
class_name: class_name,
|
41
|
+
method: method,
|
42
|
+
kwargs: kwargs,
|
43
|
+
destroy: destroy,
|
44
|
+
modal: modal,
|
45
|
+
expanded: expanded,
|
46
|
+
columns: columns,
|
47
|
+
title: title,
|
48
|
+
id: form_id
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
def class_name
|
53
|
+
[default_class_name, kwargs.fetch(:class, nil)].compact.join(' ')
|
54
|
+
end
|
55
|
+
|
56
|
+
def options_for_select(field, field_options)
|
57
|
+
return [['', '']] + base_options_for_select(field, field_options) unless field_options[:blank] == false
|
58
|
+
|
59
|
+
base_options_for_select(field, field_options)
|
60
|
+
end
|
61
|
+
|
62
|
+
def valid?(field = nil)
|
63
|
+
return true if record.blank? || !record.changed?
|
64
|
+
|
65
|
+
record.valid?
|
66
|
+
|
67
|
+
return record&.errors.blank? if field.nil?
|
68
|
+
|
69
|
+
valid_field?(field)
|
70
|
+
end
|
71
|
+
|
72
|
+
def name_for(form, schema_field, type:)
|
73
|
+
base = "#{form.object_name}[#{schema_field.fetch(:name)}]"
|
74
|
+
type == :array ? "#{base}[]" : base
|
75
|
+
end
|
76
|
+
|
77
|
+
def schema_for(field, options)
|
78
|
+
options.key?(:schema) ? options.fetch(:schema) : schema_from_yaml(field)
|
79
|
+
end
|
80
|
+
|
81
|
+
def schema_from_yaml(field)
|
82
|
+
YAML.safe_load(
|
83
|
+
Rails.root.join("config/forms/#{record.class.name.underscore}/#{field}.yml").read,
|
84
|
+
symbolize_names: true
|
85
|
+
)
|
86
|
+
end
|
87
|
+
|
88
|
+
def display_value_for_select(field, options)
|
89
|
+
options_for_select(field, options).find do |_display_value, value|
|
90
|
+
value == value_for(field)
|
91
|
+
end&.first
|
92
|
+
end
|
93
|
+
|
94
|
+
def value_for(field, default = nil)
|
95
|
+
return form_value_mapping_value(field) if record.class.is_a?(ActiveModel::Naming)
|
96
|
+
return default_record_value(field, default) if record.present? && record.respond_to?(field)
|
97
|
+
return item[field].presence || default if item.present?
|
98
|
+
|
99
|
+
default
|
100
|
+
end
|
101
|
+
|
102
|
+
def form_value_mapping_value(field)
|
103
|
+
Util::FormValueMapping.new(component: self, record: record, field: field).value
|
104
|
+
end
|
105
|
+
|
106
|
+
def default_record_value(field, default)
|
107
|
+
record&.public_send(field).presence || default
|
108
|
+
end
|
109
|
+
|
110
|
+
def options_for_json_array_field(options)
|
111
|
+
options.map { |option| option.is_a?(Array) ? option : [option, option] }
|
112
|
+
end
|
113
|
+
|
114
|
+
def value_for_json_array_field(field, schema_field, element_index = nil)
|
115
|
+
array = value_for(field, {}).fetch(schema_field[:name], [])
|
116
|
+
return array if element_index.nil?
|
117
|
+
|
118
|
+
array.fetch(element_index, nil)
|
119
|
+
end
|
120
|
+
|
121
|
+
def record
|
122
|
+
return nil if kwargs.fetch(:model, nil).blank?
|
123
|
+
|
124
|
+
kwargs[:model].is_a?(Array) ? kwargs[:model].last : kwargs[:model]
|
125
|
+
end
|
126
|
+
|
127
|
+
def model
|
128
|
+
record&.class
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
attr_reader :fields, :submit, :title, :kwargs, :item, :method, :destroy, :modal, :expanded, :columns
|
134
|
+
|
135
|
+
def valid_field?(field)
|
136
|
+
return true if record.respond_to?("#{field}_changed?") && !record.public_send("#{field}_changed?")
|
137
|
+
|
138
|
+
record&.errors.blank? || record.errors.full_messages_for(field).blank?
|
139
|
+
end
|
140
|
+
|
141
|
+
def submit_position
|
142
|
+
return submit[:position] if submit.is_a?(Hash) && submit[:position].present?
|
143
|
+
|
144
|
+
:bottom
|
145
|
+
end
|
146
|
+
|
147
|
+
def submit_label
|
148
|
+
return submit[:label] if submit.is_a?(Hash) && submit[:label].present?
|
149
|
+
return submit if submit.is_a?(String)
|
150
|
+
return submit_label_from_model if record.present? && submit_label_from_model.present?
|
151
|
+
|
152
|
+
'Submit'
|
153
|
+
end
|
154
|
+
|
155
|
+
def submit_label_from_model
|
156
|
+
return "Create #{humanized_model_name}" if record.present? && method == :post
|
157
|
+
return "Update #{humanized_model_name}" if record.present? && %i[patch put].include?(method)
|
158
|
+
|
159
|
+
nil
|
160
|
+
end
|
161
|
+
|
162
|
+
def humanized_model_name
|
163
|
+
record.class.name.titleize
|
164
|
+
end
|
165
|
+
|
166
|
+
def base_options_for_select(field, field_options)
|
167
|
+
return normalized_options(field_options.fetch(:options)) if field_options.key?(:options)
|
168
|
+
return default_options_for_select(field, field_options) if record.class.is_a?(ActiveModel::Naming)
|
169
|
+
|
170
|
+
raise ArgumentError, "Must provide select options `[:#{field}, { options: [...] }]` or a record instance."
|
171
|
+
end
|
172
|
+
|
173
|
+
def normalized_options(options)
|
174
|
+
options.map { |option| option.is_a?(Array) ? option : [option, option] }
|
175
|
+
end
|
176
|
+
|
177
|
+
def default_class_name
|
178
|
+
return nil if record.blank?
|
179
|
+
|
180
|
+
Util::I18n.class_name(record.class.name)
|
181
|
+
end
|
182
|
+
|
183
|
+
def default_options_for_select(field, field_options)
|
184
|
+
options = record.class.distinct.order(field).pluck(field)
|
185
|
+
[options.map { |option| autoformat(option, field_options) }, options].transpose
|
186
|
+
end
|
187
|
+
|
188
|
+
def form_id
|
189
|
+
kwargs.fetch(:id) { "form-#{SecureRandom.uuid}" }
|
190
|
+
end
|
191
|
+
|
192
|
+
def autoformat(val, field_options)
|
193
|
+
return val unless field_options[:autoformat] || !field_options.key?(:autoformat)
|
194
|
+
|
195
|
+
val.titleize
|
196
|
+
end
|
197
|
+
|
198
|
+
def default_method
|
199
|
+
case controller.action_name
|
200
|
+
when 'edit'
|
201
|
+
'PATCH'
|
202
|
+
when 'index'
|
203
|
+
'GET'
|
204
|
+
else
|
205
|
+
'POST'
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# A table component for rendering the fields of a single object horizontally.
|
6
|
+
class ItemTable
|
7
|
+
include LinkHelpers
|
8
|
+
include SecretFields
|
9
|
+
|
10
|
+
attr_reader :controller, :model_name
|
11
|
+
|
12
|
+
def initialize(controller, item:, fields:, class_name: nil, model_name: nil,
|
13
|
+
edit: false, new: false, destroy: false, style: nil, row_class: nil, **_kwargs)
|
14
|
+
@controller = controller
|
15
|
+
@class_name = class_name
|
16
|
+
@model_name = model_name
|
17
|
+
@item = item
|
18
|
+
@fields = fields
|
19
|
+
@destroy = destroy
|
20
|
+
@edit = edit
|
21
|
+
@new = new
|
22
|
+
@style = style
|
23
|
+
@row_class = row_class
|
24
|
+
end
|
25
|
+
|
26
|
+
def template
|
27
|
+
'active_element/components/table/item'
|
28
|
+
end
|
29
|
+
|
30
|
+
def locals # rubocop:disable Metrics/MethodLength
|
31
|
+
{
|
32
|
+
component: self,
|
33
|
+
class_name: class_name,
|
34
|
+
item: item,
|
35
|
+
fields: Util::FieldMapping.new(self, fields, class_name).mapped_fields,
|
36
|
+
destroy: destroy,
|
37
|
+
edit: edit,
|
38
|
+
new: new,
|
39
|
+
style: style,
|
40
|
+
row_class_mapper: row_class_mapper
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def model
|
45
|
+
item.class.is_a?(ActiveModel::Naming) ? item.class : nil
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
attr_reader :class_name, :item, :fields, :edit, :new, :destroy, :style, :row_class
|
51
|
+
|
52
|
+
def row_class_mapper
|
53
|
+
row_class.is_a?(Proc) ? row_class : proc { row_class }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# Outputs a `<script>` tag and sets globally-available JSON data, available as `ActiveElement.jsonData.<key>`.
|
6
|
+
# Note key is camelized, so `foo_bar_baz` becomes `fooBarBaz`.
|
7
|
+
class Json
|
8
|
+
def initialize(controller, object:, key:)
|
9
|
+
@controller = controller
|
10
|
+
@object = object
|
11
|
+
@key = key
|
12
|
+
end
|
13
|
+
|
14
|
+
def template
|
15
|
+
'active_element/components/json'
|
16
|
+
end
|
17
|
+
|
18
|
+
def locals
|
19
|
+
{
|
20
|
+
controller: controller,
|
21
|
+
object: object,
|
22
|
+
key: ActiveElement::Components::Util.json_name(key)
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
attr_reader :controller, :object, :key
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# Provides a description of a page, intended to be used underneath the `page_title` component.
|
6
|
+
class PageDescription
|
7
|
+
def initialize(controller, content:)
|
8
|
+
@controller = controller
|
9
|
+
@content = content
|
10
|
+
end
|
11
|
+
|
12
|
+
def template
|
13
|
+
'active_element/components/page_description'
|
14
|
+
end
|
15
|
+
|
16
|
+
def locals
|
17
|
+
{
|
18
|
+
component: self,
|
19
|
+
content: content
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :controller, :content
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# Provides a convenience method for detecting a field should be classified as a secret, used
|
6
|
+
# for censoring values or selecting the default form field type as `password_field`, etc.
|
7
|
+
module SecretFields
|
8
|
+
SECRET_FIELDS = %w[secret password].freeze
|
9
|
+
|
10
|
+
def secret_field?(field)
|
11
|
+
SECRET_FIELDS.any? { |secret_field| field.to_s.downcase.include?(secret_field) }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# One navigation tab in a Tabs component.
|
6
|
+
class Tab
|
7
|
+
attr_reader :title, :path, :content
|
8
|
+
|
9
|
+
def initialize(controller, title:, path:, &block)
|
10
|
+
@controller = controller
|
11
|
+
@title = title
|
12
|
+
@path = path
|
13
|
+
@content = block_given? ? block.call : ''
|
14
|
+
end
|
15
|
+
|
16
|
+
def selected?
|
17
|
+
controller.request.fullpath == path
|
18
|
+
end
|
19
|
+
|
20
|
+
def identifier
|
21
|
+
Util::I18n.class_name(title)
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
''
|
26
|
+
end
|
27
|
+
|
28
|
+
def tab(title)
|
29
|
+
yield Tab.new(title)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
attr_reader :controller
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# Navigation tabs component.
|
6
|
+
class Tabs
|
7
|
+
def initialize(controller, class_name:)
|
8
|
+
@controller = controller
|
9
|
+
@class_name = class_name
|
10
|
+
@tabs = []
|
11
|
+
yield self
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
''
|
16
|
+
end
|
17
|
+
|
18
|
+
def tab(title:, path:, &block)
|
19
|
+
Tab.new(controller, title: title, path: path, &block).tap { |tab| @tabs << tab }
|
20
|
+
end
|
21
|
+
|
22
|
+
def template
|
23
|
+
'active_element/components/tabs'
|
24
|
+
end
|
25
|
+
|
26
|
+
def locals
|
27
|
+
{ tabs: tabs, class_name: class_name }
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
attr_reader :tabs, :controller, :class_name
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
# Provides `#i18n` method as standard entrypoint to translation point, specifies required
|
6
|
+
# interface for classes that use this module.
|
7
|
+
module Translations
|
8
|
+
def i18n
|
9
|
+
@i18n ||= Util::I18n.new(self)
|
10
|
+
end
|
11
|
+
|
12
|
+
def model
|
13
|
+
raise NotImplementedError,
|
14
|
+
'Component must implement `#model` and return `nil` or an ActiveRecord model class.'
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
module Util
|
6
|
+
# Utility class for mapping an association to a linked URL (e.g. for displaying associations in a table).
|
7
|
+
class AssociationMapping
|
8
|
+
def initialize(component:, field:, record:, associated_record:, options:)
|
9
|
+
@component = component
|
10
|
+
@field = field
|
11
|
+
@record = record
|
12
|
+
@associated_record = associated_record
|
13
|
+
@options = options || {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def link_tag
|
17
|
+
verify_display_attribute
|
18
|
+
return display_value if associated_record_path.blank?
|
19
|
+
|
20
|
+
component.controller.helpers.link_to(display_value, associated_record_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :component, :field, :record, :associated_record, :options
|
26
|
+
|
27
|
+
def associated_record_path
|
28
|
+
return nil unless component.controller.helpers.respond_to?(path_helper)
|
29
|
+
|
30
|
+
component.controller.helpers.public_send(path_helper, associated_record)
|
31
|
+
end
|
32
|
+
|
33
|
+
def verify_display_attribute
|
34
|
+
return if display_field.present?
|
35
|
+
|
36
|
+
raise ArgumentError,
|
37
|
+
"Must provide { attribute: :example_attribute } for `#{field}` or define " \
|
38
|
+
"`#{associated_record.class.name}.default_display_attribute`"
|
39
|
+
end
|
40
|
+
|
41
|
+
def display_value
|
42
|
+
associated_record.public_send(display_field)
|
43
|
+
end
|
44
|
+
|
45
|
+
def display_field
|
46
|
+
@display_field ||= options.fetch(:attribute) do
|
47
|
+
next associated_record.class.default_display_attribute if defined_display_attribute?
|
48
|
+
next default_display_attribute if default_display_attribute.present?
|
49
|
+
|
50
|
+
associated_record.class.primary_key
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def defined_display_attribute?
|
55
|
+
associated_record.class.respond_to?(:default_display_attribute)
|
56
|
+
end
|
57
|
+
|
58
|
+
def namespace
|
59
|
+
component.controller.class.name.deconstantize.underscore
|
60
|
+
end
|
61
|
+
|
62
|
+
def resource_name
|
63
|
+
record.public_send(field).model_name.singular
|
64
|
+
end
|
65
|
+
|
66
|
+
def default_display_attribute
|
67
|
+
%i[name email display_name username].find do |display_field|
|
68
|
+
associated_record.respond_to?(display_field) && associated_record.method(display_field).arity.zero?
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def path_helper
|
73
|
+
return "#{resource_name}_path" if namespace.blank?
|
74
|
+
|
75
|
+
"#{namespace}_#{resource_name}_path"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
module Util
|
6
|
+
# Decorates a field by rendering a user-provided partial found in
|
7
|
+
# app/views/decorators/<model-name-plural>/_<field-name>.html.erb
|
8
|
+
class Decorator
|
9
|
+
def initialize(component:, item:, field:, value:)
|
10
|
+
@component = component
|
11
|
+
@item = item
|
12
|
+
@field = field
|
13
|
+
@value = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def decorated_value
|
17
|
+
return default_decorated_value unless decorate?
|
18
|
+
|
19
|
+
render
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :component, :item, :field, :value
|
25
|
+
|
26
|
+
def render(sti: false)
|
27
|
+
component.controller.render_to_string(partial: decorator_path(sti: sti), locals: locals)
|
28
|
+
rescue ActionView::MissingTemplate
|
29
|
+
if sti
|
30
|
+
component.controller.missing_template_store[decorator_path] = true
|
31
|
+
default_decorated_value
|
32
|
+
else
|
33
|
+
render(sti: true)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def locals
|
38
|
+
{
|
39
|
+
record: item,
|
40
|
+
field: field,
|
41
|
+
default: value,
|
42
|
+
value: value, # Provide both default and value, default might change in future.
|
43
|
+
context: component.class.name.demodulize.underscore
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
def decorate?
|
48
|
+
return false unless item.class.is_a?(ActiveModel::Naming) || component.model_name.present?
|
49
|
+
|
50
|
+
!missing_template?
|
51
|
+
end
|
52
|
+
|
53
|
+
def missing_template?
|
54
|
+
component.controller.missing_template_store[decorator_path].present?
|
55
|
+
end
|
56
|
+
|
57
|
+
def decorator_path(sti: false)
|
58
|
+
Rails.root.join(
|
59
|
+
'app/views/decorators',
|
60
|
+
model_path_name(sti: sti).pluralize,
|
61
|
+
field.to_s
|
62
|
+
).relative_path_from(Rails.root.join('app/views')).to_s
|
63
|
+
end
|
64
|
+
|
65
|
+
def model_path_name(sti:)
|
66
|
+
return component.model_name if component.model_name.present?
|
67
|
+
return record_name(sti: sti) if record_name(sti: sti).present?
|
68
|
+
return item_record_name if item_record_name.present?
|
69
|
+
|
70
|
+
item.class.name
|
71
|
+
end
|
72
|
+
|
73
|
+
def record_name(sti:)
|
74
|
+
return Util.sti_record_name(item) if sti
|
75
|
+
|
76
|
+
Util.record_name(item).presence
|
77
|
+
end
|
78
|
+
|
79
|
+
def item_record_name
|
80
|
+
item.try(:model_name)&.singular
|
81
|
+
end
|
82
|
+
|
83
|
+
def render_with_default_decorator(template)
|
84
|
+
component.controller.render_to_string(
|
85
|
+
partial: "active_element/decorators/#{template}",
|
86
|
+
locals: { value: value }
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def default_decorated_value # rubocop:disable Metrics/MethodLength
|
91
|
+
case value
|
92
|
+
when true, false
|
93
|
+
render_with_default_decorator('boolean')
|
94
|
+
when DateTime
|
95
|
+
render_with_default_decorator('datetime')
|
96
|
+
when Date
|
97
|
+
render_with_default_decorator('date')
|
98
|
+
when Time
|
99
|
+
render_with_default_decorator('time')
|
100
|
+
else
|
101
|
+
value
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveElement
|
4
|
+
module Components
|
5
|
+
module Util
|
6
|
+
# Maps ActiveRecord record fields to values for display (e.g. in tables).
|
7
|
+
class DisplayValueMapping
|
8
|
+
include RecordMapping
|
9
|
+
|
10
|
+
def numeric_value
|
11
|
+
value_from_record
|
12
|
+
end
|
13
|
+
|
14
|
+
def json_value
|
15
|
+
ActiveElement.json_pretty_print(value_from_record)
|
16
|
+
end
|
17
|
+
|
18
|
+
def string_value
|
19
|
+
value_from_record
|
20
|
+
end
|
21
|
+
|
22
|
+
def datetime_value
|
23
|
+
value_from_record.strftime('%Y-%m-%d %H:%M:%S')
|
24
|
+
end
|
25
|
+
|
26
|
+
def time_value
|
27
|
+
value_from_record.strftime('%H:%M:%S')
|
28
|
+
end
|
29
|
+
|
30
|
+
def date_value
|
31
|
+
value_from_record.strftime('%Y-%m-%d')
|
32
|
+
end
|
33
|
+
|
34
|
+
def boolean_value
|
35
|
+
component.controller.render_to_string(
|
36
|
+
partial: 'active_element/components/fields/boolean',
|
37
|
+
locals: { value: value_from_record }
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
def geometry_value
|
42
|
+
require 'rgeo/geo_json'
|
43
|
+
Util.json_pretty_print(RGeo::GeoJSON.encode(value_from_record))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|