brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -1,114 +1,157 @@
1
1
  require "forwardable"
2
2
 
3
+ # Holds classes used in form processing
3
4
  module Brut::FrontEnd::Forms
5
+ autoload(:InputDeclarations, "brut/front_end/forms/input_declarations")
4
6
  autoload(:InputDefinition, "brut/front_end/forms/input_definition")
7
+ autoload(:SelectInputDefinition, "brut/front_end/forms/select_input_definition")
8
+ autoload(:RadioButtonGroupInputDefinition, "brut/front_end/forms/radio_button_group_input_definition")
5
9
  autoload(:ConstraintViolation, "brut/front_end/forms/constraint_violation")
6
10
  autoload(:ValidityState, "brut/front_end/forms/validity_state")
7
11
  autoload(:Input, "brut/front_end/forms/input")
12
+ autoload(:SelectInput, "brut/front_end/forms/select_input")
13
+ autoload(:RadioButtonGroupInput, "brut/front_end/forms/radio_button_group_input")
8
14
  end
9
15
 
10
- module Brut::FrontEnd::FormInputDeclaration
11
- # Declares an input for this form.
12
- def input(name,attributes={})
13
- self.add_input_definition(
14
- Brut::FrontEnd::Forms::InputDefinition.new(**(attributes.merge(name: name)))
15
- )
16
- end
17
-
18
- def select(name,attributes={})
19
- self.add_input_definition(
20
- Brut::FrontEnd::Forms::SelectInputDefinition.new(**(attributes.merge(name: name)))
21
- )
22
- end
23
-
24
- def add_input_definition(input_definition)
25
- @input_definitions ||= {}
26
- @input_definitions[input_definition.name] = input_definition
27
- define_method input_definition.name do
28
- self[input_definition.name].value
29
- end
30
- end
31
-
32
- # Copy the inputs from another form into this one
33
- def inputs_from(other_class)
34
- if !other_class.respond_to?(:input_definitions)
35
- raise ArgumentError,"#{other_class} does not respond to #input_definitions - you cannot copy inputs from it"
36
- end
37
- other_class.input_definitions.each do |_name,input_definition|
38
- self.add_input_definition(input_definition)
39
- end
40
- end
41
-
42
- def input_definitions = @input_definitions || {}
43
- end
44
-
16
+ # Base class for forms you create to process an HTML form. Generally, your form subclasses will only declare their inputs using
17
+ # methods from {Brut::FrontEnd::Forms::InputDeclarations}. That said, you will likely create instances of forms as part of the logic
18
+ # for processing them or for testing.
45
19
  class Brut::FrontEnd::Form
46
20
 
47
- include SemanticLogger::Loggable
48
-
49
- extend Brut::FrontEnd::FormInputDeclaration
21
+ extend Brut::FrontEnd::Forms::InputDeclarations
50
22
 
23
+ # @!visibility private
51
24
  def self.routing(*)
52
25
  raise ArgumentError,"You called .routing on a form, but that form hasn't been configured with a route. You must do so in your route_config.rb file via the `form` method"
53
26
  end
54
27
 
55
28
  # Create an instance of this form, optionally initialized with
56
- # the given values for its params.
29
+ # the given values for its params. Because any values can be posted to a form endpoint, the initializer does not use kewyord
30
+ # arguments. Instead, it's initialize with whatever parameters were received. This intializer will then set only those values
31
+ # defined.
57
32
  def initialize(params: {})
58
33
  params = convert_to_string_or_nil(params.to_h)
59
34
  unknown_params = params.keys.map(&:to_s).reject { |key|
60
35
  self.class.input_definitions.key?(key)
61
36
  }
62
37
  if unknown_params.any?
63
- logger.info "Ignoring unknown params", keys: unknown_params
38
+ Brut.container.instrumentation.add_attributes(ignored_unknown_params: unknown_params)
64
39
  end
65
40
  @params = params.except(*unknown_params)
66
41
  @new = params_empty?(@params)
