mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* fix(goals): let a months-of-expenses reserve be created at all The mode could not be used from the UI. The form makes the amount field read-only in months mode — correctly, since the figure is derived — so the form submits it empty. `target_amount` is required and positive, and validations run before every save callback, so the derivation that fills it never got the chance. Creation came back 422 with "can't be blank" on a field the user is not allowed to type in. Reproduced through the controller before changing anything: response 422, no goal created. Every existing test set `target_amount` explicitly, which is why the model looked healthy — the gap was entirely on the path a user actually takes. The derivation moves to `before_validation`, where a derived value belongs: it is computed, then validated like any other. The dirty-state predicates change with it, since `will_save_change_to_*` describes a save that has not been decided on yet at that point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): move the new tests out of the private section Review flagged them as never running. They do — Rails' `test` macro goes through `define_method` from a class method, which defines a public method whatever the surrounding visibility, and `-n` confirms Minitest picks both up. Verified before touching anything: 2 runs, 7 assertions. Moved anyway. My insertion targeted the file's last `private` rather than its first, so they landed among the helper methods, where they read as a mistake whether or not they behave like one — three separate reviewers have now stopped on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * test(goals): put the page tests where nobody has to check they run Review on #3229. The four page tests sat between `private` and a later `public`, and every reader so far has stopped to work out whether they run. They do — Rails' `test` macro calls `define_method` from a class method, and a method defined that way is public whatever the surrounding visibility — but a test whose behaviour has to be reasoned about is a test nobody trusts. They move above the first `private`, where the question does not come up. The second `private` goes with them: everything between it and the first was already private, so it did nothing. The fixed-amount test also gained the assertion it was missing. It named the guard it was protecting and then checked only the status, so a 422 arriving for any other reason would have kept it green. It now asserts the error the form actually puts in front of the user; flipping that paragraph's condition makes it fail, which is the point. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * feat(goals): settle on target balance, the term this kind of app uses A reserve holds a balance rather than reaching an amount, and the page had three words for that one idea: "floor" twice, "level" seven times, and a field labelled "Target amount". The mode selector said "How the floor is set" three lines above a field called "Target amount". They are all replaced by **target balance** / **solde cible**. That is the term this kind of application uses, and it keeps the noun the rest of the page already leans on — "target" appears 55 times here. My first pass invented "Level to hold", which was internally tidy and standard nowhere: it fought 55 uses of a word that was not actually wrong. A target need not be a finish line; a target balance is one you hold. In months mode the balance also stops pretending to be a field. It is worked out from spending, so it is shown as a result with a line saying where it comes from — "Worked out when you save" on a goal that has none yet, and the balance it currently holds when editing one. That removes the `readOnly` toggle, which existed only to stop people typing into something that should not have been an input. Three smaller corrections while in the file: - `exceeds_earmark` had the actor backwards in both languages. The account does not earmark; the goal earmarks on the account. - The French `not_active` was a comma splice, where the file already uses a colon for that construction. - "se recomplète" is not standard French; a reserve "se reconstitue", and "ce qui lui manque" reads better than "son manque". A test asserts neither locale still says floor or niveau, so the three vocabularies cannot quietly come back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a hidden field blocking the reserve it belongs to Replacing the read-only amount input with a hidden one left it `required`, and a required input is still validated by the browser while `display: none`. Submitting a months-based reserve was refused over a field the user could not see, and could not have filled in either — the reserve became impossible to create, which is the very thing the previous change set out to fix. Disabled rather than hidden, so it is barred from validation and its value stays out of the params, letting the derived figure land. The field also has to come back when there is nothing to derive from: with no spending history the model keeps whatever was typed, so the typed amount is then the only way to set a target at all. The form now asks the family that question up front and keeps the field where the answer is no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop the label swap deleting the required marker Review on #3230. `_money_field` puts the required-field asterisk inside the label, in a span of its own. Swapping the wording with `textContent = label` replaces every child of that label, so the asterisk went with it — and `refresh()` runs on connect, so this fired on every goal form, one-off and fixed reserve included, not only the derived-months case this PR is about. Nothing put it back for the life of the page. Only the wording changes now: the label's text node is rewritten and the span left alone. A system test covers it, because nothing short of a browser can. It fails on the old code at the first assertion, before anything is clicked, which is where the bug actually landed. Also from review: the months derivation asked for the median twice, building an IncomeStatement each time. It reads it once now — the method stays un-memoized, which is what the refresh job needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
87 lines
3.9 KiB
JavaScript
87 lines
3.9 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
|
|
// A maintained reserve has no deadline: it is a target balance to hold, not
|
|
// something due on a date. Hiding the target-date field keeps a stale value from being
|
|
// submitted and driving a pace the goal does not have.
|
|
export default class extends Controller {
|
|
static targets = ["radio", "dateField", "modeField", "modeSelect", "monthsField", "amountField", "amountDerived"]
|
|
|
|
static values = {
|
|
amountLabel: String,
|
|
balanceLabel: String,
|
|
derivable: Boolean,
|
|
}
|
|
|
|
connect() {
|
|
this.refresh()
|
|
}
|
|
|
|
refresh() {
|
|
const maintained = this.radioTargets.some((radio) => radio.checked && radio.value === "maintained")
|
|
|
|
this.dateFieldTargets.forEach((field) => field.classList.toggle("hidden", maintained))
|
|
if (maintained) this.#clearInputs(this.dateFieldTargets)
|
|
|
|
// The target mode is a reserve's business only; a one-off is always a
|
|
// fixed amount. Reset it on the way out so a one-off cannot be saved
|
|
// carrying a months mode nothing would ever refresh.
|
|
this.modeFieldTargets.forEach((field) => field.classList.toggle("hidden", !maintained))
|
|
if (!maintained && this.hasModeSelectTarget) this.modeSelectTarget.value = "fixed"
|
|
|
|
const months = maintained && this.hasModeSelectTarget && this.modeSelectTarget.value === "months_of_expenses"
|
|
this.monthsFieldTargets.forEach((field) => field.classList.toggle("hidden", !months))
|
|
if (!months) this.#clearInputs(this.monthsFieldTargets)
|
|
|
|
// Three states. A one-off saves toward a target amount; a fixed reserve
|
|
// holds a target balance you type; a months-based reserve holds one worked
|
|
// out from spending, so the input gives way to the figure itself — a
|
|
// greyed-out input still reads as something to fill in, beside the months
|
|
// that are the actual question.
|
|
//
|
|
// Only where there IS spending to work one out from, though. With none the
|
|
// model keeps whatever was typed, so the field has to stay: it is then the
|
|
// only way to give the reserve a target at all.
|
|
const derived = months && this.derivableValue
|
|
this.amountFieldTargets.forEach((field) => field.classList.toggle("hidden", derived))
|
|
this.amountDerivedTargets.forEach((field) => field.classList.toggle("hidden", !derived))
|
|
|
|
// Disabled, not merely hidden. A `required` input is still validated by
|
|
// the browser while `display: none`, which then blocks a submit it cannot
|
|
// show the error on — the reserve became impossible to create. Disabling
|
|
// also keeps the typed figure out of the params, so what lands is the
|
|
// figure the model derived.
|
|
this.amountFieldTargets.forEach((field) => {
|
|
field.querySelectorAll("input").forEach((input) => { input.disabled = derived })
|
|
})
|
|
|
|
// Only the wording changes. Assigning `textContent` would replace every
|
|
// child of the label, and the label carries the required-field asterisk in
|
|
// a span of its own — wiped on the first `refresh()`, which runs on connect
|
|
// for every goal form, with nothing to put it back.
|
|
const label = maintained ? this.balanceLabelValue : this.amountLabelValue
|
|
this.amountFieldTargets.forEach((field) => {
|
|
const el = field.querySelector(".form-field__label")
|
|
if (!el || !label) return
|
|
|
|
const wording = [...el.childNodes].find(
|
|
(node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(),
|
|
)
|
|
if (wording) wording.textContent = label
|
|
else el.prepend(document.createTextNode(label))
|
|
})
|
|
}
|
|
|
|
#clearInputs(fields) {
|
|
fields.forEach((field) => {
|
|
const input = field.querySelector("input")
|
|
if (!input) return
|
|
|
|
input.value = ""
|
|
// Assigning `value` fires nothing, so the pace suggestion bound to this
|
|
// input's action kept showing a monthly figure derived from a deadline
|
|
// the goal no longer has.
|
|
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
})
|
|
}
|
|
}
|