vident 0.1.0

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.
@@ -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