67
42
  @inputs = self.class.input_definitions.map { |name,input_definition|
68
- input = input_definition.make_input(value: @params[name] || @params[name.to_sym])
69
- [ name, input ]
43
+ value = @params[name] || @params[name.to_sym]
44
+ inputs = if value.kind_of?(Array)
45
+ value.map { |one_value|
46
+ input_definition.make_input(value: one_value)
47
+ }
48
+ else
49
+ [
50
+ input_definition.make_input(value:)
51
+ ]
52
+ end
53
+
54
+ [ name, inputs ]
70
55
  }.to_h
71
56
  end
72
57
 
73
58
  # Returns true if this form represents a new, empty, untouched form. This is
74
59
  # useful for determining if this form has never been submitted and thus
75
60
  # any required values don't represent an intentional omission by the user.
61
+ # Generally, don't override this. Instead, override {#params_empty?}.
76
62
  def new? = @new
77
63
 
78
64
  # Access an input with the given name
79
- def [](input_name) = @inputs.fetch(input_name.to_s)
65
+ #
66
+ # @param [String|Symbol] input_name the name of the input, as passed to {Brut::FrontEnd::Forms::InputDeclarations#input} et. al.
67
+ # @param [Integer] index the index of the input, when using arrays.
68
+ # @return [Brut::FrontEnd::Forms::Input]
69
+ def input(input_name, index: nil)
70
+ index ||= 0
71
+ inputs = self.inputs(input_name)
72
+ input = inputs[index]
73
+ if input.nil?
74
+ input_definition = self.class.input_definitions.fetch(input_name.to_s)
75
+ input = input_definition.make_input(value:"")
76
+ inputs[index] = input
77
+ end
78
+ input
79
+ end
80
+
81
+ # Return all inputs for the given name.
82
+ # @param [String|Symbol] input_name the name of the input, as passed to {Brut::FrontEnd::Forms::InputDeclarations#input} et. al.
83
+ # @return [Brut::FrontEnd::Forms::Input]
84
+ def inputs(input_name)
85
+ @inputs.fetch(input_name.to_s)
86
+ rescue KeyError => ex
87
+ raise Brut::Framework::Errors::Bug, "Form does not define the input '#{input_name}'. You must add this to your form. Found these inputs: #{@inputs.keys.join(', ')}"
88
+ end
80
89
 
81
90
  # Returns true if this form has constraint violations.
82
- def constraint_violations? = !@inputs.values.all?(&:valid?)
91
+ def constraint_violations? = !@inputs.values.flatten.all?(&:valid?)
83
92
 
84
93
  # Set a server-side constraint violation on a given input's name.
85
- def server_side_constraint_violation(input_name:, key:, context:{})
86
- self[input_name].server_side_constraint_violation(key,context)
94
+ #
95
+ # @param [String|Symbol] input_name the name of the input, as passed to {Brut::FrontEnd::Forms::InputDeclarations#input} et. al.
96
+ # @param [String] key the i18n key fragment representing the constraint. Assume this will be appended to `general.cv.be.` in order
97
+ # to form the entire key.
98
+ # @param [Hash] context additional information about the violation, typically interpolated values for the I18n message.
99
+ def server_side_constraint_violation(input_name:, key:, index: nil, context:{})
100
+ index ||= 0
101
+ self.input(input_name, index:).server_side_constraint_violation(key,context)
87
102
  end
88
103
 
104
+
105
+ # Returns a map of any input with a constraint violation and the list of violations. The keys in the hash are input names and the
106
+ # values are arrays of {Brut::FrontEnd::Forms::ValidityState} instances.
107
+ #
108
+ # @param [true|false] server_side_only if true, only server side constraints are returned.
109
+ # @return [Hash] map of input names to arrays of validity states
110
+ #
89
111
  def constraint_violations(server_side_only: false)
90
- @inputs.map { |input_name, input|
91
- if input.valid?
92
- nil
93
- else
94
- [
95
- input_name,
96
- input.validity_state.select { |constraint|
97
- if server_side_only
98
- !constraint.client_side?
99
- else
100
- true
101
- end
102
- }
103
- ]
104
- end
105
- }.compact.to_h
112
+ @inputs.map { |input_name, inputs|
113
+ inputs.map.with_index { |input,index|
114
+ if input.valid?
115
+ nil
116
+ else
117
+ [
118
+ input_name,
119
+ [
120
+ input.validity_state.select { |constraint|
121
+ if server_side_only
122
+ !constraint.client_side?
123
+ else
124
+ true
125
+ end
126
+ },
127
+ index
128
+ ]
129
+ ]
130
+ end
131
+ }.compact
132
+ }.select { !it.empty? }.flatten(1).to_h
106
133
  end
