brut 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- data/lib/brut/instrumentation/subscriber.rb +0 -41
data/lib/brut/front_end/form.rb
CHANGED
@@ -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
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
69
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
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,
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
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
|
-
|
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
|
-
|
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.
|
2
|
-
#
|
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
|
-
|
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
|
-
|
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 =
|
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
|
-
|
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
|
-
#
|
2
|
-
#
|
3
|
-
#
|
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
|
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
|
-
|
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
|
-
|
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::
|
113
|
+
Brut::FrontEnd::Forms::Input.new(input_definition: self, value: value)
|
99
114
|
end
|
100
115
|
end
|