brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -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 <form> HTML component
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
- def initialize(**attributes,&contents)
5
- form_class = attributes.delete(:for)
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 attributes[:action]
8
- raise ArgumentError, "You cannot specify both for: (#{form_class}) and and action: (#{attributes[:action]}) to a form_tag"
8
+ if form_class.kind_of?(Brut::FrontEnd::Form)
9
+ form_class = form_class.class
9
10
  end
10
- if attributes[:method]
11
- raise ArgumentError, "You cannot specify both for: (#{form_class}) and and method: (#{attributes[:method]}) to a form_tag"
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(attributes[:method])
28
+ http_method = Brut::FrontEnd::HttpMethod.new(html_attributes[:method])
22
29
 
23
- if http_method.get?
24
- if attributes.key?(:no_csrf_token)
25
- raise ArgumentError,":no_csrf_token is not allowed for form_tag when the HTTP method is a GET"
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
- input = form[input_name]
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: input.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
- def self.for_form_input(form:, input_name:, html_attributes: {})
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
- input = form[input_name]
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.name
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"] = input.value == "true"
44
+ default_html_attributes["checked"] = value == "true"
27
45
  else
28
- default_html_attributes["value"] = input.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
- def self.for_form_input(form:, input_name:, html_attributes: {})
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
- input = form[input_name]
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.name
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
- Brut::FrontEnd::Components::Inputs::Textarea.new(default_html_attributes.merge(html_attributes), input.value)
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
@@ -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,