phlexi-display 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Appraisals +8 -0
  5. data/CHANGELOG.md +5 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +13 -0
  8. data/Rakefile +14 -0
  9. data/TODO +0 -0
  10. data/config.ru +6 -0
  11. data/gemfiles/default.gemfile +5 -0
  12. data/gemfiles/rails_7.gemfile +8 -0
  13. data/lib/phlexi/display/base.rb +243 -0
  14. data/lib/phlexi/display/components/base.rb +51 -0
  15. data/lib/phlexi/display/components/checkbox.rb +48 -0
  16. data/lib/phlexi/display/components/collection_checkboxes.rb +44 -0
  17. data/lib/phlexi/display/components/collection_radio_buttons.rb +35 -0
  18. data/lib/phlexi/display/components/concerns/handles_array_input.rb +21 -0
  19. data/lib/phlexi/display/components/concerns/handles_input.rb +53 -0
  20. data/lib/phlexi/display/components/concerns/has_options.rb +37 -0
  21. data/lib/phlexi/display/components/concerns/submits_form.rb +39 -0
  22. data/lib/phlexi/display/components/error.rb +21 -0
  23. data/lib/phlexi/display/components/file_input.rb +32 -0
  24. data/lib/phlexi/display/components/full_error.rb +21 -0
  25. data/lib/phlexi/display/components/hint.rb +21 -0
  26. data/lib/phlexi/display/components/input.rb +84 -0
  27. data/lib/phlexi/display/components/input_array.rb +45 -0
  28. data/lib/phlexi/display/components/label.rb +27 -0
  29. data/lib/phlexi/display/components/radio_button.rb +41 -0
  30. data/lib/phlexi/display/components/select.rb +69 -0
  31. data/lib/phlexi/display/components/submit_button.rb +41 -0
  32. data/lib/phlexi/display/components/textarea.rb +34 -0
  33. data/lib/phlexi/display/components/wrapper.rb +31 -0
  34. data/lib/phlexi/display/field_options/associations.rb +21 -0
  35. data/lib/phlexi/display/field_options/autofocus.rb +18 -0
  36. data/lib/phlexi/display/field_options/collection.rb +54 -0
  37. data/lib/phlexi/display/field_options/disabled.rb +18 -0
  38. data/lib/phlexi/display/field_options/errors.rb +92 -0
  39. data/lib/phlexi/display/field_options/hints.rb +22 -0
  40. data/lib/phlexi/display/field_options/inferred_types.rb +155 -0
  41. data/lib/phlexi/display/field_options/labels.rb +28 -0
  42. data/lib/phlexi/display/field_options/length.rb +53 -0
  43. data/lib/phlexi/display/field_options/limit.rb +66 -0
  44. data/lib/phlexi/display/field_options/min_max.rb +92 -0
  45. data/lib/phlexi/display/field_options/multiple.rb +65 -0
  46. data/lib/phlexi/display/field_options/pattern.rb +38 -0
  47. data/lib/phlexi/display/field_options/placeholder.rb +18 -0
  48. data/lib/phlexi/display/field_options/readonly.rb +18 -0
  49. data/lib/phlexi/display/field_options/required.rb +37 -0
  50. data/lib/phlexi/display/field_options/themes.rb +207 -0
  51. data/lib/phlexi/display/field_options/validators.rb +48 -0
  52. data/lib/phlexi/display/option_mapper.rb +154 -0
  53. data/lib/phlexi/display/structure/dom.rb +62 -0
  54. data/lib/phlexi/display/structure/field_builder.rb +236 -0
  55. data/lib/phlexi/display/structure/field_collection.rb +54 -0
  56. data/lib/phlexi/display/structure/namespace.rb +135 -0
  57. data/lib/phlexi/display/structure/namespace_collection.rb +48 -0
  58. data/lib/phlexi/display/structure/node.rb +18 -0
  59. data/lib/phlexi/display/version.rb +7 -0
  60. data/lib/phlexi/display.rb +31 -0
  61. data/lib/phlexi-display.rb +3 -0
  62. data/sig/phlexi/display.rbs +6 -0
  63. metadata +262 -0
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module Phlexi
6
+ module Display
7
+ module Structure
8
+ # FieldBuilder class is responsible for building display fields with various options and components.
9
+ #
10
+ # @attr_reader [Structure::DOM] dom The DOM structure for the field.
11
+ # @attr_reader [Hash] options Options for the field.
12
+ # @attr_reader [Object] object The object associated with the field.
13
+ # @attr_reader [Hash] attributes Attributes for the field.
14
+ # @attr_accessor [Object] value The value of the field.
15
+ class FieldBuilder < Node
16
+ include Phlex::Helpers
17
+ include FieldOptions::Associations
18
+ include FieldOptions::Themes
19
+ include FieldOptions::Validators
20
+ include FieldOptions::Labels
21
+ include FieldOptions::Hints
22
+ include FieldOptions::Errors
23
+ include FieldOptions::InferredTypes
24
+ include FieldOptions::Collection
25
+ include FieldOptions::Placeholder
26
+ include FieldOptions::Required
27
+ include FieldOptions::Autofocus
28
+ include FieldOptions::Disabled
29
+ include FieldOptions::Readonly
30
+ include FieldOptions::Length
31
+ include FieldOptions::MinMax
32
+ include FieldOptions::Pattern
33
+ include FieldOptions::Multiple
34
+ include FieldOptions::Limit
35
+
36
+ attr_reader :dom, :options, :object, :input_attributes, :value
37
+
38
+ # Initializes a new FieldBuilder instance.
39
+ #
40
+ # @param key [Symbol, String] The key for the field.
41
+ # @param parent [Structure::Namespace] The parent object.
42
+ # @param object [Object, nil] The associated object.
43
+ # @param value [Object] The initial value for the field.
44
+ # @param input_attributes [Hash] Default attributes to apply to input fields.
45
+ # @param options [Hash] Additional options for the field.
46
+ def initialize(key, parent:, object: nil, value: NIL_VALUE, input_attributes: {}, **options)
47
+ super(key, parent: parent)
48
+
49
+ @object = object
50
+ @value = determine_initial_value(value)
51
+ @input_attributes = input_attributes
52
+ @options = options
53
+ @dom = Structure::DOM.new(field: self)
54
+ end
55
+
56
+ # Creates a label tag for the field.
57
+ #
58
+ # @param attributes [Hash] Additional attributes for the label.
59
+ # @return [Components::Label] The label component.
60
+ def label_tag(**attributes, &)
61
+ create_component(Components::Label, :label, **attributes, &)
62
+ end
63
+
64
+ # Creates an input tag for the field.
65
+ #
66
+ # @param attributes [Hash] Additional attributes for the input.
67
+ # @return [Components::Input] The input component.
68
+ def input_tag(**attributes, &)
69
+ create_component(Components::Input, :input, **attributes, &)
70
+ end
71
+
72
+ def file_input_tag(**attributes, &)
73
+ create_component(Components::FileInput, :file, **attributes, &)
74
+ end
75
+
76
+ # Creates a checkbox tag for the field.
77
+ #
78
+ # @param attributes [Hash] Additional attributes for the checkbox.
79
+ # @return [Components::Checkbox] The checkbox component.
80
+ def checkbox_tag(**attributes, &)
81
+ create_component(Components::Checkbox, :checkbox, **attributes, &)
82
+ end
83
+
84
+ # Creates collection checkboxes for the field.
85
+ #
86
+ # @param attributes [Hash] Additional attributes for the collection checkboxes.
87
+ # @yield [block] The block to be executed for each checkbox.
88
+ # @return [Components::CollectionCheckboxes] The collection checkboxes component.
89
+ def collection_checkboxes_tag(**attributes, &)
90
+ create_component(Components::CollectionCheckboxes, :collection_checkboxes, **attributes, &)
91
+ end
92
+
93
+ # Creates a radio button tag for the field.
94
+ #
95
+ # @param attributes [Hash] Additional attributes for the radio button.
96
+ # @return [Components::RadioButton] The radio button component.
97
+ def radio_button_tag(**attributes, &)
98
+ create_component(Components::RadioButton, :radio, **attributes, &)
99
+ end
100
+
101
+ # Creates collection radio buttons for the field.
102
+ #
103
+ # @param attributes [Hash] Additional attributes for the collection radio buttons.
104
+ # @yield [block] The block to be executed for each radio button.
105
+ # @return [Components::CollectionRadioButtons] The collection radio buttons component.
106
+ def collection_radio_buttons_tag(**attributes, &)
107
+ create_component(Components::CollectionRadioButtons, :collection_radio_buttons, **attributes, &)
108
+ end
109
+
110
+ # Creates a textarea tag for the field.
111
+ #
112
+ # @param attributes [Hash] Additional attributes for the textarea.
113
+ # @return [Components::Textarea] The textarea component.
114
+ def textarea_tag(**attributes, &)
115
+ create_component(Components::Textarea, :textarea, **attributes, &)
116
+ end
117
+
118
+ # Creates a select tag for the field.
119
+ #
120
+ # @param attributes [Hash] Additional attributes for the select.
121
+ # @return [Components::Select] The select component.
122
+ def select_tag(**attributes, &)
123
+ create_component(Phlex::UI::Select, :select, **attributes, &)
124
+ end
125
+
126
+ def input_array_tag(**attributes, &)
127
+ create_component(Components::InputArray, :array, **attributes, &)
128
+ end
129
+
130
+ # Creates a hint tag for the field.
131
+ #
132
+ # @param attributes [Hash] Additional attributes for the hint.
133
+ # @return [Components::Hint] The hint component.
134
+ def hint_tag(**attributes, &)
135
+ create_component(Components::Hint, :hint, **attributes, &)
136
+ end
137
+
138
+ # Creates an error tag for the field.
139
+ #
140
+ # @param attributes [Hash] Additional attributes for the error.
141
+ # @return [Components::Error] The error component.
142
+ def error_tag(**attributes, &)
143
+ create_component(Components::Error, :error, **attributes, &)
144
+ end
145
+
146
+ # Creates a full error tag for the field.
147
+ #
148
+ # @param attributes [Hash] Additional attributes for the full error.
149
+ # @return [Components::FullError] The full error component.
150
+ def full_error_tag(**attributes, &)
151
+ create_component(Components::FullError, :full_error, **attributes, &)
152
+ end
153
+
154
+ # Wraps the field with additional markup.
155
+ #
156
+ # @param inner [Hash] Attributes for the inner wrapper.
157
+ # @param attributes [Hash] Additional attributes for the wrapper.
158
+ # @yield [block] The block to be executed within the wrapper.
159
+ # @return [Components::Wrapper] The wrapper component.
160
+ def wrapped(inner: {}, **attributes, &)
161
+ wrapper_class = attributes.delete(:class) || themed(attributes.delete(:theme) || :wrapper)
162
+ inner[:class] = inner.delete(:class) || themed(inner.delete(:theme) || :inner_wrapper)
163
+ Components::Wrapper.new(self, class: wrapper_class, inner: inner, **attributes, &)
164
+ end
165
+
166
+ # Creates a multi-value field collection.
167
+ #
168
+ # @param range [Integer, #to_a] The range of keys for each field. If an integer is passed, keys will begin from 1.
169
+ # @yield [block] The block to be executed for each item in the collection.
170
+ # @return [FieldCollection] The field collection.
171
+ def multi(range = nil, &)
172
+ FieldCollection.new(field: self, range: range, &)
173
+ end
174
+
175
+ # Creates a submit button
176
+ #
177
+ # @param attributes [Hash] Additional attributes for the submit.
178
+ # @return [Components::SubmitButton] The submit button component.
179
+ def submit_button_tag(**attributes, &)
180
+ create_component(Components::SubmitButton, :submit_button, **attributes, &)
181
+ end
182
+
183
+ def extract_input(params)
184
+ raise "field##{dom.name} did not define an input component" unless @field_input_component
185
+
186
+ @field_input_component.extract_input(params)
187
+ end
188
+
189
+ protected
190
+
191
+ def create_component(component_class, theme_key, **attributes, &)
192
+ if component_class.include?(Phlexi::Display::Components::Concerns::HandlesInput)
193
+ raise "input component already defined: #{@field_input_component.inspect}" if @field_input_component
194
+
195
+ attributes = input_attributes.deep_merge(attributes)
196
+ @field_input_component = component_class.new(self, class: component_class_for(theme_key, attributes), **attributes, &)
197
+ else
198
+ component_class.new(self, class: component_class_for(theme_key, attributes), **attributes, &)
199
+ end
200
+ end
201
+
202
+ def component_class_for(theme_key, attributes)
203
+ attributes.delete(:class) || themed(attributes.key?(:theme) ? attributes.delete(:theme) : theme_key)
204
+ end
205
+
206
+ def has_value?
207
+ value.present?
208
+ end
209
+
210
+ def determine_initial_value(value)
211
+ return value unless value == NIL_VALUE
212
+
213
+ determine_from_association || determine_value_from_object
214
+ end
215
+
216
+ def determine_value_from_object
217
+ object.respond_to?(key) ? object.public_send(key) : nil
218
+ end
219
+
220
+ def determine_from_association
221
+ return nil unless reflection.present?
222
+
223
+ value = object.public_send(key)
224
+ case reflection.macro
225
+ when :has_many, :has_and_belongs_to_many
226
+ value&.map { |v| v.public_send(reflection.klass.primary_key) }
227
+ when :belongs_to, :has_one
228
+ value&.public_send(reflection.klass.primary_key)
229
+ else
230
+ raise ArgumentError, "Unsupported association type: #{reflection.macro}"
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module Structure
6
+ class FieldCollection
7
+ include Enumerable
8
+
9
+ class Builder
10
+ attr_reader :key, :index
11
+
12
+ def initialize(key, field, index)
13
+ @key = key.to_s
14
+ @field = field
15
+ @index = index
16
+ end
17
+
18
+ def field(**)
19
+ @field.class.new(key, input_attributes: @field.input_attributes, **, parent: @field).tap do |field|
20
+ yield field if block_given?
21
+ end
22
+ end
23
+
24
+ def hidden_field_tag(value: "", force: false)
25
+ raise "Attempting to build hidden field on non-first field in a collection" unless index == 0 || force
26
+
27
+ @field.class
28
+ .new("hidden", parent: @field)
29
+ .input_tag(type: :hidden, value:)
30
+ end
31
+ end
32
+
33
+ def initialize(field:, range:, &)
34
+ @field = field
35
+ @range = case range
36
+ when Range, Array
37
+ range
38
+ when Integer
39
+ 1..range
40
+ else
41
+ range.to_a
42
+ end
43
+ each(&) if block_given?
44
+ end
45
+
46
+ def each(&)
47
+ @range.each.with_index do |key, index|
48
+ yield Builder.new(key, @field, index)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module Structure
6
+ # A Namespace maps and object to values, but doesn't actually have a value itself. For
7
+ # example, a `User` object or ActiveRecord model could be passed into the `:user` namespace.
8
+ # To access the values on a Namespace, the `field` can be called for single values.
9
+ #
10
+ # Additionally, to access namespaces within a namespace, such as if a `User has_many :addresses` in
11
+ # ActiveRecord, the `namespace` method can be called which will return another Namespace object and
12
+ # set the current Namespace as the parent.
13
+ class Namespace < Structure::Node
14
+ include Enumerable
15
+
16
+ attr_reader :builder_klass, :object
17
+
18
+ def initialize(key, parent:, builder_klass:, object: nil)
19
+ super(key, parent: parent)
20
+ @builder_klass = builder_klass
21
+ @object = object
22
+ @children = {}
23
+ yield self if block_given?
24
+ end
25
+
26
+ def field(key, **attributes)
27
+ create_child(key, attributes.delete(:builder_klass) || builder_klass, object: object, **attributes).tap do |field|
28
+ yield field if block_given?
29
+ end
30
+ end
31
+
32
+ def submit_button(key = nil, **attributes, &)
33
+ field(key || SecureRandom.hex).submit_button_tag(**attributes, &)
34
+ end
35
+
36
+ # Creates a `Namespace` child instance with the parent set to the current instance, adds to
37
+ # the `@children` Hash to ensure duplicate child namespaces aren't created, then calls the
38
+ # method on the `@object` to get the child object to pass into that namespace.
39
+ #
40
+ # For example, if a `User#permission` returns a `Permission` object, we could map that to a
41
+ # form like this:
42
+ #
43
+ # ```ruby
44
+ # Superform :user, object: User.new do |form|
45
+ # form.nest_one :permission do |permission|
46
+ # form.field :role
47
+ # end
48
+ # end
49
+ # ```
50
+ def nest_one(key, object: nil, &)
51
+ object ||= object_value_for(key: key)
52
+ create_child(key, self.class, object:, builder_klass:, &)
53
+ end
54
+
55
+ # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns
56
+ # an enumerable or array of `Address` classes:
57
+ #
58
+ # ```ruby
59
+ # Phlexi::Display.new User.new do |form|
60
+ # render form.field(:email).input_tag
61
+ # render form.field(:name).input_tag
62
+ # form.nest_many :addresses do |address|
63
+ # render address.field(:street).input_tag
64
+ # render address.field(:state).input_tag
65
+ # render address.field(:zip).input_tag
66
+ # end
67
+ # end
68
+ # ```
69
+ # The object within the block is a `Namespace` object that maps each object within the enumerable
70
+ # to another `Namespace` or `Field`.
71
+ def nest_many(key, collection: nil, &)
72
+ collection ||= Array(object_value_for(key: key))
73
+ create_child(key, NamespaceCollection, collection:, &)
74
+ end
75
+
76
+ def extract_input(params)
77
+ if params.is_a?(Array)
78
+ each_with_object({}) do |child, hash|
79
+ hash.merge! child.extract_input(params[0])
80
+ end
81
+ else
82
+ input = each_with_object({}) do |child, hash|
83
+ hash.merge! child.extract_input(params[key])
84
+ end
85
+ {key => input}
86
+ end
87
+ end
88
+
89
+ # Iterates through the children of the current namespace, which could be `Namespace` or `Field`
90
+ # objects.
91
+ def each(&)
92
+ @children.values.each(&)
93
+ end
94
+
95
+ def dom_id
96
+ @dom_id ||= begin
97
+ id = if object.nil?
98
+ nil
99
+ elsif object.class.respond_to?(:primary_key)
100
+ object.public_send(object.class.primary_key) || :new
101
+ elsif object.respond_to?(:id)
102
+ object.id || :new
103
+ end
104
+ [key, id].compact.join("_").underscore
105
+ end
106
+ end
107
+
108
+ # Creates a root Namespace, which is essentially a form.
109
+ def self.root(*, builder_klass:, **, &)
110
+ new(*, parent: nil, builder_klass:, **, &)
111
+ end
112
+
113
+ protected
114
+
115
+ # Calls the corresponding method on the object for the `key` name, if it exists. For example
116
+ # if the `key` is `email` on `User`, this method would call `User#email` if the method is
117
+ # present.
118
+ #
119
+ # This method could be overwritten if the mapping between the `@object` and `key` name is not
120
+ # a method call. For example, a `Hash` would be accessed via `user[:email]` instead of `user.send(:email)`
121
+ def object_value_for(key:)
122
+ @object.send(key) if @object.respond_to? key
123
+ end
124
+
125
+ private
126
+
127
+ # Checks if the child exists. If it does then it returns that. If it doesn't, it will
128
+ # build the child.
129
+ def create_child(key, child_class, **kwargs, &block)
130
+ @children.fetch(key) { @children[key] = child_class.new(key, parent: self, **kwargs, &block) }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module Structure
6
+ class NamespaceCollection < Node
7
+ include Enumerable
8
+
9
+ def initialize(key, parent:, collection: nil, &block)
10
+ raise ArgumentError, "block is required" unless block.present?
11
+
12
+ super(key, parent: parent)
13
+
14
+ @collection = collection
15
+ @block = block
16
+ each(&block)
17
+ end
18
+
19
+ def extract_input(params)
20
+ namespace = build_namespace(0)
21
+ @block.call(namespace)
22
+
23
+ inputs = params[key].map { |param| namespace.extract_input([param]) }
24
+ {key => inputs}
25
+ end
26
+
27
+ private
28
+
29
+ def each(&)
30
+ namespaces.each(&)
31
+ end
32
+
33
+ # Builds and memoizes namespaces for the collection.
34
+ #
35
+ # @return [Array<Hash>] An array of namespace hashes.
36
+ def namespaces
37
+ @namespaces ||= @collection.map.with_index do |object, key|
38
+ build_namespace(key, object: object)
39
+ end
40
+ end
41
+
42
+ def build_namespace(index, **)
43
+ parent.class.new(index, parent: self, builder_klass: parent.builder_klass, **)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ module Structure
6
+ # Superclass for Namespace and Field classes. Not much to it other than it has a `name`
7
+ # and `parent` node attribute. Think of it as a tree.
8
+ class Node
9
+ attr_reader :key, :parent
10
+
11
+ def initialize(key, parent:)
12
+ @key = key.to_s.to_sym
13
+ @parent = parent
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlexi
4
+ module Display
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "phlex"
5
+ require "active_support/core_ext/object/blank"
6
+
7
+ module Phlexi
8
+ module Display
9
+ Loader = Zeitwerk::Loader.new.tap do |loader|
10
+ loader.tag = File.basename(__FILE__, ".rb")
11
+ loader.inflector.inflect(
12
+ "phlexi-display" => "Phlexi",
13
+ "phlexi" => "Phlexi",
14
+ "dom" => "DOM"
15
+ )
16
+ loader.push_dir(File.expand_path("..", __dir__))
17
+ loader.ignore(File.expand_path("../generators", __dir__))
18
+ loader.setup
19
+ end
20
+
21
+ COMPONENT_BASE = (defined?(::ApplicationComponent) ? ::ApplicationComponent : Phlex::HTML)
22
+
23
+ NIL_VALUE = :__i_phlexi_display_nil_value_i__
24
+
25
+ class Error < StandardError; end
26
+ end
27
+ end
28
+
29
+ def Phlexi.Display(...)
30
+ Phlexi::Display::Base.new(...)
31
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "phlexi/display"
@@ -0,0 +1,6 @@
1
+ module Phlexi
2
+ module Display
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end