107
134
 
108
- private
109
-
135
+ # Template method that is used to determine if the params given to the form's initializer
136
+ # represent an empty value. By default, this returns true if those params were nil or empty.
137
+ # You'd override this if there are values you want to initialize a form with that aren't considered
138
+ # values provided by the user. This can allow a form with values in it to be considered un-submitted.
110
139
  def params_empty?(params) = params.nil? || params.empty?
111
140
 
141
+ # Return this form as a hash, which is a map of its parameters' current values. This is not simply
142
+ # a filtered version of what was passed into the initializer. This will only have the keys for the inputs
143
+ # this form defines. Those keys will be strings, not symbols. The values will be
144
+ # either `nil`, a String, or an array of strings. Not every input defined by this form
145
+ # will be represented as a key in the resulting hash—only those keys that were passed to the initializer.
146
+ # @return [Hash<String,nil|String|Array<String>>] the form's params as a hash.
147
+ def to_h
148
+ @params.map { |key,value|
149
+ [ key.to_s, value ]
150
+ }.to_h
151
+ end
152
+
153
+ private
154
+
112
155
  def convert_to_string_or_nil(hash)
113
156
  hash.each do |key,value|
114
157
  case value
@@ -118,54 +161,14 @@ private
118
161
  in TrueClass then hash[key] = "true"
119
162
  in FalseClass then hash[key] = "false"
120
163
  in NilClass then # it's fine
164
+ in Array then hash[key] = value
121
165
  else
122
166
  if Brut.container.project_env.test?
123
167
  raise ArgumentError, "Got #{value.class} for #{key} in params hash, which is not expected"
124
168
  else
125
- logger.warn("Got #{value.class} for #{key} in params hash, which is not expected")
169
+ Brut.container.instrumentation.add_event("convert_to_string_or_nil: Unknown class in params hash", class: value.class, key: key)
126
170
  end
127
171
  end
128
172
  end
129
173
  end
130
-
131
- end
132
-
133
- class Brut::FrontEnd::FormProcessingResponse
134
-
135
- def self.redirect_to(uri) = Redirect.new(uri)
136
- def self.render_page(page) = RenderPage.new(page)
137
- def self.render_component(component, http_status: 200) = RenderComponent.new(component,http_status)
138
- def self.send_http_status(http_status) = SendHttpStatusOnly.new(http_status)
139
-
140
- class Redirect < Brut::FrontEnd::FormProcessingResponse
141
- def initialize(uri)
142
- @uri = uri
143
- end
144
-
145
- def deconstruct_keys(keys) = { redirect: @uri }
146
-
147
- end
148
-
149
- class RenderPage < Brut::FrontEnd::FormProcessingResponse
150
- def initialize(page)
151
- @page = page
152
- end
153
- def deconstruct_keys(keys) = { page_instance: @page }
154
- end
155
-
156
- class RenderComponent < Brut::FrontEnd::FormProcessingResponse
157
- def initialize(component, http_status)
158
- @component = component
159
- @http_status = http_status
160
- end
161
- def deconstruct_keys(keys) = { component_instance: @component, http_status: @http_status }
162
- end
163
-
164
- class SendHttpStatusOnly < Brut::FrontEnd::FormProcessingResponse
165
- def initialize(http_status)
166
- @http_status = http_status
167
- end
168
- def deconstruct_keys(keys) = { http_status: @http_status }
169
- end
170
-
171
174
  end
@@ -1,7 +1,10 @@
1
- # Represents a specific error with a field. A field can have any number of constraint violations
2
- # to indicate what is wrong with it.
1
+ # Represents a specific error with a field. Essentially wraps an i18n key fragment and interpolated values for use with other
2
+ # form-related classes.
3
3
  class Brut::FrontEnd::Forms::ConstraintViolation
4
4
 
