brut 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +7 -0
- data/CODE_OF_CONDUCT.txt +99 -0
- data/Dockerfile.dx +32 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +370 -0
- data/README.md +21 -0
- data/Rakefile +1 -0
- data/bin/bin_kit.rb +39 -0
- data/bin/rake +27 -0
- data/bin/setup +145 -0
- data/brut.gemspec +60 -0
- data/docker-compose.dx.yml +16 -0
- data/dx/build +26 -0
- data/dx/docker-compose.env +22 -0
- data/dx/dx.sh.lib +24 -0
- data/dx/exec +58 -0
- data/dx/prune +19 -0
- data/dx/setupkit.sh.lib +144 -0
- data/dx/show-help-in-app-container-then-wait.sh +38 -0
- data/dx/start +30 -0
- data/dx/stop +23 -0
- data/lib/brut/back_end/action.rb +3 -0
- data/lib/brut/back_end/result.rb +46 -0
- data/lib/brut/back_end/seed_data.rb +24 -0
- data/lib/brut/back_end/validator.rb +3 -0
- data/lib/brut/back_end/validators/form_validator.rb +37 -0
- data/lib/brut/cli/app.rb +130 -0
- data/lib/brut/cli/app_runner.rb +219 -0
- data/lib/brut/cli/apps/build_assets.rb +123 -0
- data/lib/brut/cli/apps/db.rb +279 -0
- data/lib/brut/cli/apps/scaffold.rb +256 -0
- data/lib/brut/cli/apps/test.rb +200 -0
- data/lib/brut/cli/command.rb +130 -0
- data/lib/brut/cli/error.rb +12 -0
- data/lib/brut/cli/execution_results.rb +81 -0
- data/lib/brut/cli/executor.rb +37 -0
- data/lib/brut/cli/options.rb +46 -0
- data/lib/brut/cli/output.rb +30 -0
- data/lib/brut/cli.rb +24 -0
- data/lib/brut/factory_bot.rb +20 -0
- data/lib/brut/framework/app.rb +55 -0
- data/lib/brut/framework/config.rb +415 -0
- data/lib/brut/framework/container.rb +190 -0
- data/lib/brut/framework/errors/abstract_method.rb +9 -0
- data/lib/brut/framework/errors/bug.rb +14 -0
- data/lib/brut/framework/errors/not_found.rb +10 -0
- data/lib/brut/framework/errors.rb +14 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
- data/lib/brut/framework/mcp.rb +215 -0
- data/lib/brut/framework/project_environment.rb +18 -0
- data/lib/brut/framework.rb +13 -0
- data/lib/brut/front_end/asset_metadata.rb +76 -0
- data/lib/brut/front_end/component.rb +213 -0
- data/lib/brut/front_end/components/form_tag.rb +71 -0
- data/lib/brut/front_end/components/i18n_translations.rb +36 -0
- data/lib/brut/front_end/components/input.rb +13 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
- data/lib/brut/front_end/components/inputs/select.rb +100 -0
- data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
- data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
- data/lib/brut/front_end/components/locale_detection.rb +25 -0
- data/lib/brut/front_end/components/page_identifier.rb +13 -0
- data/lib/brut/front_end/components/timestamp.rb +33 -0
- data/lib/brut/front_end/download.rb +23 -0
- data/lib/brut/front_end/flash.rb +57 -0
- data/lib/brut/front_end/form.rb +171 -0
- data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
- data/lib/brut/front_end/forms/input.rb +119 -0
- data/lib/brut/front_end/forms/input_definition.rb +100 -0
- data/lib/brut/front_end/forms/validity_state.rb +36 -0
- data/lib/brut/front_end/handler.rb +48 -0
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
- data/lib/brut/front_end/handling_results.rb +14 -0
- data/lib/brut/front_end/http_method.rb +33 -0
- data/lib/brut/front_end/http_status.rb +16 -0
- data/lib/brut/front_end/middleware.rb +7 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
- data/lib/brut/front_end/page.rb +47 -0
- data/lib/brut/front_end/request_context.rb +82 -0
- data/lib/brut/front_end/route_hook.rb +15 -0
- data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
- data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
- data/lib/brut/front_end/routing.rb +236 -0
- data/lib/brut/front_end/session.rb +56 -0
- data/lib/brut/front_end/template.rb +32 -0
- data/lib/brut/front_end/templates/block_filter.rb +60 -0
- data/lib/brut/front_end/templates/erb_engine.rb +26 -0
- data/lib/brut/front_end/templates/erb_parser.rb +84 -0
- data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
- data/lib/brut/i18n/base_methods.rb +168 -0
- data/lib/brut/i18n/for_cli.rb +4 -0
- data/lib/brut/i18n/for_html.rb +4 -0
- data/lib/brut/i18n/http_accept_language.rb +68 -0
- data/lib/brut/i18n.rb +6 -0
- data/lib/brut/instrumentation/basic.rb +66 -0
- data/lib/brut/instrumentation/event.rb +19 -0
- data/lib/brut/instrumentation/http_event.rb +5 -0
- data/lib/brut/instrumentation/subscriber.rb +41 -0
- data/lib/brut/instrumentation.rb +11 -0
- data/lib/brut/junk_drawer.rb +88 -0
- data/lib/brut/sinatra_helpers.rb +183 -0
- data/lib/brut/spec_support/component_support.rb +49 -0
- data/lib/brut/spec_support/flash_support.rb +7 -0
- data/lib/brut/spec_support/general_support.rb +18 -0
- data/lib/brut/spec_support/handler_support.rb +7 -0
- data/lib/brut/spec_support/matcher.rb +9 -0
- data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
- data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
- data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
- data/lib/brut/spec_support/session_support.rb +3 -0
- data/lib/brut/spec_support.rb +12 -0
- data/lib/brut/version.rb +3 -0
- data/lib/brut.rb +38 -0
- data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
- data/lib/sequel/extensions/brut_migrations.rb +98 -0
- data/lib/sequel/plugins/created_at.rb +14 -0
- data/lib/sequel/plugins/external_id.rb +45 -0
- data/lib/sequel/plugins/find_bang.rb +13 -0
- data/lib/sequel/plugins.rb +3 -0
- metadata +484 -0
@@ -0,0 +1,100 @@
|
|
1
|
+
class Brut::FrontEnd::Components::Inputs::Select < Brut::FrontEnd::Components::Input
|
2
|
+
def self.for_form_input(form:,
|
3
|
+
input_name:,
|
4
|
+
options:,
|
5
|
+
selected_value:,
|
6
|
+
value_attribute:,
|
7
|
+
option_text_attribute:,
|
8
|
+
html_attributes: {})
|
9
|
+
default_html_attributes = {}
|
10
|
+
input = form[input_name]
|
11
|
+
default_html_attributes["required"] = input.required
|
12
|
+
if !form.new? && !input.valid?
|
13
|
+
default_html_attributes["data-invalid"] = true
|
14
|
+
input.validity_state.each do |constraint,violated|
|
15
|
+
if violated
|
16
|
+
default_html_attributes["data-#{constraint}"] = true
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
Brut::FrontEnd::Components::Inputs::Select.new(
|
21
|
+
name: input.name,
|
22
|
+
options:,
|
23
|
+
selected_value:,
|
24
|
+
value_attribute:,
|
25
|
+
option_text_attribute:,
|
26
|
+
html_attributes: default_html_attributes.merge(html_attributes)
|
27
|
+
)
|
28
|
+
end
|
29
|
+
def initialize(name:,
|
30
|
+
options:,
|
31
|
+
include_blank: false,
|
32
|
+
selected_value:,
|
33
|
+
value_attribute:,
|
34
|
+
option_text_attribute:,
|
35
|
+
html_attributes:)
|
36
|
+
@options = options
|
37
|
+
@include_blank = IncludeBlank.from_param(include_blank)
|
38
|
+
@selected_value = selected_value
|
39
|
+
@value_attribute = value_attribute
|
40
|
+
@option_text_attribute = option_text_attribute
|
41
|
+
|
42
|
+
html_attributes["name"] = name
|
43
|
+
@sanitized_attributes = html_attributes.map { |key,value|
|
44
|
+
[
|
45
|
+
key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
|
46
|
+
value
|
47
|
+
]
|
48
|
+
}.select { |key,value|
|
49
|
+
!value.nil?
|
50
|
+
}.to_h
|
51
|
+
end
|
52
|
+
|
53
|
+
def render
|
54
|
+
html_tag(:select,**@sanitized_attributes) {
|
55
|
+
options = @options.map { |option|
|
56
|
+
value = option.send(@value_attribute)
|
57
|
+
option_attributes = { value: value }
|
58
|
+
if value == @selected_value
|
59
|
+
option_attributes[:selected] = true
|
60
|
+
end
|
61
|
+
html_tag(:option,**option_attributes) {
|
62
|
+
option.send(@option_text_attribute)
|
63
|
+
}
|
64
|
+
}
|
65
|
+
if @include_blank
|
66
|
+
options.unshift(html_tag(:option,**@include_blank.option_attributes) {
|
67
|
+
@include_blank.text_content
|
68
|
+
})
|
69
|
+
end
|
70
|
+
options.join("\n")
|
71
|
+
}
|
72
|
+
end
|
73
|
+
private
|
74
|
+
|
75
|
+
class IncludeBlank
|
76
|
+
attr_reader :text_content, :option_attributes
|
77
|
+
def self.from_param(include_blank)
|
78
|
+
if !include_blank
|
79
|
+
return nil
|
80
|
+
else
|
81
|
+
self.new(include_blank)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
def initialize(include_blank)
|
85
|
+
if include_blank == true
|
86
|
+
@text_content = ""
|
87
|
+
@option_attributes = {}
|
88
|
+
elsif include_blank.kind_of?(Hash)
|
89
|
+
if include_blank.key?(:value) && include_blank.key?(:text_content)
|
90
|
+
@text_content = include_blank[:text_content]
|
91
|
+
@option_attributes = { value: include_blank[:value] }
|
92
|
+
else
|
93
|
+
raise ArgumentError, "when include_blank: is a Hash, it must include both :value and :text_content as keys. Got: #{include_blank.keys.join(", ")}"
|
94
|
+
end
|
95
|
+
else
|
96
|
+
raise ArgumentError,"include_blank: was a #{include_blank.class}. It should be true, false, nil, or a Hash"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
class Brut::FrontEnd::Components::Inputs::TextField < Brut::FrontEnd::Components::Input
|
2
|
+
def self.for_form_input(form:, input_name:, html_attributes: {})
|
3
|
+
default_html_attributes = {}
|
4
|
+
input = form[input_name]
|
5
|
+
default_html_attributes["required"] = input.required
|
6
|
+
default_html_attributes["pattern"] = input.pattern
|
7
|
+
default_html_attributes["type"] = input.type
|
8
|
+
default_html_attributes["name"] = input.name
|
9
|
+
if input.max
|
10
|
+
default_html_attributes["max"] = input.max
|
11
|
+
end
|
12
|
+
if input.maxlength
|
13
|
+
default_html_attributes["maxlength"] = input.maxlength
|
14
|
+
end
|
15
|
+
if input.min
|
16
|
+
default_html_attributes["min"] = input.min
|
17
|
+
end
|
18
|
+
if input.minlength
|
19
|
+
default_html_attributes["minlength"] = input.minlength
|
20
|
+
end
|
21
|
+
if input.step
|
22
|
+
default_html_attributes["step"] = input.step
|
23
|
+
end
|
24
|
+
if input.type == "checkbox"
|
25
|
+
default_html_attributes["value"] = "true"
|
26
|
+
default_html_attributes["checked"] = input.value == "true"
|
27
|
+
else
|
28
|
+
default_html_attributes["value"] = input.value
|
29
|
+
end
|
30
|
+
if !form.new? && !input.valid?
|
31
|
+
default_html_attributes["data-invalid"] = true
|
32
|
+
input.validity_state.each do |constraint,violated|
|
33
|
+
if violated
|
34
|
+
default_html_attributes["data-#{constraint}"] = true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
Brut::FrontEnd::Components::Inputs::TextField.new(default_html_attributes.merge(html_attributes))
|
39
|
+
end
|
40
|
+
def initialize(attributes)
|
41
|
+
@sanitized_attributes = attributes.map { |key,value|
|
42
|
+
[
|
43
|
+
key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
|
44
|
+
value
|
45
|
+
]
|
46
|
+
}.select { |key,value|
|
47
|
+
!value.nil?
|
48
|
+
}.to_h
|
49
|
+
end
|
50
|
+
|
51
|
+
def render
|
52
|
+
attribute_string = @sanitized_attributes.map { |key,value|
|
53
|
+
if value == true
|
54
|
+
key
|
55
|
+
elsif value == false
|
56
|
+
""
|
57
|
+
else
|
58
|
+
REXML::Attribute.new(key,value).to_string
|
59
|
+
end
|
60
|
+
}.join(" ")
|
61
|
+
"<input #{attribute_string}>"
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
class Brut::FrontEnd::Components::Inputs::Textarea < Brut::FrontEnd::Components::Input
|
2
|
+
def self.for_form_input(form:, input_name:, html_attributes: {})
|
3
|
+
default_html_attributes = {}
|
4
|
+
input = form[input_name]
|
5
|
+
default_html_attributes["required"] = input.required
|
6
|
+
default_html_attributes["name"] = input.name
|
7
|
+
if input.maxlength
|
8
|
+
default_html_attributes["maxlength"] = input.maxlength
|
9
|
+
end
|
10
|
+
if input.minlength
|
11
|
+
default_html_attributes["minlength"] = input.minlength
|
12
|
+
end
|
13
|
+
if !form.new? && !input.valid?
|
14
|
+
default_html_attributes["data-invalid"] = true
|
15
|
+
input.validity_state.each do |constraint,violated|
|
16
|
+
if violated
|
17
|
+
default_html_attributes["data-#{constraint}"] = true
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
Brut::FrontEnd::Components::Inputs::Textarea.new(default_html_attributes.merge(html_attributes), input.value)
|
22
|
+
end
|
23
|
+
def initialize(attributes, value)
|
24
|
+
@sanitized_attributes = attributes.map { |key,value|
|
25
|
+
[
|
26
|
+
key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
|
27
|
+
value
|
28
|
+
]
|
29
|
+
}.select { |key,value|
|
30
|
+
!value.nil?
|
31
|
+
}.to_h
|
32
|
+
@value = value
|
33
|
+
end
|
34
|
+
|
35
|
+
def sanitized_attributes = @sanitized_attributes
|
36
|
+
|
37
|
+
def render
|
38
|
+
attribute_string = @sanitized_attributes.map { |key,value|
|
39
|
+
if value == true
|
40
|
+
key
|
41
|
+
elsif value == false
|
42
|
+
""
|
43
|
+
else
|
44
|
+
REXML::Attribute.new(key,value).to_string
|
45
|
+
end
|
46
|
+
}.join(" ")
|
47
|
+
%{
|
48
|
+
<textarea #{attribute_string}>#{ @value }</textarea>
|
49
|
+
}
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# Produces `<brut-locale-detection>`
|
2
|
+
class Brut::FrontEnd::Components::LocaleDetection < Brut::FrontEnd::Component
|
3
|
+
def initialize(session:)
|
4
|
+
@timezone = session.timezone_from_browser
|
5
|
+
@locale = session.http_accept_language.known? ? session.http_accept_language.weighted_locales.first&.locale : nil
|
6
|
+
@url = Brut::FrontEnd::Handlers::LocaleDetectionHandler.routing
|
7
|
+
end
|
8
|
+
|
9
|
+
def render
|
10
|
+
attributes = {
|
11
|
+
"url" => @url,
|
12
|
+
}
|
13
|
+
if @timezone
|
14
|
+
attributes["timezone-from-server"] = @timezone.name
|
15
|
+
end
|
16
|
+
if @locale
|
17
|
+
attributes["locale-from-server"] = @locale
|
18
|
+
end
|
19
|
+
if !Brut.container.project_env.production?
|
20
|
+
attributes["show-warnings"] = true
|
21
|
+
end
|
22
|
+
|
23
|
+
html_tag("brut-locale-detection",**attributes)
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "rexml"
|
2
|
+
class Brut::FrontEnd::Components::PageIdentifier < Brut::FrontEnd::Component
|
3
|
+
def initialize(page_name)
|
4
|
+
@page_name = page_name
|
5
|
+
end
|
6
|
+
|
7
|
+
def render
|
8
|
+
if Brut.container.project_env.production?
|
9
|
+
return ""
|
10
|
+
end
|
11
|
+
html_tag(:meta, name: "class", content: @page_name)
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require "rexml"
|
2
|
+
class Brut::FrontEnd::Components::Timestamp < Brut::FrontEnd::Component
|
3
|
+
include Brut::I18n::ForHTML
|
4
|
+
def initialize(timestamp:, format: :full, skip_year_if_same: true, attribute_format: :iso_8601, **only_contains_class)
|
5
|
+
@timestamp = timestamp
|
6
|
+
formats = [ format ]
|
7
|
+
if @timestamp.year == Time.now.year && skip_year_if_same
|
8
|
+
formats.unshift("#{format}_no_year")
|
9
|
+
end
|
10
|
+
format_keys = formats.map { |f| "time.formats.#{f}" }
|
11
|
+
found_format = formats.zip(::I18n.t(format_keys)).detect { |(key,value)|
|
12
|
+
value !~ /^Translation missing/
|
13
|
+
}.first
|
14
|
+
if found_format.nil?
|
15
|
+
raise ArgumentError,"format #{format} is not a known time format"
|
16
|
+
end
|
17
|
+
@format = found_format.to_sym
|
18
|
+
|
19
|
+
if ::I18n.t("time.formats.#{attribute_format}") =~ /^Translation missing/
|
20
|
+
raise ArgumentError,"attribute_format #{attribute_format} is not a known time format"
|
21
|
+
end
|
22
|
+
@attribute_format = attribute_format.to_sym
|
23
|
+
@class_attribute = only_contains_class[:class] || ""
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def render(clock:)
|
28
|
+
timestamp_in_time_zone = clock.in_time_zone(@timestamp)
|
29
|
+
html_tag(:time, class: @class_attribute, datetime: ::I18n.l(timestamp_in_time_zone,format: @attribute_format)) do
|
30
|
+
::I18n.l(timestamp_in_time_zone,format: @format)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class Brut::FrontEnd::Download
|
2
|
+
|
3
|
+
attr_reader :data
|
4
|
+
|
5
|
+
def initialize(filename:,data:,content_type:,timestamp: false)
|
6
|
+
@filename = filename
|
7
|
+
@data = data
|
8
|
+
@content_type = content_type
|
9
|
+
@timestamp = timestamp
|
10
|
+
end
|
11
|
+
|
12
|
+
def headers
|
13
|
+
filename = if @timestamp
|
14
|
+
Time.now.strftime("%Y-%m-%dT%H-%M-%S") + "-" + @filename
|
15
|
+
else
|
16
|
+
@filename
|
17
|
+
end
|
18
|
+
{
|
19
|
+
"content-disposition" => "attachment; filename=\"#{filename}\"",
|
20
|
+
"content-type" => @content_type,
|
21
|
+
}
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
class Brut::FrontEnd::Flash
|
2
|
+
def self.from_h(hash)
|
3
|
+
hash ||= {}
|
4
|
+
self.new(
|
5
|
+
age: hash[:age] || 0,
|
6
|
+
messages: hash[:messages] || {}
|
7
|
+
)
|
8
|
+
end
|
9
|
+
def initialize(age: 0, messages: {})
|
10
|
+
@age = age.to_i
|
11
|
+
if !messages.kind_of?(Hash)
|
12
|
+
raise ArgumentError,"messages must be a Hash, not a #{messages.class}"
|
13
|
+
end
|
14
|
+
@messages = messages
|
15
|
+
end
|
16
|
+
|
17
|
+
def clear!
|
18
|
+
@age = 0
|
19
|
+
@messages = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def notice=(notice)
|
23
|
+
self[:notice] = notice
|
24
|
+
end
|
25
|
+
def notice = self[:notice]
|
26
|
+
def notice? = !!self.notice
|
27
|
+
|
28
|
+
def alert=(alert)
|
29
|
+
self[:alert] = alert
|
30
|
+
end
|
31
|
+
def alert = self[:alert]
|
32
|
+
def alert? = !!self.alert
|
33
|
+
|
34
|
+
def age!
|
35
|
+
@age += 1
|
36
|
+
if @age > 1
|
37
|
+
@age = 0
|
38
|
+
@messages = {}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def [](key)
|
43
|
+
@messages[key]
|
44
|
+
end
|
45
|
+
|
46
|
+
def []=(key,message)
|
47
|
+
@messages[key] = message
|
48
|
+
@age = [0,@age-1].max
|
49
|
+
end
|
50
|
+
|
51
|
+
def to_h
|
52
|
+
{
|
53
|
+
age: @age,
|
54
|
+
messages: @messages,
|
55
|
+
}
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
|
3
|
+
module Brut::FrontEnd::Forms
|
4
|
+
autoload(:InputDefinition, "brut/front_end/forms/input_definition")
|
5
|
+
autoload(:ConstraintViolation, "brut/front_end/forms/constraint_violation")
|
6
|
+
autoload(:ValidityState, "brut/front_end/forms/validity_state")
|
7
|
+
autoload(:Input, "brut/front_end/forms/input")
|
8
|
+
end
|
9
|
+
|
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
|
+
|
45
|
+
class Brut::FrontEnd::Form
|
46
|
+
|
47
|
+
include SemanticLogger::Loggable
|
48
|
+
|
49
|
+
extend Brut::FrontEnd::FormInputDeclaration
|
50
|
+
|
51
|
+
def self.routing(*)
|
52
|
+
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
|
+
end
|
54
|
+
|
55
|
+
# Create an instance of this form, optionally initialized with
|
56
|
+
# the given values for its params.
|
57
|
+
def initialize(params: {})
|
58
|
+
params = convert_to_string_or_nil(params.to_h)
|
59
|
+
unknown_params = params.keys.map(&:to_s).reject { |key|
|
60
|
+
self.class.input_definitions.key?(key)
|
61
|
+
}
|
62
|
+
if unknown_params.any?
|
63
|
+
logger.info "Ignoring unknown params", keys: unknown_params
|
64
|
+
end
|
65
|
+
@params = params.except(*unknown_params)
|
66
|
+
@new = params_empty?(@params)
|
67
|
+
@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 ]
|
70
|
+
}.to_h
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returns true if this form represents a new, empty, untouched form. This is
|
74
|
+
# useful for determining if this form has never been submitted and thus
|
75
|
+
# any required values don't represent an intentional omission by the user.
|
76
|
+
def new? = @new
|
77
|
+
|
78
|
+
# Access an input with the given name
|
79
|
+
def [](input_name) = @inputs.fetch(input_name.to_s)
|
80
|
+
|
81
|
+
# Returns true if this form has constraint violations.
|
82
|
+
def constraint_violations? = !@inputs.values.all?(&:valid?)
|
83
|
+
|
84
|
+
# 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)
|
87
|
+
end
|
88
|
+
|
89
|
+
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
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def params_empty?(params) = params.nil? || params.empty?
|
111
|
+
|
112
|
+
def convert_to_string_or_nil(hash)
|
113
|
+
hash.each do |key,value|
|
114
|
+
case value
|
115
|
+
in Hash then convert_to_string_or_nil(value)
|
116
|
+
in String then hash[key] = RichString.new(value).to_s_or_nil
|
117
|
+
in Numeric then hash[key] = value.to_s
|
118
|
+
in TrueClass then hash[key] = "true"
|
119
|
+
in FalseClass then hash[key] = "false"
|
120
|
+
in NilClass then # it's fine
|
121
|
+
else
|
122
|
+
if Brut.container.project_env.test?
|
123
|
+
raise ArgumentError, "Got #{value.class} for #{key} in params hash, which is not expected"
|
124
|
+
else
|
125
|
+
logger.warn("Got #{value.class} for #{key} in params hash, which is not expected")
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
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
|
+
end
|
@@ -0,0 +1,39 @@
|
|
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.
|
3
|
+
class Brut::FrontEnd::Forms::ConstraintViolation
|
4
|
+
|
5
|
+
CLIENT_SIDE_KEYS = [
|
6
|
+
"bad_input",
|
7
|
+
"custom_error",
|
8
|
+
"pattern_mismatch",
|
9
|
+
"range_overflow",
|
10
|
+
"range_underflow",
|
11
|
+
"step_mismatch",
|
12
|
+
"too_long",
|
13
|
+
"too_short",
|
14
|
+
"type_mismatch",
|
15
|
+
"value_missing",
|
16
|
+
]
|
17
|
+
|
18
|
+
attr_reader :key, :context
|
19
|
+
|
20
|
+
def initialize(key:,context:, server_side: :based_on_key)
|
21
|
+
@key = key.to_s
|
22
|
+
@client_side = CLIENT_SIDE_KEYS.include?(@key) && server_side != true
|
23
|
+
@context = context || {}
|
24
|
+
if !@context.kind_of?(Hash)
|
25
|
+
raise "#{self.class} created for key #{key} with an invalid context: '#{context}/#{context.class}'. Context must be nil or a hash"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def client_side? = @client_side
|
30
|
+
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
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# An Input is a stateful object representing a specific input and its value
|
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.
|
4
|
+
class Brut::FrontEnd::Forms::Input
|
5
|
+
|
6
|
+
extend Forwardable
|
7
|
+
|
8
|
+
attr_reader :value, :validity_state
|
9
|
+
|
10
|
+
def initialize(input_definition:, value:)
|
11
|
+
@input_definition = input_definition
|
12
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new
|
13
|
+
self.value=(value)
|
14
|
+
end
|
15
|
+
|
16
|
+
def_delegators :"@input_definition", :max,
|
17
|
+
:maxlength,
|
18
|
+
:min,
|
19
|
+
:minlength,
|
20
|
+
:name,
|
21
|
+
:pattern,
|
22
|
+
:required,
|
23
|
+
:step,
|
24
|
+
:type
|
25
|
+
|
26
|
+
def value=(new_value)
|
27
|
+
value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
|
28
|
+
missing = if self.required
|
29
|
+
value_missing
|
30
|
+
else
|
31
|
+
false
|
32
|
+
end
|
33
|
+
too_short = if self.minlength && !value_missing
|
34
|
+
new_value.length < self.minlength
|
35
|
+
else
|
36
|
+
false
|
37
|
+
end
|
38
|
+
|
39
|
+
too_long = if self.maxlength && !value_missing
|
40
|
+
new_value.length > self.maxlength
|
41
|
+
else
|
42
|
+
false
|
43
|
+
end
|
44
|
+
|
45
|
+
type_mismatch = false # TBD
|
46
|
+
|
47
|
+
range_overflow = if self.max && !value_missing && !type_mismatch
|
48
|
+
new_value.to_i > self.max
|
49
|
+
else
|
50
|
+
false
|
51
|
+
end
|
52
|
+
|
53
|
+
range_underflow = if self.min && !value_missing && !type_mismatch
|
54
|
+
new_value.to_i < self.min
|
55
|
+
else
|
56
|
+
false
|
57
|
+
end
|
58
|
+
|
59
|
+
pattern_mismatch = false
|
60
|
+
step_mismatch = false
|
61
|
+
|
62
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new(
|
63
|
+
value_missing: missing,
|
64
|
+
too_short: too_short,
|
65
|
+
too_long: too_short,
|
66
|
+
range_overflow: range_overflow,
|
67
|
+
range_underflow: range_underflow,
|
68
|
+
pattern_mismatch: pattern_mismatch,
|
69
|
+
step_mismatch: step_mismatch,
|
70
|
+
type_mismatch: type_mismatch,
|
71
|
+
)
|
72
|
+
@value = new_value
|
73
|
+
end
|
74
|
+
|
75
|
+
# Set a server-side constraint violation on this input. This is essentially arbitrary, but note
|
76
|
+
# that `key` should not be a key used for client-side validations.
|
77
|
+
def server_side_constraint_violation(key,context=true)
|
78
|
+
@validity_state.server_side_constraint_violation(key: key, context: context)
|
79
|
+
end
|
80
|
+
|
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
|
+
|
118
|
+
def valid? = @validity_state.valid?
|
119
|
+
end
|