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
@@ -0,0 +1,57 @@
|
|
1
|
+
# Renders the custom elements used to manage both client- and server-side constraint violations via the `<brut-cv-messages>` and `<brut-cv>` tags. Each constraint violation on the input's {Brut::FrontEnd::Forms::ValidityState} will generate a `<brut-cv server-side>` tag that will contain the I18n translation of the violation's {Brut::FrontEnd::Forms::ConstraintViolation#key} prefixed with `"cv.be"`.
|
2
|
+
#
|
3
|
+
# The general form of this component will be:
|
4
|
+
#
|
5
|
+
# ```html
|
6
|
+
# <brut-cv-messages input-name="«input_name»">
|
7
|
+
# <brut-cv server-side>
|
8
|
+
# «message»
|
9
|
+
# </brut-cv>
|
10
|
+
# <brut-cv server-side>
|
11
|
+
# «message»
|
12
|
+
# </brut-cv>
|
13
|
+
# <!- ... ->
|
14
|
+
# </brut-cv-messages>
|
15
|
+
# ```
|
16
|
+
#
|
17
|
+
# Note that if you are using `<brut-form>` then `<brut-cv>` elements will be inserted into the `<brut-cv-messages>` element, however
|
18
|
+
# they will not have the `server-side` attribute.
|
19
|
+
#
|
20
|
+
# You will most commonly use this component via {Brut::FrontEnd::Component::Helpers#constraint_violations}.
|
21
|
+
class Brut::FrontEnd::Components::ConstraintViolations < Brut::FrontEnd::Component
|
22
|
+
# Create a new ConstraintViolations component
|
23
|
+
#
|
24
|
+
# @param [Brut::FrontEnd::Form] form the form in which this component is being rendered.
|
25
|
+
# @param [String|Symbol] input_name the name of the input, based on what was used in the form object.
|
26
|
+
# @param [Hash] html_attributes attributes to be placed on the outer `<brut-cv-messages>` element.
|
27
|
+
# @param [Integer] index index of the input, for array-based inputs
|
28
|
+
# @param [Hash] message_html_attributes attributes to be placed on each inner `<brut-cv>` element.
|
29
|
+
def initialize(form:, input_name:, index: nil, message_html_attributes: {}, **html_attributes)
|
30
|
+
@form = form
|
31
|
+
@input_name = input_name
|
32
|
+
@array = !index.nil?
|
33
|
+
@index = index || 0
|
34
|
+
@html_attributes = html_attributes
|
35
|
+
@message_html_attributes = message_html_attributes
|
36
|
+
end
|
37
|
+
|
38
|
+
def render
|
39
|
+
html_attributes = {
|
40
|
+
"input-name": @array ? "#{@input_name}[]" : @input_name
|
41
|
+
}.merge(@html_attributes)
|
42
|
+
|
43
|
+
message_html_attributes = {
|
44
|
+
"server-side": true,
|
45
|
+
}.merge(@message_html_attributes)
|
46
|
+
|
47
|
+
html_tag("brut-cv-messages", **html_attributes) do
|
48
|
+
@form.input(@input_name, index: @index).validity_state.select { |constraint|
|
49
|
+
!constraint.client_side?
|
50
|
+
}.map { |constraint|
|
51
|
+
html_tag("brut-cv",**message_html_attributes) do
|
52
|
+
t("cv.be.#{constraint}", **constraint.context).capitalize
|
53
|
+
end
|
54
|
+
}.join("\n")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -1,48 +1,39 @@
|
|
1
1
|
require "rexml"
|
2
|
-
# Represents a
|
2
|
+
# Represents a `<form>` HTML element that includes a CSRF token as needed. You likely want to use this class via the {Brut::FrontEnd::Component::Helpers#form_tag} method.
|
3
3
|
class Brut::FrontEnd::Components::FormTag < Brut::FrontEnd::Component
|
4
|
-
|
5
|
-
|
4
|
+
# (see Brut::FrontEnd::Component::Helpers#form_tag)
|
5
|
+
def initialize(route_params: {}, **html_attributes,&contents)
|
6
|
+
form_class = html_attributes.delete(:for) # Cannot be a keyword arg, since for is a reserved word
|
6
7
|
if !form_class.nil?
|
7
|
-
if
|
8
|
-
|
8
|
+
if form_class.kind_of?(Brut::FrontEnd::Form)
|
9
|
+
form_class = form_class.class
|
9
10
|
end
|
10
|
-
if
|
11
|
-
raise ArgumentError, "You cannot specify both for: (#{form_class}) and and
|
11
|
+
if html_attributes[:action]
|
12
|
+
raise ArgumentError, "You cannot specify both for: (#{form_class}) and and action: (#{html_attributes[:action]}) to a form_tag"
|
13
|
+
end
|
14
|
+
if html_attributes[:method]
|
15
|
+
raise ArgumentError, "You cannot specify both for: (#{form_class}) and and method: (#{html_attributes[:method]}) to a form_tag"
|
16
|
+
end
|
17
|
+
begin
|
18
|
+
route = Brut.container.routing.route(form_class)
|
19
|
+
html_attributes[:method] = route.http_method
|
20
|
+
html_attributes[:action] = route.path(**route_params)
|
21
|
+
rescue Brut::Framework::Errors::MissingParameter
|
22
|
+
raise ArgumentError, "You specified #{form_class} (or an instance of it), but it requires more url parameters than were found in route_params: (or route_params: was omitted). Please add all required parameters to route_params: or use `action: #{form_class}.routing(..params..), method: [:get|:post]` instead"
|
12
23
|
end
|
13
|
-
route = Brut.container.routing.route(form_class)
|
14
|
-
attributes[:method] = route.http_method
|
15
|
-
attributes[:action] = route.path
|
16
24
|
end
|
17
25
|
|
18
|
-
@include_csrf_token = true
|
19
26
|
@csrf_token_omit_reasoning = nil
|
20
27
|
|
21
|
-
http_method = Brut::FrontEnd::HttpMethod.new(
|
28
|
+
http_method = Brut::FrontEnd::HttpMethod.new(html_attributes[:method])
|
22
29
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
end
|
27
|
-
force_csrf_token = attributes.delete(:force_csrf_token)
|
28
|
-
if !force_csrf_token
|
29
|
-
@include_csrf_token = false
|
30
|
-
@csrf_token_omit_reasoning = "because this form's action is GET"
|
31
|
-
end
|
32
|
-
else
|
33
|
-
if attributes.key?(:force_csrf_token)
|
34
|
-
raise ArgumentError,":force_csrf_token is not allowed for form_tag when the HTTP method is not a GET"
|
35
|
-
end
|
36
|
-
no_csrf_token = attributes.delete(:no_csrf_token)
|
37
|
-
if no_csrf_token
|
38
|
-
@include_csrf_token = false
|
39
|
-
@csrf_token_omit_reasoning = "because :no_csrf_token was passed to form_tag"
|
40
|
-
end
|
41
|
-
end
|
42
|
-
@attributes = attributes
|
30
|
+
@include_csrf_token = http_method.post?
|
31
|
+
@csrf_token_omit_reasoning = http_method.get? ? "because this form's action is a GET" : nil
|
32
|
+
@attributes = html_attributes
|
43
33
|
@contents = contents
|
44
34
|
end
|
45
35
|
|
36
|
+
# @!visibility private
|
46
37
|
def render
|
47
38
|
attribute_string = @attributes.map { |key,value|
|
48
39
|
key = key.to_s
|
@@ -1,11 +1,44 @@
|
|
1
1
|
require "rexml"
|
2
2
|
|
3
|
-
# Produces `<brut-i18n-translation>` entries for the given values
|
3
|
+
# Produces `<brut-i18n-translation>` entries for the given values. This is used for client-side constraint violation messaging with
|
4
|
+
# JavaScript. The `<brut-constraint-violation-message>` tag uses these keys to produce messages on the client.
|
5
|
+
#
|
6
|
+
# The default layout included in new Brut apps includes this:
|
7
|
+
#
|
8
|
+
# ```html
|
9
|
+
# <%= component(
|
10
|
+
# Brut::FrontEnd::Components::I18nTranslations.new(
|
11
|
+
# "general.cv.fe"
|
12
|
+
# )
|
13
|
+
# ) %>
|
14
|
+
# ```
|
15
|
+
#
|
16
|
+
# At runtime, this will produce this:
|
17
|
+
#
|
18
|
+
# ```html
|
19
|
+
# <brut-i18n-translation
|
20
|
+
# key="general.cv.fe.badInput"
|
21
|
+
# value="%{field} is the wrong type of data">
|
22
|
+
# </brut-i18n-translation>
|
23
|
+
# <brut-i18n-translation
|
24
|
+
# key="general.cv.fe.patternMismatch"
|
25
|
+
# value="%{field} isn't in the right format">
|
26
|
+
# </brut-i18n-translation>
|
27
|
+
# <!-- etc -->
|
28
|
+
# ```
|
29
|
+
#
|
30
|
+
# Thus, it will render the translations for all client side errors supported by the browser. This means that if a
|
31
|
+
# client side `ValidityState` returns true for, say, `badInput`, JavaScript can look up by the `key`
|
32
|
+
# `general.cv.fe.badInput` and find the `value` to produce the string "This field is the wrong type of data".
|
4
33
|
class Brut::FrontEnd::Components::I18nTranslations < Brut::FrontEnd::Component
|
34
|
+
|
35
|
+
# Create the component for all keys under the given root
|
36
|
+
# @param [String] i18n_key_root A prefix or full key for the i18n messages to render. For example, if you have `en.cv.fe.valueMissing` and `en.cv.fe.badInput`, an `i18n_key_root` value of `"en.cv.fe"` will result in both of those keys being rendered.
|
5
37
|
def initialize(i18n_key_root)
|
6
38
|
@i18n_key_root = i18n_key_root
|
7
39
|
end
|
8
40
|
|
41
|
+
# @!visibility private
|
9
42
|
def render
|
10
43
|
values = ::I18n.t(@i18n_key_root)
|
11
44
|
if values.kind_of?(String)
|
@@ -1,13 +1,16 @@
|
|
1
1
|
require "rexml"
|
2
2
|
module Brut::FrontEnd::Components
|
3
3
|
|
4
|
+
# Holds components designed to render HTML `<input>` and other form components.
|
4
5
|
module Inputs
|
5
6
|
autoload(:TextField,"brut/front_end/components/inputs/text_field")
|
7
|
+
autoload(:RadioButton,"brut/front_end/components/inputs/radio_button")
|
6
8
|
autoload(:Select,"brut/front_end/components/inputs/select")
|
7
9
|
autoload(:Textarea,"brut/front_end/components/inputs/textarea")
|
8
10
|
autoload(:CsrfToken,"brut/front_end/components/inputs/csrf_token")
|
9
11
|
end
|
10
12
|
|
13
|
+
# Base class for all inputs
|
11
14
|
class Input < Brut::FrontEnd::Component
|
12
15
|
end
|
13
16
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# Renders a hidden field for a form that contains the current CSRF token. You only need
|
2
|
+
# to use this directly if you are building a form without {Brut::FrontEnd::Component::Helpers#form_tag}.
|
1
3
|
class Brut::FrontEnd::Components::Inputs::CsrfToken < Brut::FrontEnd::Components::Input
|
2
4
|
def initialize(csrf_token:)
|
3
5
|
@csrf_token = csrf_token
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# Renders an HTML `<input type="radio">`. Unlike other form fields, radio
|
2
|
+
# button groups require several HTML elements to present the visitor a choice. All of the classes
|
3
|
+
# internal to the {Brut::FrontEnd::Form} treat the radio button group as a single input with
|
4
|
+
# a single name and value. When it comes time to generate HTML, this class is used
|
5
|
+
# to generate a single radio button from a group.
|
6
|
+
class Brut::FrontEnd::Components::Inputs::RadioButton < Brut::FrontEnd::Components::Inputs::TextField
|
7
|
+
# Creates a radio button that is part of a radio button group. You should call this
|
8
|
+
# method once for each radio button in the group.
|
9
|
+
#
|
10
|
+
# @param [Brut::FrontEnd::Form} form The form that is being rendered. This method will consult this class to understand the requirements on this input so its HTML is generated correctly.
|
11
|
+
# @param [String] input_name the name of the input, which should be a member of `form`
|
12
|
+
# @param [String] value the value for this radio button. The {Brut::FrontEnd::Forms::RadioButtonGroupInput} value is compared
|
13
|
+
# against this value to determine if this `<input>` will have the `checked` attribute.
|
14
|
+
# @param [Hash] html_attributes any additional HTML attributes to include on the `<input>` element.
|
15
|
+
def self.for_form_input(form:, input_name:, value:, html_attributes: {})
|
16
|
+
default_html_attributes = {}
|
17
|
+
html_attributes = html_attributes.map { |key,value| [ key.to_s, value ] }.to_h
|
18
|
+
input = form.input(input_name)
|
19
|
+
|
20
|
+
default_html_attributes["required"] = input.required
|
21
|
+
default_html_attributes["type"] = "radio"
|
22
|
+
default_html_attributes["name"] = input.name
|
23
|
+
default_html_attributes["value"] = value
|
24
|
+
|
25
|
+
selected_value = input.value
|
26
|
+
|
27
|
+
if selected_value == value
|
28
|
+
default_html_attributes["checked"] = true
|
29
|
+
end
|
30
|
+
|
31
|
+
if !form.new? && !input.valid?
|
32
|
+
default_html_attributes["data-invalid"] = true
|
33
|
+
input.validity_state.each do |constraint,violated|
|
34
|
+
if violated
|
35
|
+
default_html_attributes["data-#{constraint}"] = true
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
Brut::FrontEnd::Components::Inputs::RadioButton.new(default_html_attributes.merge(html_attributes))
|
40
|
+
end
|
41
|
+
end
|
@@ -1,13 +1,29 @@
|
|
1
|
+
# Renders an HTML `<select>`.
|
1
2
|
class Brut::FrontEnd::Components::Inputs::Select < Brut::FrontEnd::Components::Input
|
3
|
+
# Creates the appropriate select input for the given {Brut::FrontEnd::Form} and input name.
|
4
|
+
# Generally, you want to use this method over the initializer.
|
5
|
+
#
|
6
|
+
# @param [Brut::FrontEnd::Form} form The form that is being rendered. This method will consult this class to understand the requirements on this select so its HTML is generated correctly.
|
7
|
+
# @param [String] input_name the name of the input, which should be a member of `form`
|
8
|
+
# @param [Array<Object>] options An array of objects represented what is being selected. These can be any object and are ideally whatever domain object or data type you want on the backend to represent this selection.
|
9
|
+
# @param [Object] selected_value The currently-selected value for the select. Can be `nil` if nothing is selected.
|
10
|
+
# @param [Symbol|String] value_attribute the name of an attribute or no-parameter method that can be called on objects inside `options` to get the value to use in the select input. This should be unique amongst the options, and is usually an id.
|
11
|
+
# @param [Symbol|String] option_text_attribute the name of an attribute or no-parameter method that can be called on objects inside `options` to get the actual text of the option shown to the user. This should probably allow for I18n.
|
12
|
+
# @param [Integer] index if this input is part of an array, this is the index into that array. This is used to get the input's value.
|
13
|
+
# @param [Hash] html_attributes any additional HTML attributes to include on the `<select>` element.
|
14
|
+
# @param [false|true|Hash] include_blank configure how and if to include a blank element in the select. If this is false, there will be no blank element. If it's `true`, there will be one with no value nor text. If this is a `Hash` it must contain a `value:` key and `text_content:` key to be used as the `value` attribute and option text content, respectively.
|
2
15
|
def self.for_form_input(form:,
|
3
16
|
input_name:,
|
4
17
|
options:,
|
5
18
|
selected_value:,
|
19
|
+
include_blank: false,
|
6
20
|
value_attribute:,
|
7
21
|
option_text_attribute:,
|
22
|
+
index: nil,
|
8
23
|
html_attributes: {})
|
9
24
|
default_html_attributes = {}
|
10
|
-
|
25
|
+
index ||= 0
|
26
|
+
input = form.input(input_name, index:)
|
11
27
|
default_html_attributes["required"] = input.required
|
12
28
|
if !form.new? && !input.valid?
|
13
29
|
default_html_attributes["data-invalid"] = true
|
@@ -17,15 +33,22 @@ class Brut::FrontEnd::Components::Inputs::Select < Brut::FrontEnd::Components::I
|
|
17
33
|
end
|
18
34
|
end
|
19
35
|
end
|
36
|
+
name = if input.array?
|
37
|
+
"#{input.name}[]"
|
38
|
+
else
|
39
|
+
input.name
|
40
|
+
end
|
20
41
|
Brut::FrontEnd::Components::Inputs::Select.new(
|
21
|
-
name:
|
42
|
+
name: name,
|
22
43
|
options:,
|
23
44
|
selected_value:,
|
24
45
|
value_attribute:,
|
25
46
|
option_text_attribute:,
|
47
|
+
include_blank:,
|
26
48
|
html_attributes: default_html_attributes.merge(html_attributes)
|
27
49
|
)
|
28
50
|
end
|
51
|
+
# Create the element. See {.for_form_input} for documentation on these parameters.
|
29
52
|
def initialize(name:,
|
30
53
|
options:,
|
31
54
|
include_blank: false,
|
@@ -72,6 +95,7 @@ class Brut::FrontEnd::Components::Inputs::Select < Brut::FrontEnd::Components::I
|
|
72
95
|
end
|
73
96
|
private
|
74
97
|
|
98
|
+
# @!visibility private
|
75
99
|
class IncludeBlank
|
76
100
|
attr_reader :text_content, :option_attributes
|
77
101
|
def self.from_param(include_blank)
|
@@ -1,11 +1,27 @@
|
|
1
|
+
# Generates an HTML `<input>` field.
|
1
2
|
class Brut::FrontEnd::Components::Inputs::TextField < Brut::FrontEnd::Components::Input
|
2
|
-
|
3
|
+
# Creates the appropriate input for the given {Brut::FrontEnd::Form} and input name.
|
4
|
+
# Generally, you want to use this method over the initializer.
|
5
|
+
#
|
6
|
+
# @param [Brut::FrontEnd::Form} form The form that is being rendered. This method will consult this class to understand the requirements on this input so its HTML is generated correctly.
|
7
|
+
# @param [String] input_name the name of the input, which should be a member of `form`
|
8
|
+
# @param [Integer] index if this input is part of an array, this is the index into that array. This is used to get the input's value.
|
9
|
+
# @param [Hash] html_attributes any additional HTML attributes to include on the `<input>` element.
|
10
|
+
def self.for_form_input(form:, input_name:, index: nil, html_attributes: {})
|
3
11
|
default_html_attributes = {}
|
4
|
-
|
12
|
+
html_attributes = html_attributes.map { |key,value| [ key.to_s, value ] }.to_h
|
13
|
+
index ||= 0
|
14
|
+
input = form.input(input_name, index:)
|
15
|
+
|
5
16
|
default_html_attributes["required"] = input.required
|
6
17
|
default_html_attributes["pattern"] = input.pattern
|
7
18
|
default_html_attributes["type"] = input.type
|
8
|
-
default_html_attributes["name"] = input.
|
19
|
+
default_html_attributes["name"] = if input.array?
|
20
|
+
"#{input.name}[]"
|
21
|
+
else
|
22
|
+
input.name
|
23
|
+
end
|
24
|
+
|
9
25
|
if input.max
|
10
26
|
default_html_attributes["max"] = input.max
|
11
27
|
end
|
@@ -21,11 +37,13 @@ class Brut::FrontEnd::Components::Inputs::TextField < Brut::FrontEnd::Components
|
|
21
37
|
if input.step
|
22
38
|
default_html_attributes["step"] = input.step
|
23
39
|
end
|
40
|
+
value = input.value
|
41
|
+
|
24
42
|
if input.type == "checkbox"
|
25
43
|
default_html_attributes["value"] = "true"
|
26
|
-
default_html_attributes["checked"] =
|
44
|
+
default_html_attributes["checked"] = value == "true"
|
27
45
|
else
|
28
|
-
default_html_attributes["value"] =
|
46
|
+
default_html_attributes["value"] = value
|
29
47
|
end
|
30
48
|
if !form.new? && !input.valid?
|
31
49
|
default_html_attributes["data-invalid"] = true
|
@@ -37,6 +55,10 @@ class Brut::FrontEnd::Components::Inputs::TextField < Brut::FrontEnd::Components
|
|
37
55
|
end
|
38
56
|
Brut::FrontEnd::Components::Inputs::TextField.new(default_html_attributes.merge(html_attributes))
|
39
57
|
end
|
58
|
+
|
59
|
+
# Create an instance
|
60
|
+
#
|
61
|
+
# @param [Hash] attributes HTML attributes to put on the element.
|
40
62
|
def initialize(attributes)
|
41
63
|
@sanitized_attributes = attributes.map { |key,value|
|
42
64
|
[
|
@@ -1,9 +1,24 @@
|
|
1
|
+
# Generates an HTML `<textarea>` field.
|
1
2
|
class Brut::FrontEnd::Components::Inputs::Textarea < Brut::FrontEnd::Components::Input
|
2
|
-
|
3
|
+
# Creates the appropriate textarea for the given {Brut::FrontEnd::Form} and input name.
|
4
|
+
# Generally, you want to use this method over the initializer.
|
5
|
+
#
|
6
|
+
# @param [Brut::FrontEnd::Form} form The form that is being rendered. This method will consult this class to understand the requirements on this textarea so its HTML is generated correctly.
|
7
|
+
# @param [String] input_name the name of the input, which should be a member of `form`
|
8
|
+
# @param [Integer] index if this input is part of an array, this is the index into that array. This is used to get the input's value.
|
9
|
+
# @param [Hash] html_attributes any additional HTML attributes to include on the `<textarea>` element.
|
10
|
+
def self.for_form_input(form:, input_name:, index: nil, html_attributes: {})
|
3
11
|
default_html_attributes = {}
|
4
|
-
|
12
|
+
|
13
|
+
index ||= 0
|
14
|
+
input = form.input(input_name, index:)
|
15
|
+
|
5
16
|
default_html_attributes["required"] = input.required
|
6
|
-
default_html_attributes["name"] = input.
|
17
|
+
default_html_attributes["name"] = if input.array?
|
18
|
+
"#{input.name}[]"
|
19
|
+
else
|
20
|
+
input.name
|
21
|
+
end
|
7
22
|
if input.maxlength
|
8
23
|
default_html_attributes["maxlength"] = input.maxlength
|
9
24
|
end
|
@@ -18,8 +33,13 @@ class Brut::FrontEnd::Components::Inputs::Textarea < Brut::FrontEnd::Components:
|
|
18
33
|
end
|
19
34
|
end
|
20
35
|
end
|
21
|
-
|
36
|
+
value = input.value
|
37
|
+
Brut::FrontEnd::Components::Inputs::Textarea.new(default_html_attributes.merge(html_attributes), value)
|
22
38
|
end
|
39
|
+
# Create an instance
|
40
|
+
#
|
41
|
+
# @param [Hash] attributes HTML attributes to put on the element.
|
42
|
+
# @param [String] value the value to place inside the text area
|
23
43
|
def initialize(attributes, value)
|
24
44
|
@sanitized_attributes = attributes.map { |key,value|
|
25
45
|
[
|
@@ -1,4 +1,11 @@
|
|
1
|
-
# Produces `<brut-locale-detection>`
|
1
|
+
# Produces the `<brut-locale-detection>` custom element, with attributes set as appropriate based on the server's
|
2
|
+
# understanding of the current session's locale.
|
3
|
+
#
|
4
|
+
# The `<brut-locale-detection>` element exists to send a JSON payload back to the server
|
5
|
+
# (handled by {Brut::FrontEnd::Handlers::LocaleDetectionHandler}), with information about the browser's time zone and locale.
|
6
|
+
#
|
7
|
+
# This element doesn't need to do this if the server has this information. This component handles creating the right HTML to either
|
8
|
+
# ask the browser to send it, or not.
|
2
9
|
class Brut::FrontEnd::Components::LocaleDetection < Brut::FrontEnd::Component
|
3
10
|
def initialize(session:)
|
4
11
|
@timezone = session.timezone_from_browser
|
@@ -1,4 +1,6 @@
|
|
1
1
|
require "rexml"
|
2
|
+
|
3
|
+
# Renders a `<meta>` tag that contains the name of the page. This is useful for end to end tests to assert that they are on a specific page before continuing with the test. It can eliminate a lot of confusion when a test fails.
|
2
4
|
class Brut::FrontEnd::Components::PageIdentifier < Brut::FrontEnd::Component
|
3
5
|
def initialize(page_name)
|
4
6
|
@page_name = page_name
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require "rexml"
|
2
|
+
# Renders a date or timestamp accessibly, using the `<time>` element. Likely you will use this via the {Brut::FrontEnd::Component::Helpers#time_tag} method. This will account for the current request's time zone. See {Clock}.
|
3
|
+
class Brut::FrontEnd::Components::Time < Brut::FrontEnd::Component
|
4
|
+
include Brut::I18n::ForHTML
|
5
|
+
# Creates the component
|
6
|
+
# @param timestamp [Time] the timestamp you wish to render. Mutually exclusive with `date`.
|
7
|
+
# @param date [Date] the date you wish to render. Mutually exclusive with `timestamp`.
|
8
|
+
# @param format [Symbol] the I18n format key fragment to use to locate the strftime format for formatting the timestamp. This is appended to `"time.formats."` to form the full string. If `skip_year_if_same` is true *and* the year of this timestamp is this year, `"_no_year"` is further appended. For example, if this value is `:full` and `skip_year_if_same` is false, the I18n key used will be `"time.formats.full"`. If `skip_year_if_same` is true, the key would be `"time.formats.full_no_year"` only if this year is the year of the timestamp. Otherwise `"time.formats.full"` would be used.
|
9
|
+
# @param skip_year_if_same [true|false] if true, and this year is the same year as the timestamp, `"_no_year"` is appened to the value of `format` to form the I18n key to use. This is applied before `skip_dow_if_not_this_week`'s suffix is.
|
10
|
+
# @param skip_dow_if_not_this_week [true|false] if true, and the date/timestamp is within 7 days of now, appends `"no_dow"` to the format string. If this matches a configured format, it's assumed that would be just like `format` but without the day of the week. This is applied after `skip_year_if_same`'s suffix is.
|
11
|
+
# @param attribute_format [Symbol] the I18n format key fragment to use to locate the strftime format for formatting *the `datetime` attribute* of the HTML element that this component renders. Generally, you want to leave this as the default of `:iso_8601`, however if you need to change it, you can. This value is appeneded to `"time.formats."` to form the complete key. `skip_year_if_same` is not used for this value.
|
12
|
+
# @param only_contains_class [Hash] exists because `class` is a reserved word
|
13
|
+
# @option only_contains_class [String] :class the value to use for the `class` attribute.
|
14
|
+
def initialize(
|
15
|
+
timestamp: nil,
|
16
|
+
date: nil,
|
17
|
+
format: :full,
|
18
|
+
skip_year_if_same: true,
|
19
|
+
skip_dow_if_not_this_week: true,
|
20
|
+
attribute_format: :iso_8601,
|
21
|
+
**only_contains_class
|
22
|
+
)
|
23
|
+
require_exactly_one!(timestamp:,date:)
|
24
|
+
|
25
|
+
@date_only = timestamp.nil?
|
26
|
+
@timestamp = timestamp || date
|
27
|
+
|
28
|
+
formats = [ format ]
|
29
|
+
use_no_year = skip_year_if_same && @timestamp.year == Time.now.year
|
30
|
+
use_no_dow = if skip_dow_if_not_this_week
|
31
|
+
$seven_days_ago = (Date.today - 7).to_time
|
32
|
+
$timestamp = @timestamp.to_time
|
33
|
+
$timestamp < $seven_days_ago
|
34
|
+
else
|
35
|
+
false
|
36
|
+
end
|
37
|
+
if use_no_year
|
38
|
+
formats.unshift("#{format}_no_year")
|
39
|
+
end
|
40
|
+
|
41
|
+
if use_no_dow
|
42
|
+
if use_no_year
|
43
|
+
formats.unshift("#{format}_no_year_no_dow")
|
44
|
+
else
|
45
|
+
formats.unshift("#{format}_no_dow")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
assumed_key_base = if @date_only
|
50
|
+
"date.formats"
|
51
|
+
else
|
52
|
+
"time.formats"
|
53
|
+
end
|
54
|
+
|
55
|
+
format_keys = formats.map { |f| "#{assumed_key_base}.#{f}" }
|
56
|
+
|
57
|
+
found_format,_value = formats.zip( ::I18n.t(format_keys) ).detect { |(_key,value)|
|
58
|
+
value !~ /^Translation missing/
|
59
|
+
}
|
60
|
+
|
61
|
+
if found_format.nil?
|
62
|
+
raise ArgumentError,"format #{format} is not a known time format (checked #{format_keys})"
|
63
|
+
end
|
64
|
+
|
65
|
+
@format = found_format.to_sym
|
66
|
+
@attribute_format = attribute_format.to_sym
|
67
|
+
@class_attribute = only_contains_class[:class] || ""
|
68
|
+
end
|
69
|
+
|
70
|
+
def render(clock:)
|
71
|
+
adjusted_value = if @date_only
|
72
|
+
@timestamp
|
73
|
+
else
|
74
|
+
clock.in_time_zone(@timestamp)
|
75
|
+
end
|
76
|
+
|
77
|
+
datetime_attribute = ::I18n.l(adjusted_value,format: @attribute_format)
|
78
|
+
|
79
|
+
html_tag(:time, class: @class_attribute, datetime: datetime_attribute) do
|
80
|
+
::I18n.l(adjusted_value,format: @format)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def require_exactly_one!(timestamp:,date:)
|
87
|
+
if timestamp.nil? && date.nil?
|
88
|
+
raise ArgumentError,"one of timestamp: or date: are required"
|
89
|
+
elsif !timestamp.nil? && !date.nil?
|
90
|
+
raise ArgumentError,"only one of timestamp: or date: may be given"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Renders the traceparent value for the current trace so that the front-end can add additional spans.
|
2
|
+
class Brut::FrontEnd::Components::Traceparent < Brut::FrontEnd::Component
|
3
|
+
def initialize
|
4
|
+
propagator = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
5
|
+
carrier = {}
|
6
|
+
current_context = OpenTelemetry::Context.current
|
7
|
+
propagator.inject(carrier, context: current_context)
|
8
|
+
@traceparent = carrier["traceparent"]
|
9
|
+
end
|
10
|
+
|
11
|
+
def render
|
12
|
+
attributes = {
|
13
|
+
name: "traceparent"
|
14
|
+
}
|
15
|
+
if @traceparent
|
16
|
+
attributes[:content] = @traceparent
|
17
|
+
else
|
18
|
+
attributes["data-no-traceparent"] = "no traceparent was available - this component may have been rendered outside of an existing OpenTelemetry context"
|
19
|
+
end
|
20
|
+
html_tag(:meta, **attributes)
|
21
|
+
end
|
22
|
+
end
|
@@ -1,7 +1,17 @@
|
|
1
|
+
# Represents a file the browser is going to download. This can be returned from a handler to initiate a download instead of rendering
|
2
|
+
# content.
|
1
3
|
class Brut::FrontEnd::Download
|
2
4
|
|
5
|
+
# @return [Object] the data to be sent in the download
|
3
6
|
attr_reader :data
|
4
7
|
|
8
|
+
# Create a download
|
9
|
+
#
|
10
|
+
# @param [String] filename The name (or base name) of the file name that will be downloaded.
|
11
|
+
# @param [Object] data the data/contents of the file to download
|
12
|
+
# @param [String] content_type the MIME content type to let the browser know what type of file this is.
|
13
|
+
# @param [Time] timestamp if given, will be used with `filename` to set the filename of the file. This is useful if your users will
|
14
|
+
# download the same file mulitple times but you want to make each name different and meaningful.
|
5
15
|
def initialize(filename:,data:,content_type:,timestamp: false)
|
6
16
|
@filename = filename
|
7
17
|
@data = data
|
@@ -9,6 +19,7 @@ class Brut::FrontEnd::Download
|
|
9
19
|
@timestamp = timestamp
|
10
20
|
end
|
11
21
|
|
22
|
+
# Access the necessary HTTP headers to allow this file to be downloaded
|
12
23
|
def headers
|
13
24
|
filename = if @timestamp
|
14
25
|
Time.now.strftime("%Y-%m-%dT%H-%M-%S") + "-" + @filename
|
data/lib/brut/front_end/flash.rb
CHANGED
@@ -1,4 +1,12 @@
|
|
1
|
+
# A hash that can be used to pass short-lived information across requests. Generally, this is useful for storing error and status
|
2
|
+
# messages. Generally, you won't create instances of this class. You may subclass it, to provide your own additional API for your
|
3
|
+
# app's needs. To do that, you must call `Brut.container.override("flash_class",«your class»)`.
|
1
4
|
class Brut::FrontEnd::Flash
|
5
|
+
|
6
|
+
# Create a flash from a hash of values.
|
7
|
+
#
|
8
|
+
# @param [Hash] hash the values that should comprise the hash. Note that this hash is not exactly how the flash stores itself
|
9
|
+
# internally.
|
2
10
|
def self.from_h(hash)
|
3
11
|
hash ||= {}
|
4
12
|
self.new(
|
@@ -6,6 +14,11 @@ class Brut::FrontEnd::Flash
|
|
6
14
|
messages: hash[:messages] || {}
|
7
15
|
)
|
8
16
|
end
|
17
|
+
|
18
|
+
# Create a new flash of a given age with the given messages initialized
|
19
|
+
#
|
20
|
+
# @param [Integer] age the age of this flash. See {#age!}.
|
21
|
+
# @param [Hash] messages the flash messages to use. Note that `:notice` and `:alert` are special. See {#notice=} and {#alert=}.
|
9
22
|
def initialize(age: 0, messages: {})
|
10
23
|
@age = age.to_i
|
11
24
|
if !messages.kind_of?(Hash)
|
@@ -14,23 +27,39 @@ class Brut::FrontEnd::Flash
|
|
14
27
|
@messages = messages
|
15
28
|
end
|
16
29
|
|
30
|
+
# Clear the flash and reset its age to 0.
|
17
31
|
def clear!
|
18
32
|
@age = 0
|
19
33
|
@messages = {}
|
20
34
|
end
|
21
35
|
|
36
|
+
# Set the "notice", which is an informational message. The value is intended to be an I18N key.
|
37
|
+
#
|
38
|
+
# @param [String] notice the I18n key of the notice. You can use any value you like, but you should decide one way or the other,
|
39
|
+
# because it will be confusing to use an I18n key sometimes and sometimes a message.
|
22
40
|
def notice=(notice)
|
23
41
|
self[:notice] = notice
|
24
42
|
end
|
43
|
+
# Access the notice. See {#notice=}
|
25
44
|
def notice = self[:notice]
|
45
|
+
|
46
|
+
# True if there is a notice
|
26
47
|
def notice? = !!self.notice
|
27
48
|
|
49
|
+
# Set the "alert", which is an important error message. The value is intended to be an I18N key.
|
50
|
+
#
|
51
|
+
# @param [String] alert the I18n key of the notice. You can use any value you like, but you should decide one way or the other,
|
52
|
+
# because it will be confusing to use an I18n key sometimes and sometimes a message.
|
28
53
|
def alert=(alert)
|
29
54
|
self[:alert] = alert
|
30
55
|
end
|
56
|
+
# Access the alert. See {#alert=}
|
31
57
|
def alert = self[:alert]
|
58
|
+
# True if there is an alert
|
32
59
|
def alert? = !!self.alert
|
33
60
|
|
61
|
+
# Age this flash. The flash's age is the number of requests in the session it has existed for. This implementation prevents a
|
62
|
+
# flash from being more than 1 request old. This is usually sufficient for a handler to send information across a redirect.
|
34
63
|
def age!
|
35
64
|
@age += 1
|
36
65
|
if @age > 1
|
@@ -39,15 +68,18 @@ class Brut::FrontEnd::Flash
|
|
39
68
|
end
|
40
69
|
end
|
41
70
|
|
71
|
+
# Access an arbitrary flash message
|
42
72
|
def [](key)
|
43
73
|
@messages[key]
|
44
74
|
end
|
45
75
|
|
76
|
+
# Set an arbitrary flash message. This resets the flash's age by one request.
|
46
77
|
def []=(key,message)
|
47
78
|
@messages[key] = message
|
48
79
|
@age = [0,@age-1].max
|
49
80
|
end
|
50
81
|
|
82
|
+
# Conver this flash into a hash, suitable for passing to {.from_h}
|
51
83
|
def to_h
|
52
84
|
{
|
53
85
|
age: @age,
|