5
+ # These are underscorized versions of the attributes of the browser's `ValidityState`'s properties.
6
+ #
7
+ # @see https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
5
8
  CLIENT_SIDE_KEYS = [
6
9
  "bad_input",
7
10
  "custom_error",
@@ -15,8 +18,17 @@ class Brut::FrontEnd::Forms::ConstraintViolation
15
18
  "value_missing",
16
19
  ]
17
20
 
18
- attr_reader :key, :context
21
+ # @return [String] the key fragment representing the violation
22
+ attr_reader :key
23
+ # @return [Hash] interpolated values useful in rendering the actual message
24
+ attr_reader :context
19
25
 
26
+ # Create a constraint violations
27
+ #
28
+ # @param [String|Symbol] key I18n key fragment representing this violation.
29
+ # @param [Hash|nil] context interpolated values useful in rendering the message
30
+ # @param [true|:based_on_key] server_side If `:based_on_key`, {#client_side?} will return true if `key` is in {.CLIENT_SIDE_KEYS}.
31
+ # If `true`, {#client_side?} will return false no matter what.
20
32
  def initialize(key:,context:, server_side: :based_on_key)
21
33
  @key = key.to_s
22
34
  @client_side = CLIENT_SIDE_KEYS.include?(@key) && server_side != true
@@ -26,14 +38,7 @@ class Brut::FrontEnd::Forms::ConstraintViolation
26
38
  end
27
39
  end
28
40
 
41
+ # @return [true|false] true if this violation is a client-side violation
29
42
  def client_side? = @client_side
30
43
  def to_s = @key
31
-
32
- def to_json(*args)
33
- {
34
- key: self.key,
35
- context: self.context,
36
- client_side: self.client_side?
37
- }.to_json(*args)
38
- end
39
44
  end
@@ -1,12 +1,18 @@
1
1
  # An Input is a stateful object representing a specific input and its value
2
2
  # during the course of a form submission process. In particular, it wraps a value
3
- # and a ValidityState. These are mutable, whereas the wrapped InputDefinition is not.
3
+ # and a {Brut::FrontEnd::Forms::ValidityState}. These are mutable, whereas the wrapped {Brut::FrontEnd::Forms::InputDefinition} is not.
4
4
  class Brut::FrontEnd::Forms::Input
5
5
 
6
6
  extend Forwardable
7
7
 
8
- attr_reader :value, :validity_state
8
+ # @return [String] the input's value
9
+ attr_reader :value
10
+ # @return [Brut::FrontEnd::Forms::ValidityState] Validity state that captures the current constraint violations, if any
11
+ attr_reader :validity_state
9
12
 
13
+ # Create the input with the given definition and value
14
+ # @param [Brut::FrontEnd::Forms::InputDefinition] input_definition
15
+ # @param [String] value
10
16
  def initialize(input_definition:, value:)
11
17
  @input_definition = input_definition
12
18
  @validity_state = Brut::FrontEnd::Forms::ValidityState.new
@@ -21,9 +27,20 @@ class Brut::FrontEnd::Forms::Input
21
27
  :pattern,
22
28
  :required,
23
29
  :step,
24
- :type
25
-
30
+ :type,
31
+ :array?
32
+
33
+ # Set the value, analyzing it for constraint violations based on the input's definition.
34
+ # This is essentially duplicating whatever the browser would be doing on its end, thus allowing
35
+ # for server-side validation of client-side constraints.
36
+ #
37
+ # When this method completes, the value of {#validity_state} could change.
38
+ #
39
+ # @param [String] new_value the value for the input. Empty strings are coerced to `nil`
26
40
  def value=(new_value)
41
+ if new_value.kind_of?(String) && new_value.strip == ""
42
+ new_value = nil
43
+ end
27
44
  value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
28
45
  missing = if self.required
29
46
  value_missing
@@ -56,7 +73,11 @@ class Brut::FrontEnd::Forms::Input
56
73
  false
57
74
  end
58
75
 
59
- pattern_mismatch = false
76
+ pattern_mismatch = if self.pattern && !value_missing && !type_mismatch
77
+ !new_value.match?(Regexp.new(self.pattern))
78
+ else
79
+ false
80
+ end
60
81
  step_mismatch = false
