vident 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ if Gem.loaded_specs.has_key? "dry-struct"
6
+ require_relative "./types"
7
+ require_relative "./typed_niling_struct"
8
+
9
+ module Vident
10
+ # Adapts Dry Types to confinus Typed Attributes. We use dry-struct (see ::Core::NilingStruct) but
11
+ # we could probably also use dry-initializer directly, saving us from maintaining the schema.
12
+ module Attributes
13
+ module Typed
14
+ extend ActiveSupport::Concern
15
+
16
+ def prepare_attributes(attributes)
17
+ @__attributes = self.class.schema.new(**attributes)
18
+ end
19
+
20
+ def attributes
21
+ @__attributes.attributes
22
+ end
23
+
24
+ def attribute_names
25
+ @attribute_names ||= self.class.attribute_names
26
+ end
27
+
28
+ def attribute(key)
29
+ if Rails.env.development? && !key?(key)
30
+ raise StandardError, "Attribute #{key} not found in #{self.class.name}"
31
+ end
32
+ @__attributes.attributes[key]
33
+ end
34
+ alias_method :[], :attribute
35
+
36
+ def key?(key)
37
+ self.class.schema.attribute_names.include?(key)
38
+ end
39
+
40
+ def to_hash
41
+ @__attributes.to_h
42
+ end
43
+
44
+ class_methods do
45
+ def inherited(subclass)
46
+ subclass.instance_variable_set(:@schema, @schema.clone)
47
+ subclass.instance_variable_set(:@attribute_ivar_names, @attribute_ivar_names.clone)
48
+ super
49
+ end
50
+
51
+ def attribute_names
52
+ schema.attribute_names
53
+ end
54
+
55
+ def attribute_metadata(key)
56
+ schema.schema.key(key).meta
57
+ end
58
+
59
+ attr_reader :schema, :attribute_ivar_names
60
+
61
+ def attribute(name, type = :any, **options)
62
+ type_info = map_primitive_to_dry_type(type, !options[:convert])
63
+ type_info = set_constraints(type_info, type, options)
64
+ define_on_schema(name, type_info, options)
65
+ end
66
+
67
+ private
68
+
69
+ def set_constraints(type_info, specified_type, options)
70
+ member_klass = options[:type] || options[:sub_type]
71
+ if member_klass && type_info.respond_to?(:of)
72
+ # Sub types of collections currently can be nil - this should be an option
73
+ type_info = type_info.of(
74
+ map_primitive_to_dry_type(member_klass, !options[:convert]).optional.meta(required: false)
75
+ )
76
+ end
77
+ type_info = type_info.optional.meta(required: false) if allows_nil?(options)
78
+ type_info = type_info.constrained(filled: true) unless allows_blank?(options)
79
+ if options[:default]&.is_a?(Proc)
80
+ type_info = type_info.default(options[:default].freeze)
81
+ elsif !options[:default].nil?
82
+ type_info = type_info.default(->(_) { options[:default] }.freeze)
83
+ end
84
+ type_info = type_info.constrained(included_in: options[:in].freeze) if options[:in]
85
+
86
+ # Store adapter type info in the schema for use by typed form
87
+ metadata = {typed_attribute_type: specified_type, typed_attribute_options: options}
88
+ type_info.meta(**metadata)
89
+ end
90
+
91
+ def delegates?(options)
92
+ options[:delegates] != false
93
+ end
94
+
95
+ def define_on_schema(name, type_info, options)
96
+ @attribute_ivar_names ||= {}
97
+ @attribute_ivar_names[name] = :"@#{name}"
98
+ define_attribute_delegate(name) if delegates?(options)
99
+ @schema ||= Class.new(Vident::Attributes::TypedNilingStruct)
100
+ @schema.attribute name, type_info
101
+ end
102
+
103
+ def define_attribute_delegate(attr_name)
104
+ # Define reader & presence check method
105
+ class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
106
+ def #{attr_name}
107
+ @__attributes.attributes[:#{attr_name}]
108
+ end
109
+
110
+ def #{attr_name}?
111
+ send(:#{attr_name}).present?
112
+ end
113
+ RUBY
114
+ end
115
+
116
+ def allows_nil?(options)
117
+ return true unless options
118
+ allow_nil = options[:allow_nil]
119
+ return false if allow_nil == false
120
+ allow_nil || allows_blank?(options)
121
+ end
122
+
123
+ def allows_blank?(options)
124
+ return true unless options
125
+ allow_blank = options[:allow_blank]
126
+ allow_blank.nil? ? true : allow_blank
127
+ end
128
+
129
+ def map_primitive_to_dry_type(type, strict)
130
+ if type == :any
131
+ Types::Nominal::Any
132
+ elsif type == Integer
133
+ strict ? Types::Strict::Integer : Types::Params::Integer
134
+ elsif type == BigDecimal
135
+ strict ? Types::Strict::Decimal : Types::Params::Decimal
136
+ elsif type == Float
137
+ strict ? Types::Strict::Float : Types::Params::Float
138
+ elsif type == Numeric
139
+ if strict
140
+ Types::Strict::Float | Types::Strict::Integer | Types::Strict::Decimal
141
+ else
142
+ Types::Params::Float | Types::Params::Integer | Types::Params::Decimal
143
+ end
144
+ elsif type == Symbol
145
+ strict ? Types::Strict::Symbol : Types::Coercible::Symbol
146
+ elsif type == String
147
+ strict ? Types::Strict::String : Types::Coercible::String
148
+ elsif type == Time
149
+ strict ? Types::Strict::Time : Types::Params::Time
150
+ elsif type == Date
151
+ strict ? Types::Strict::Date : Types::Params::Date
152
+ elsif type == Array
153
+ strict ? Types::Strict::Array : Types::Params::Array
154
+ elsif type == Hash
155
+ strict ? Types::Strict::Hash : Types::Coercible::Hash
156
+ elsif type == :boolean
157
+ strict ? Types::Strict::Bool : Types::Params::Bool
158
+ elsif strict
159
+ # when strict create a Nominal type with a is_a? constraint, otherwise create a Nominal type which constructs
160
+ # values using the default constructor, `new`.
161
+ Types.Instance(type)
162
+ else
163
+ Types.Constructor(type) { |values| type.new(**values) }
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ else
171
+ module Vident
172
+ module Attributes
173
+ module Typed
174
+ def self.included(base)
175
+ raise "Vident::Attributes::Typed requires dry-struct to be installed"
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Attributes
5
+ # A dry struct that is loose about keys provided on initialization. It sets them to nil if not provided.
6
+ # It is strict about types but not about provided keys
7
+ class TypedNilingStruct < ::Dry::Struct
8
+ # convert string keys to symbols
9
+ transform_keys(&:to_sym)
10
+
11
+ # resolve default types on nil
12
+ transform_types do |type|
13
+ if type.default?
14
+ type.constructor { |value| value.nil? ? ::Dry::Types::Undefined : value }
15
+ else
16
+ type
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def check_schema_duplication(new_keys)
22
+ # allow overriding keys
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ module Vident
6
+ module Attributes
7
+ module Types
8
+ include ::Dry.Types
9
+
10
+ StrippedString = Types::String.constructor(&:strip)
11
+ BooleanDefaultFalse = Types::Bool.default(false)
12
+ BooleanDefaultTrue = Types::Bool.default(true)
13
+ HashDefaultEmpty = Types::Hash.default({}.freeze)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Base
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def no_stimulus_controller
9
+ @no_stimulus_controller = true
10
+ end
11
+
12
+ def stimulus_controller?
13
+ !@no_stimulus_controller
14
+ end
15
+
16
+ # The "name" of the component from its class name and namespace. This is used to generate a HTML class name
17
+ # that can helps identify the component type in the DOM or for styling purposes.
18
+ def component_name
19
+ @component_name ||= stimulus_identifier
20
+ end
21
+
22
+ def slots?
23
+ registered_slots.present?
24
+ end
25
+
26
+ # TODO: move stuff related to cache key to a module
27
+
28
+ # TypedComponents can be used with fragment caching, but you need to be careful! Read on...
29
+ #
30
+ # <% cache component do %>
31
+ # <%= render component %>
32
+ # <% end %>
33
+ #
34
+ # The most important point is that Rails cannot track dependencies on the component itself, so you need to
35
+ # be careful to be explicit on the attributes, and manually specify any sub Viewcomponent dependencies that the
36
+ # component has. The assumption is that the subcomponent takes any attributes from the parent, so the cache key
37
+ # depends on the parent component attributes. Otherwise changes to the parent or sub component views/Ruby class
38
+ # will result in different cache keys too. Of course if you invalidate all cache keys with a modifier on deploy
39
+ # then no need to worry about changing the cache key on component changes, only on attribute/data changes.
40
+ #
41
+ # A big caveat is that the cache key cannot depend on anything related to the view_context of the component (such
42
+ # as `helpers` as the key is created before the rending pipline is invoked (which is when the view_context is set).
43
+ def depends_on(*klasses)
44
+ @component_dependencies ||= []
45
+ @component_dependencies += klasses
46
+ end
47
+
48
+ attr_reader :component_dependencies
49
+
50
+ def with_cache_key(*attrs, name: :_collection)
51
+ raise StandardError, "with_cache_key can only be used on components *without* slots as there is no eary way to track their content changes so too risky" if slots?
52
+ # Add view file to cache key
53
+ attrs << :component_modified_time
54
+ super
55
+ end
56
+
57
+ def component_modified_time
58
+ return @component_modified_time if Rails.env.production? && @component_modified_time
59
+ # FIXME: This could stack overflow if there are circular dependencies
60
+ deps = component_dependencies&.map(&:component_modified_time)&.join("-") || ""
61
+ @component_modified_time = deps + sidecar_view_modified_time + rb_component_modified_time
62
+ end
63
+
64
+ def sidecar_view_modified_time
65
+ return @sidecar_view_modified_time if Rails.env.production? && defined?(@sidecar_view_modified_time)
66
+ @sidecar_view_modified_time = ::File.exist?(template_path) ? ::File.mtime(template_path).to_i.to_s : ""
67
+ end
68
+
69
+ def rb_component_modified_time
70
+ return @rb_component_modified_time if Rails.env.production? && defined?(@rb_component_modified_time)
71
+ @rb_component_modified_time = ::File.exist?(component_path) ? ::File.mtime(component_path).to_i.to_s : ""
72
+ end
73
+
74
+ def template_path
75
+ File.join components_base_path, "#{virtual_path}.html.erb"
76
+ end
77
+
78
+ def component_path
79
+ File.join components_base_path, "#{virtual_path}.rb"
80
+ end
81
+
82
+ def components_base_path
83
+ ::Rails.configuration.view_component.view_component_path || "app/components"
84
+ end
85
+
86
+ # Dont check collection params, we use kwargs
87
+ def validate_collection_parameter!(validate_default: false)
88
+ end
89
+
90
+ # stimulus controller identifier
91
+ def stimulus_identifier
92
+ stimulus_identifier_from_path(identifier_name_path)
93
+ end
94
+
95
+ def identifier_name_path
96
+ if ancestors.include?(Phlex::HTML)
97
+ name.remove("Views::").underscore
98
+ else
99
+ name.underscore
100
+ end
101
+ end
102
+
103
+ def stimulus_identifier_from_path(path)
104
+ path.split("/").map { |p| p.to_s.dasherize }.join("--")
105
+ end
106
+
107
+ private
108
+
109
+ # Define reader & presence check method, for performance use ivar directly
110
+ def define_attribute_delegate(attr_name)
111
+ class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
112
+ def #{attr_name}
113
+ #{@attribute_ivar_names[attr_name]}
114
+ end
115
+
116
+ def #{attr_name}?
117
+ #{@attribute_ivar_names[attr_name]}.present?
118
+ end
119
+ RUBY
120
+ end
121
+ end
122
+
123
+ def prepare_attributes(attributes)
124
+ raise NotImplementedError
125
+ end
126
+
127
+ # Override this method to perform any initialisation before attributes are set
128
+ def before_initialise(_attrs)
129
+ end
130
+
131
+ # Override this method to perform any initialisation after attributes are set
132
+ def after_initialise
133
+ end
134
+
135
+ def clone(overrides = {})
136
+ new_set = to_hash.merge(**overrides)
137
+ self.class.new(**new_set)
138
+ end
139
+
140
+ def inspect(klass_name = "Component")
141
+ attr_text = attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(", ")
142
+ "#<#{self.class.name}<Vident::#{klass_name}> #{attr_text}>"
143
+ end
144
+
145
+ # Generate a unique ID for a component, can be overridden as required. Makes it easier to setup things like ARIA
146
+ # attributes which require elements to reference by ID. Note this overrides the `id` accessor
147
+ def id
148
+ @id.presence || random_id
149
+ end
150
+
151
+ # Methods to use in component views
152
+ # ---------------------------------
153
+
154
+ delegate :params, to: :helpers
155
+
156
+ # HTML and attribute definition and creation
157
+
158
+ # Helper to create the main element
159
+ def parent_element(**options)
160
+ @parent_element ||= begin
161
+ # Note: we cant mix phlex and view_component render contexts
162
+ klass = if self.class.ancestors.include?(Phlex::HTML)
163
+ RootComponent::UsingPhlexHTML
164
+ else
165
+ RootComponent::UsingViewComponent
166
+ end
167
+ klass.new(**stimulus_options_for_component(options))
168
+ end
169
+ end
170
+ alias_method :root, :parent_element
171
+
172
+ # FIXME: if we call them before `root` we will setup the root element before we intended
173
+ # The separation between component and root element is a bit messy. Might need rethinking.
174
+ delegate :action, :target, :named_classes, to: :root
175
+
176
+ # This can be overridden to return an array of extra class names
177
+ def element_classes
178
+ end
179
+
180
+ # A HTML class name that can helps identify the component type in the DOM or for styling purposes.
181
+ def component_class_name
182
+ self.class.component_name
183
+ end
184
+ alias_method :js_event_name_prefix, :component_class_name
185
+
186
+ # Generates the full list of HTML classes for the component
187
+ def render_classes(erb_defined_classes = nil)
188
+ # TODO: avoid pointless creation of arrays
189
+ base_classes = [component_class_name] + Array.wrap(element_classes)
190
+ base_classes += Array.wrap(erb_defined_classes) if erb_defined_classes
191
+ classes_on_component = attribute(:html_options)&.fetch(:class, nil)
192
+ base_classes += Array.wrap(classes_on_component) if classes_on_component
193
+ produce_style_classes(base_classes)
194
+ end
195
+
196
+ def stimulus_identifier
197
+ self.class.stimulus_identifier
198
+ end
199
+
200
+ # TODO: Move to caching module
201
+ # Component modified time which is combined with other cache key attributes to generate cache key for an instance
202
+ def component_modified_time
203
+ self.class.component_modified_time
204
+ end
205
+
206
+ # The `component` class name is used to create the controller name.
207
+ # The path of the Stimulus controller when none is explicitly set
208
+ def default_controller_path
209
+ self.class.identifier_name_path
210
+ end
211
+
212
+ protected
213
+
214
+ # Prepare the stimulus attributes for a StimulusComponent
215
+ def stimulus_options_for_component(options)
216
+ {
217
+ **options.except(:id, :element_tag, :html_options, :controller, :controllers, :actions, :targets, :named_classes, :data_maps),
218
+ id: respond_to?(:id) ? id : (attribute(:id) || options[:id]),
219
+ element_tag: attribute(:element_tag) || options[:element_tag] || :div,
220
+ html_options: prepare_html_options(options[:html_options]),
221
+ controllers: (
222
+ self.class.stimulus_controller? ? [default_controller_path] : []
223
+ ) + Array.wrap(options[:controllers]) + attribute(:controllers),
224
+ actions: attribute(:actions) + Array.wrap(options[:actions]),
225
+ targets: attribute(:targets) + Array.wrap(options[:targets]),
226
+ named_classes: merge_stimulus_option(options, :named_classes),
227
+ data_maps: prepare_stimulus_option(options, :data_maps)
228
+ }
229
+ end
230
+
231
+ private
232
+
233
+ def prepare_html_options(erb_options)
234
+ # Options should override in this order:
235
+ # - defined on component class methods (lowest priority)
236
+ # - defined by passing to component erb
237
+ # - defined by passing to component constructor (highest priority)
238
+ options = erb_options&.except(:class) || {}
239
+ classes_from_view = Array.wrap(erb_options[:class]) if erb_options&.key?(:class)
240
+ options[:class] = render_classes(classes_from_view)
241
+ options.merge!(attribute(:html_options).except(:class)) if attribute(:html_options)
242
+ options
243
+ end
244
+
245
+ # TODO: deprecate the ability to set via method on class (responds_to?) and just use component attributes
246
+ # or attributes passed to parent_element
247
+ def prepare_stimulus_option(options, name)
248
+ resolved = respond_to?(name) ? Array.wrap(send(name)) : []
249
+ resolved.concat(Array.wrap(attribute(name)))
250
+ resolved.concat(Array.wrap(options[name]))
251
+ resolved
252
+ end
253
+
254
+ def merge_stimulus_option(options, name)
255
+ (attribute(name) || {}).merge(options[name] || {})
256
+ end
257
+
258
+ def produce_style_classes(class_names)
259
+ dedupe_view_component_classes(class_names)
260
+ end
261
+
262
+ def template_path
263
+ self.class.template_path
264
+ end
265
+
266
+ def random_id
267
+ @random_id ||= "#{self.class.component_name}-#{StableId.next_id_in_sequence}"
268
+ end
269
+
270
+ CLASSNAME_SEPARATOR = " "
271
+
272
+ # Join all the various class definisions possible and dedupe
273
+ def dedupe_view_component_classes(html_classes)
274
+ html_classes.reject!(&:blank?)
275
+
276
+ # Join, then dedupe.
277
+ # This ensures that entries from the classes array such as "a b", "a", "b" are correctly deduped.
278
+ # Note we are trying to do this with less allocations to avoid GC churn
279
+ # classes = classes.join(" ").split(" ").uniq
280
+ html_classes.map! { |x| x.include?(CLASSNAME_SEPARATOR) ? x.split(CLASSNAME_SEPARATOR) : x }
281
+ .flatten!
282
+ html_classes.uniq!
283
+ html_classes.present? ? html_classes.join(CLASSNAME_SEPARATOR) : nil
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./attributes/not_typed"
4
+
5
+ module Vident
6
+ module Component
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ include Vident::Base
11
+ include Vident::Attributes::NotTyped
12
+
13
+ attribute :id, delegates: false
14
+ attribute :html_options, delegates: false
15
+ attribute :element_tag, delegates: false
16
+
17
+ # StimulusJS support
18
+ attribute :controllers, default: [], delegates: false
19
+ attribute :actions, default: [], delegates: false
20
+ attribute :targets, default: [], delegates: false
21
+ attribute :data_maps, default: [], delegates: false
22
+ attribute :named_classes, delegates: false
23
+ end
24
+
25
+ def initialize(attrs = {})
26
+ before_initialise(attrs)
27
+ prepare_attributes(attrs)
28
+ after_initialise
29
+ super()
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ # Include rake tasks
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ load "tasks/vident.rake"
8
+ end
9
+ end
10
+ end