Class: RuboCop::Cop::DevDoc::View::PreferPropOverName
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::DevDoc::View::PreferPropOverName
- 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_hiddenis exempt by default (ExemptFieldMethods): hidden fields have no label/placeholder, and they are the dominant legitimatename:users (echoed tokens, dynamic-group indexes, forced values).fields_creditCardis 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_dynamicGroupis exempt by default: itsname:is the structural array-param key for the group's rows, not a labelled model attribute — the row template's inner fields are whereprop: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:
- Form bound + real attribute → plain
prop:. - 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). - 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; overridemodel_nameif the class name doesn't match, and pass an expliciturl:(POROs have no route for polymorphic derivation). - 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
visibilityreader derived from an enum, anotification_notereader defaulting to the saved note). No writer → no mass-assignment surface; the controller's manual handling is unchanged. - Label-less by design → keep
prop:and passlabel: ''. The empty string is truthy, so it blocks the i18n label derivation — "it must not have a label" is NOT a reason for rawname:.
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 underparam_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-rowcheckValue:). - 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
modelsegment 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.
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 |