61
82
 
62
83
  @validity_state = Brut::FrontEnd::Forms::ValidityState.new(
@@ -74,46 +95,13 @@ class Brut::FrontEnd::Forms::Input
74
95
 
75
96
  # Set a server-side constraint violation on this input. This is essentially arbitrary, but note
76
97
  # that `key` should not be a key used for client-side validations.
98
+ #
99
+ # @param [String|Symbol] key the I18n key fragment that describes the server side constraint violation
100
+ # @param [Hash|nil] context any interpolations required to render the message
77
101
  def server_side_constraint_violation(key,context=true)
78
102
  @validity_state.server_side_constraint_violation(key: key, context: context)
79
103
  end
80
104
 
81
- def valid? = @validity_state.valid?
82
- end
83
- class Brut::FrontEnd::Forms::SelectInput
84
-
85
- extend Forwardable
86
-
87
- attr_reader :value, :validity_state
88
-
89
- def initialize(input_definition:, value:)
90
- @input_definition = input_definition
91
- @validity_state = Brut::FrontEnd::Forms::ValidityState.new
92
- self.value=(value)
93
- end
94
-
95
- def_delegators :"@input_definition", :name,
96
- :required
97
-
98
- def value=(new_value)
99
- value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
100
- missing = if self.required
101
- value_missing
102
- else
103
- false
104
- end
105
-
106
- @validity_state = Brut::FrontEnd::Forms::ValidityState.new(
107
- value_missing: missing,
108
- )
109
- @value = new_value
110
- end
111
-
112
- # Set a server-side constraint violation on this input. This is essentially arbitrary, but note
113
- # that `key` should not be a key used for client-side validations.
114
- def server_side_constraint_violation(key,context=true)
115
- @validity_state.server_side_constraint_violation(key: key, context: context)
116
- end
117
-
105
+ # @return [true|false] true if the underlying {#validity_state} has no constraint violations
118
106
  def valid? = @validity_state.valid?
119
107
  end
@@ -0,0 +1,90 @@
1
+ # Extended by {Brut::FrontEnd::Form} to allow declaring inputs. Do not use this module directly. Instead, call {#input} or {#select}
2
+ # from within your form's class definition.
3
+ module Brut::FrontEnd::Forms::InputDeclarations
4
+ # Declares an input for this form, to be modeled via an HTML `<INPUT>` tag.
5
+ #
6
+ # @param [String] name The name of the input (used in the `name` attribute)
7
+ # @param [Hash] attributes Attributes to be used on the tag that represent its contraints. See {Brut::FrontEnd::Forms::InputDefinition}
8
+ def input(name,attributes={})
9
+ self.add_input_definition(
10
+ Brut::FrontEnd::Forms::InputDefinition.new(**(attributes.merge(name: name)))
11
+ )
12
+ end
13
+
14
+ # Declares a select for this form, to be modeled via an HTML `<SELECT>` tag. Note that this will not define the values that appear
15
+ # in the select. That is done when the select is rendered, which you might do with
16
+ # {Brut::FrontEnd::Components::Inputs::Select.for_form_input}
17
+ #
18
+ # @param [String] name The name of the input (used in the `name` attribute)
19
+ # @param [Hash] attributes Attributes to be used on the tag that represent its contraints. See {Brut::FrontEnd::Forms::SelectInputDefinition}
20
+ def select(name,attributes={})
21
+ self.add_input_definition(
22
+ Brut::FrontEnd::Forms::SelectInputDefinition.new(**(attributes.merge(name: name)))
23
+ )
24
+ end
25
+
26
+ # Declares a radio button group, which will manifest as one or more `<input type="radio">` tags that all use the same
27
+ # value for their `name` attribute. Unlike `input` or `select`, this method is declaring one or more actual
28
+ # input tags.
29
+ #
30
+ # Note that this is not where you would define the possible values for the group. That is done in
31
+ # {Brut::FrontEnd::Components::Inputs::RadioButton.for_form_input}.
32
+ #
33
+ # @param [String] name The name of the group (used in the `name` attribute)
34
+ # @param [Hash] attributes Attributes to be used on the tag that represent its contraints. See
35
+ # {Brut::FrontEnd::Forms::RadioButtonGroupInputDefinition}
36
+ def radio_button_group(name,attributes={})
37
+ self.add_input_definition(
38
+ Brut::FrontEnd::Forms::RadioButtonGroupInputDefinition.new(**(attributes.merge(name: name)))
39
+ )
40
+ end
41
+
42
+ # @!visibility private
43
+ def add_input_definition(input_definition)
44
+ @input_definitions ||= {}
45
+ @input_definitions[input_definition.name] = input_definition
46
+ if input_definition.array?
47
+ define_method input_definition.name do |index=nil|
48
+ if index.nil?
49
+ raise ArgumentError,"#{input_definition.name} is an array - you must provide an index to access one of its values"
50
+ end
51
+ self.input(input_definition.name, index:).value
52
+ end
53
+ define_method "#{input_definition.name}_each" do |&block|
54
+ self.inputs(input_definition.name).each_with_index do |input,i|
55
+ block.(input.value,i)
56
+ end
57
+ end
58
+ else
59
+ define_method input_definition.name do |index_that_should_be_omitted=nil|
60
+ if !index_that_should_be_omitted.nil?
61
+ raise ArgumentError,"#{input_definition.name} is not an array - do not provide an index when accessing its value"
62
+ end
63
+ self.input(input_definition.name, index: 0).value
64
+ end
65
+ end
66
+ end
67
+
68
+ # Copy the inputs from another form into this one. This is useful when one form should have identical inputs from another, plus a
69
+ # few of its own.
70
+ #
71
+ # @param [Class] other_class a subclass of {Brut::FrontEnd::Form}.
72
+ def inputs_from(other_class)
73
+ if !other_class.respond_to?(:input_definitions)
74
+ raise ArgumentError,"#{other_class} does not respond to #input_definitions - you cannot copy inputs from it"
75
+ end
76
+ other_class.input_definitions.each do |_name,input_definition|
77
+ self.add_input_definition(input_definition)
78
+ end
79
+ end
80
+
81
+ # Return a map of input names to input definitions
82
+ #
83
+ # @return [Hash<String,Brut::FrontEnd::Forms::InputDefinition>] a map of all defined input names to the definitions.
84
+ #
85
+ # @!visibility private
86
+ def input_definitions
87
+ @input_definitions ||= {}
88
+ end
89
+ end
90
+
@@ -1,18 +1,24 @@
1
- # An InputDefinition captures metadata used to create an Input. Think of this
2
- # as a template for creating inputs. An Input has state, such as values and thus validity.
3
- # An InputDefinition is immutable and defines inputs.
1
+ # Defines an input for a form, but not it's current runtime state. {Brut::FrontEnd::Forms::Input} is used to understand the current
2
+ # state or value of an input.
3
+ #
4
+ # Note that an input definition is defining an HTML `<input>`, not a generic attribute. Thus, the only constraints you can place on
5
+ # an input are those that the browser supports. If your form needs server side validation, you can accomplish that in a lot of ways,
6
+ # such as implementing a {Brut::BackEnd::Validators::FormValidator}, or calling
7
+ # {Brut::FrontEnd::Form#server_side_constraint_violation} directly.
4
8
  class Brut::FrontEnd::Forms::InputDefinition
5
9
  include Brut::Framework::FussyTypeEnforcement
6
- attr_reader :max,
7
- :maxlength,
8
- :min,
9
- :minlength,
10
- :name,
11
- :pattern,
12
- :required,
13
- :step,
14
- :type
15
10
 
11
+ attr_reader :max
12
+ attr_reader :maxlength
13
+ attr_reader :min
14
+ attr_reader :minlength
15
+ attr_reader :name
16
+ attr_reader :pattern
17
+ attr_reader :required
18
+ attr_reader :step
19
+ attr_reader :type
20
+
21
+ # @!visibility private
16
22
  INPUT_TYPES_TO_CLASS = {
17
23
  "checkbox" => String,
18
24
  "color" => String,
@@ -35,7 +41,24 @@ class Brut::FrontEnd::Forms::InputDefinition
35
41
  }
36
42
 
37
43
  # Create an InputDefinition. This should very closely mirror
38
- # the attributes used in an <INPUT> element in HTML.
44
+ # the attributes used in an `<INPUT>` element in HTML. The idea is to be able to create HTML that validates its values the same as
45
+ # we can in Ruby so that client side validations can be safely used for user experience, but also re-executed server side.
46
+ #
47
+ #
48
+ # @param [Integer|Date|Time] min Minimum value allowed by the input. Not relevat to all `type`s.
49
+ # @param [Integer|Date|Time] max Maximum value allowed by the input. Not relevat to all `type`s.
50
+ # @param [Integer] minlength Minimum length of the value allowed.
51
+ # @param [Integer] maxlength Maximum length of the value allowed.
52
+ # @param [String] name Name of the input (required)
53
+ # @param [Regexp] pattern that the value must match. Note that this technically must be a regular expression that works for both Ruby and JavaScript, so don't get fancy.
54
+ # @param [true|false] required true if this field is required, false otherwise. Default is `true` unless `type` is `"checkbox"`.
55
+ # @param [Integer] step Step value for ranged inputs. A value that is not on a step is considered invalid.
56
+ # @param [String] type the type of input to create. Should be a value from the HTML spec. Default is based on the value of `name`. If `email`, `type` is `email`. If `password` or `password_confirmation`, type is `password`. Otherwise `text`.
57
+ # @param [true|false] array If true, the form will expect multiple values for this input. The values will be available as an array. Any values omitted by the user will be present as empty strings.
58
+ #
59
+ #
60
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input INPUT Element
61
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types Input types
39
62
  def initialize(
40
63
  max: nil,
41
64
  maxlength: nil,
@@ -45,13 +68,15 @@ class Brut::FrontEnd::Forms::InputDefinition
45
68
  pattern: nil,
46
69
  required: :based_on_type,
47
70
  step: nil,
48
- type: nil
71
+ type: nil,
72
+ array: false
49
73
  )
50
74
  name = name.to_s
51
75
  type = if type.nil?
52
76
  case name
53
77
  when "email" then "email"
54
78
  when "password" then "password"
79
+ when "password_confirmation" then "password"
55
80
  else
56
81
  "text"
57
82
  end
@@ -68,33 +93,23 @@ class Brut::FrontEnd::Forms::InputDefinition
68
93
  @maxlength = type!( maxlength , Numeric , "maxlength")
69
94
  @min = type!( min , Numeric , "min")
70
95
  @minlength = type!( minlength , Numeric , "minlength")
71
- @name = type!( name , String , "name")
96
+ @name = type!( name , String , "name", required: true)
72
97
  @pattern = type!( pattern , String , "pattern")
73
98
  @required = type!( required , [true, false] , "required", required: true)
74
99
  @step = type!( step , Numeric , "step")
75
100
  @type = type!( type , INPUT_TYPES_TO_CLASS.keys , "type", required: true)
101
+ @array = type!( array , [true, false] , "array", required: true)
76
102
 
77
103
  if @pattern.nil? && type == "email"
78
104
  @pattern = /^[^@]+@[^@]+\.[^@]+$/.source
79
105
  end
80
106
  end
81
107
 
82
- # Create an Input based on this defitition, initializing it with the given value.
83
- def make_input(value:)
84
- Brut::FrontEnd::Forms::Input.new(input_definition: self, value: value)
85
- end
86
- end
87
- class Brut::FrontEnd::Forms::SelectInputDefinition
88
- include Brut::Framework::FussyTypeEnforcement
89
- attr_reader :required, :name
90
- def initialize(name:, required: true)
91
- name = name.to_s
92
- @name = type!( name , String , "name")
93
- @required = type!( required , [true, false] , "required", required:true)
94
- end
108
+ def array? = @array
95
109
 
96
- # Create an Input based on this defitition, initializing it with the given value.
110
+
111
+ # Create an Input based on this definition, initializing it with the given value.
97
112
  def make_input(value:)
98
- Brut::FrontEnd::Forms::SelectInput.new(input_definition: self, value: value)
113
+ Brut::FrontEnd::Forms::Input.new(input_definition: self, value: value)
99
114
  end
100
115
  end