mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* chore(deps): upgrade Rails 7.2 → 8.1 Rails 7.2 reaches end of life on 2026-08-09. Bump the framework to the current 8.1.x line. - Gemfile: rails "~> 8.0" (resolves 8.1.3); bundle update rails pulls the Rails 8 framework gems plus the bumps it requires — ViewComponent 3.23 → 4.x (Rails 8 support), rails-i18n 7 → 8, rswag, and transitive deps. - app/models/transfer.rb: make Transfer#date nil-safe (inflow_transaction&.entry&.date). Rails 8's date_field evaluates the field default on a new/unpersisted Transfer (the new-transfer form), where the association is nil; without this, TransfersController#new raises "undefined method 'entry' for nil". Matches the &. pattern already used in Transfer#sync_account_later. Framework behavioral defaults are unchanged (config.load_defaults stays as-is). Validated on Rails 8.1.3: zeitwerk:check passes, full suite green (4904 runs, 0 failures, 0 errors), rubocop and brakeman clean. * fix(rails8): style textarea + deterministic property edit system test The Rails 8 gem bump kept config.load_defaults at 7.2, but Rails 8 renamed two ActionView::Helpers::FormBuilder field helpers regardless of defaults: :text_area → :textarea and :check_box → :checkbox. StyledFormBuilder builds its styled helpers from `field_helpers`, so `form.text_area` (e.g. the account "Notes" field) silently fell through to the unstyled base helper and rendered without a label — failing 8 system tests with `Unable to find field "Notes"`. - app/helpers/styled_form_builder.rb: exclude both spellings of the non-text helpers (:check_box and :checkbox) and alias the legacy `text_area` to the Rails 8 `textarea` so existing call sites stay styled. Harmless on Rails 7.2 (old names present instead). - test/system/property_test.rb: open the property edit dialog via the account menu with a retry. The account page issues a Turbo morph refresh shortly after load (turbo_refreshes_with :morph + a family-stream broadcast); opening the modal while that refresh is in flight let the morph re-render the page and wipe the just-loaded #modal turbo-frame. Rails 8 timing made the race deterministic. Retrying once the refresh has settled makes the test stable (confirmed via Turbo frame-load vs full-page morph event traces; 3x green in isolation). - config/brakeman.ignore: the added comment block shifted the pre-existing (already-ignored, Weak) class_eval Dangerous Eval warning from line 5 -> 10, changing its fingerprint. Re-point the existing suppression to the new fingerprint/line so scan_ruby stays green. Validated on Rails 8.1.3: full system suite green (92 runs, 355 assertions, 0 failures, 0 errors), rubocop clean, brakeman 0 warnings, CodeRabbit no findings. * chore(deps): pin rails to the 8.1 minor line (~> 8.1.0) Tighten the constraint from `~> 8.0` to `~> 8.1.0` (>= 8.1.0, < 8.2) so a future `bundle update rails` tracks the 8.1.x line rather than silently jumping to 8.2 when it ships. Matches the upgrade plan's stated intent (target 8.1.x for the EOL runway) and a review note on #2301. No resolved-version changes: bundle install keeps rails at 8.1.3 and every other locked gem unchanged — only the Gemfile.lock DEPENDENCIES constraint line moves. zeitwerk:check still passes; the already-green unit/system suites ran on this exact resolved tree. * chore(rails8): adopt Rails 8.1 framework defaults (config.load_defaults 8.1) The gem bump above kept config.load_defaults at 7.2 so the change set could be reasoned about in stages; this finalizes the upgrade by adopting the modern framework defaults now that the suite is green on Rails 8.1. Rails 8.0 added no new framework defaults (there is no new_framework_defaults_8_0 template), so 7.2 -> 8.1 is the single meaningful step. No incremental new_framework_defaults_8_1.rb opt-in file is needed: the full suites pass with all 8.1 defaults enabled at once. The 8.1 defaults this turns on include action_on_path_relative_redirect=:raise (open-redirect hardening), raise_on_missing_required_finder_order_columns, escape_json_responses=false / escape_js_separators_in_json=false (JSON perf), and Ruby-parser template-dependency tracking. Validated with no application code changes: bin/rails test 4904/0/0, bin/rails test:system 92/0/0, rubocop + brakeman clean. * chore(ci): restore brakeman CheckEOLRails now that the app is on Rails 8.1 config/brakeman.yml existed only to skip brakeman's CheckEOLRails. That check fires on the calendar (it warns 60 days before a framework's EOL and escalates as the date nears), so Rails 7.2's 2026-08-09 EOL turned `bin/brakeman` red (exit 3) on every branch and on main regardless of the diff. The skip carried a TODO to remove it once Sure upgraded off 7.2. This PR puts the app on Rails 8.1 (EOL well in the future), so the skip is obsolete; remove the file (its sole content was the skip) in the same change that makes it unnecessary -- no stale-config window. brakeman auto-loads the file when present and falls back to defaults when absent, and nothing references it explicitly. CheckEOLRuby was already enabled and is unchanged; config/brakeman.ignore is untouched. Validated on Rails 8.1: bin/brakeman runs EOLRails + EOLRuby, 0 warnings, 0 errors, exit 0.
168 lines
5.8 KiB
Ruby
168 lines
5.8 KiB
Ruby
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
|
# Rails 8 renamed two field_helpers entries: :text_area -> :textarea and
|
|
# :check_box -> :checkbox. Exclude both spellings of the non-text helpers, and
|
|
# alias the legacy method names below so existing `form.text_area` call sites
|
|
# stay styled. (Harmless on Rails 7.2, where the old names are present instead.)
|
|
NON_TEXT_FIELD_HELPERS = [ :label, :check_box, :checkbox, :radio_button, :fields_for, :fields, :hidden_field, :file_field ].freeze
|
|
class_attribute :text_field_helpers, default: field_helpers - NON_TEXT_FIELD_HELPERS
|
|
|
|
text_field_helpers.each do |selector|
|
|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
|
def #{selector}(method, options = {})
|
|
form_options = options.slice(:label, :label_tooltip, :inline, :container_class, :required)
|
|
html_options = options.except(:label, :label_tooltip, :inline, :container_class)
|
|
|
|
build_field(method, form_options, html_options) do |merged_options|
|
|
super(method, merged_options)
|
|
end
|
|
end
|
|
RUBY_EVAL
|
|
end
|
|
|
|
# Keep `form.text_area` styled after Rails 8 renamed the helper to `textarea`.
|
|
alias_method :text_area, :textarea if method_defined?(:textarea)
|
|
|
|
def radio_button(method, tag_value, options = {})
|
|
merged_options = { class: "form-field__radio" }.merge(options)
|
|
super(method, tag_value, merged_options)
|
|
end
|
|
|
|
def select(method, choices, options = {}, html_options = {})
|
|
field_options = normalize_options(options, html_options)
|
|
|
|
build_field(method, field_options, html_options) do |merged_html_options|
|
|
super(method, choices, options, merged_html_options)
|
|
end
|
|
end
|
|
|
|
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
|
selected_value =
|
|
if options.key?(:selected)
|
|
options[:selected]
|
|
elsif @object.respond_to?(method)
|
|
@object.public_send(method)
|
|
end
|
|
placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label")
|
|
|
|
@template.render(
|
|
DS::Select.new(
|
|
form: self,
|
|
method: method,
|
|
items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } },
|
|
selected: selected_value,
|
|
placeholder: placeholder,
|
|
searchable: options.fetch(:searchable, false),
|
|
menu_placement: options[:menu_placement],
|
|
variant: options.fetch(:variant, :simple),
|
|
include_blank: options[:include_blank],
|
|
label: options[:label],
|
|
container_class: options[:container_class],
|
|
label_tooltip: options[:label_tooltip],
|
|
html_options: html_options
|
|
)
|
|
)
|
|
end
|
|
|
|
def money_field(amount_method, options = {})
|
|
@template.render partial: "shared/money_field", locals: {
|
|
form: self,
|
|
amount_method:,
|
|
currency_method: options[:currency_method] || :currency,
|
|
**options
|
|
}
|
|
end
|
|
|
|
def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
|
|
field_id = field_id(method)
|
|
field_name = field_name(method)
|
|
checked = object ? object.send(method) : options[:checked]
|
|
|
|
@template.render(
|
|
DS::Toggle.new(
|
|
id: field_id,
|
|
name: field_name,
|
|
checked: checked,
|
|
disabled: options[:disabled],
|
|
checked_value: checked_value,
|
|
unchecked_value: unchecked_value,
|
|
**options
|
|
)
|
|
)
|
|
end
|
|
|
|
def submit(value = nil, options = {})
|
|
value, options = nil, value if value.is_a?(Hash)
|
|
value ||= submit_default_value
|
|
|
|
@template.render(
|
|
DS::Button.new(
|
|
text: value,
|
|
type: "submit",
|
|
data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
|
|
full_width: true
|
|
)
|
|
)
|
|
end
|
|
|
|
private
|
|
def build_field(method, options = {}, html_options = {}, &block)
|
|
if options[:inline] || options[:label] == false
|
|
return yield({ class: "form-field__input" }.merge(html_options))
|
|
end
|
|
|
|
label_element = build_label(method, options)
|
|
field_element = yield({ class: "form-field__input" }.merge(html_options))
|
|
|
|
container_classes = [ "form-field", options[:container_class] ].compact
|
|
|
|
@template.tag.div class: container_classes do
|
|
if options[:label_tooltip]
|
|
@template.tag.div(class: "form-field__header") do
|
|
label_element +
|
|
@template.tag.div(class: "form-field__actions") do
|
|
build_tooltip(options[:label_tooltip])
|
|
end
|
|
end +
|
|
@template.tag.div(class: "form-field__body") do
|
|
field_element
|
|
end
|
|
else
|
|
@template.tag.div(class: "form-field__body") do
|
|
label_element + field_element
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def normalize_options(options, html_options)
|
|
options.merge(required: options[:required] || html_options[:required])
|
|
end
|
|
|
|
def build_label(method, options)
|
|
return "".html_safe unless options[:label]
|
|
|
|
label_text = options[:label]
|
|
|
|
if options[:required]
|
|
label_text = @template.safe_join([
|
|
label_text == true ? method.to_s.humanize : label_text,
|
|
@template.tag.span("*", class: "text-red-500 ml-0.5")
|
|
])
|
|
end
|
|
|
|
return label(method, class: "form-field__label") if label_text == true
|
|
label(method, label_text, class: "form-field__label")
|
|
end
|
|
|
|
def build_tooltip(tooltip_text)
|
|
return nil unless tooltip_text
|
|
|
|
@template.tag.div(data: { controller: "tooltip" }) do
|
|
@template.safe_join([
|
|
@template.icon("help-circle", size: "sm", color: "default", class: "cursor-help"),
|
|
@template.tag.div(tooltip_text, role: "tooltip", data: { tooltip_target: "tooltip" }, class: "tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white")
|
|
])
|
|
end
|
|
end
|
|
end
|