brut 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|