Class: RuboCop::Cop::DevDoc::View::PreferPropOverName

Inherits:
Base
  • Object
show all
Extended by:
AutoCorrector
Defined in:
lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb

Overview

Bind form fields to model attributes with prop:, not a raw name: 'model[attr]'.

Rationale

The form-field builder's prop: option binds a field to the form's model attribute and resolves its label, placeholder, and validation hints from i18n. Writing name: 'model[attr]' (the raw HTML-form instinct) bypasses all of that, so the label/placeholder get hardcoded next to it in English — the exact text this ecosystem keeps in locale files.

❌ raw name + hardcoded text
form.fields_text name: 'user[email]', label: 'Email', placeholder: 'Email'

✔️ bound to the model; text resolved from i18n
form.fields_text prop: :email

Matching is receiver-agnostic (form., f., any block-arg name): keying on specific receiver names would make renaming the block argument a silent dodge. Any fields_* call with a single-level-bracket string name: is flagged.

Exemptions

  • fields_hidden is exempt by default (ExemptFieldMethods): hidden fields have no label/placeholder, and they are the dominant legitimate name: users (echoed tokens, dynamic-group indexes, forced values).
  • fields_creditCard is exempt by default: it captures transient card credentials (tokenised client-side, stripped from params server-side) that must never bind to a persisted model.
  • fields_dynamicGroup is exempt by default: its name: is the structural array-param key for the group's rows, not a labelled model attribute — the row template's inner fields are where prop: belongs.

How to fix — the decision ladder

Work down this ladder before even considering a disable. A full convert-or-justify sweep of a mature codebase (27 offenses) ended with ZERO disables — every "deliberately raw" case fell to one of these:

  1. Form bound + real attribute → plain prop:.
  2. Form unbound (pre-auth, filters) → bind a model. A blank record works for auth flows (model: User.new); for params-driven filters, a small presentation-only ActiveModel form object (the filtering can keep running off permitted params — the object exists only so fields derive names, labels and sticky values).
  3. Param namespace differs from the bound model on purpose (server composes another record from other_thing[...] params) → bind a form object whose namespace IS the contract; override model_name if the class name doesn't match, and pass an explicit url: (POROs have no route for polymorphic derivation).
  4. Virtual param (posted key isn't a column; controller applies it manually) → add a READER-ONLY virtual attribute to the model that derives the prefill (e.g. a visibility reader derived from an enum, a notification_note reader defaulting to the saved note). No writer → no mass-assignment surface; the controller's manual handling is unchanged.
  5. Label-less by design → keep prop: and pass label: ''. The empty string is truthy, so it blocks the i18n label derivation — "it must not have a label" is NOT a reason for raw name:.

Legitimately raw (don't convert)

  • Deliberately TOP-LEVEL params the controller reads un-namespaced: mode switches (params[:mode]), auth params (otp_attempt), signed link tokens, bare GET search terms (q), client-routing pass-throughs (target_form_id). prop: would namespace them under param_key[...] and break the read.
  • Client-side-only controls that never post: select-all master toggles, show/hide drivers referenced via { "var": ... }.
  • Bulk selection arrays (foo[ids][] with per-row checkValue:).
  • Names fixed by an external protocol (g-recaptcha-response).

Trade-off to be aware of

prop: removes the literal wire name from the source: you can no longer grep user[email] and land in the view — the name is assembled at render time. In exchange names become predictable (always param_key[attr]) and attribute renames fail loudly at render (field_assert_respond_to) instead of silently posting a dead param.

Autocorrect (unsafe)

name: 'model[attr]' becomes prop: :attr. Two caveats make this unsafe:

  • The model segment is dropped: prop: binds to the FORM's model, so if the raw name deliberately posted under a different key, the rewrite silently re-parents the param. No static check can compare the two — review the diff.
  • Sibling label:/placeholder: are NOT auto-deleted; verify the i18n keys exist, then remove them manually.

NOTE: a bare name: 'something' (no brackets) that should have been prop: is statically indistinguishable from a legitimately unbound field, so it is not flagged — a render-time assertion could close that gap later.

Examples:

# bad
form.fields_text name: 'user[email]', label: 'Email'

# good
form.fields_text prop: :email

# good — hidden fields are exempt by default
form.fields_hidden name: 'user[reset_password_token]', value: token

Constant Summary collapse

MSG =
'Bind the model attribute with `prop: :%<attr>s` instead of `name: %<name>s` — ' \
'`prop:` derives the field name and resolves label/placeholder from i18n. ' \
'No bound model is not a blocker: bind a blank record or an ActiveModel form ' \
'object (override `model_name` when the param namespace is the contract). A ' \
'virtual param is not a blocker: add a reader-only attribute deriving the ' \
'prefill. Label-less by design is not a blocker: keep `prop:` and pass ' \
"`label: ''`. Raw `name:` is right only for deliberately top-level params the " \
'controller reads un-namespaced (mode switches, auth params, search terms) ' \
'and client-side-only controls.'.freeze
MODEL_ATTR_NAME =
/\A\w+\[(\w+)\]\z/

Instance Method Summary collapse

Instance Method Details

#on_send(node) ⇒ Object



127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb', line 127

def on_send(node)
  return unless node.method_name.to_s.start_with?('fields_')
  return if exempt_field_methods.include?(node.method_name.to_s)

  pair = bracket_name_pair(node)
  return unless pair

  attr = pair.value.value[MODEL_ATTR_NAME, 1]
  add_offense(pair, message: format(MSG, attr: attr, name: pair.value.source)) do |corrector|
    corrector.replace(pair, "prop: :#{attr}")
  end
end