From c3bf6a157f812e106ebe9139414583498037b671 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 15:04:01 +0200 Subject: [PATCH] feat(savings_goals/new): inline per-field error state in stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the top-of-form server-error banner (kept only when model.errors on :base, which is rare) and stop relying on input.reportValidity() browser-default tooltips. Stepper validates step 1 manually when Continue is clicked: - name empty → red ring on input + "Please give your goal a name." below - target_amount ≤ 0 → red ring + "Set a target amount greater than zero." - no funding account checked → "Select at least one funding account." Each error clears as soon as the user fixes the field — typing in the name field clears the name error, entering an amount > 0 clears the amount error, checking any account clears the accounts error. Drop the now-unused flashLinkedAccountsRequired() shake; replaced by the per-field error pattern. --- .../savings_goal_stepper_controller.js | 72 +++++++++++++------ .../savings_goals/_form_stepper.html.erb | 15 ++-- config/locales/views/savings_goals/en.yml | 4 ++ 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/javascript/controllers/savings_goal_stepper_controller.js b/app/javascript/controllers/savings_goal_stepper_controller.js index b4faa2990..a1df0dd58 100644 --- a/app/javascript/controllers/savings_goal_stepper_controller.js +++ b/app/javascript/controllers/savings_goal_stepper_controller.js @@ -18,6 +18,9 @@ export default class extends Controller { "nameInput", "amountInput", "avatarPreview", + "nameError", + "amountError", + "accountsError", "linkedAccountCheckbox", "initialContributionAmount", "initialContributionAccountSelect", @@ -30,6 +33,8 @@ export default class extends Controller { "submitButton", ]; + static INVALID_INPUT_CLASSES = ["ring-2", "ring-destructive", "border-destructive"]; + static values = { step1Subtitle: { type: String, default: "Step 1 of 2 · Goal details" }, step2Subtitle: { type: String, default: "Step 2 of 2 · Review & start" }, @@ -65,10 +70,6 @@ export default class extends Controller { next() { if (!this.validateStep1()) return; - if (!this.linkedAccountCheckboxTargets.some((cb) => cb.checked)) { - this.flashLinkedAccountsRequired(); - return; - } this.currentStep = 2; this.step1PanelTarget.classList.add("hidden"); @@ -91,9 +92,15 @@ export default class extends Controller { this.refreshAccountSelect(); this.refreshSubmitState(); this.updateReview(); + if (this.linkedAccountCheckboxTargets.some((cb) => cb.checked) && this.hasAccountsErrorTarget) { + this.accountsErrorTarget.classList.add("hidden"); + } } nameChanged() { + if (this.hasNameInputTarget) { + this.clearFieldError(this.nameInputTarget, this.hasNameErrorTarget ? this.nameErrorTarget : null); + } if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return; const name = this.nameInputTarget.value.trim(); const initial = name ? name.charAt(0).toUpperCase() : "?"; @@ -102,19 +109,49 @@ export default class extends Controller { } validateStep1() { - const requiredInputs = this.step1PanelTarget.querySelectorAll( - 'input[name="savings_goal[name]"], input[name="savings_goal[target_amount]"]' - ); let ok = true; - requiredInputs.forEach((input) => { - if (typeof input.checkValidity === "function" && !input.checkValidity()) { - input.reportValidity(); - ok = false; - } - }); + let firstInvalid = null; + + const nameInput = this.hasNameInputTarget ? this.nameInputTarget : null; + if (nameInput && nameInput.value.trim().length === 0) { + this.showFieldError(nameInput, this.hasNameErrorTarget ? this.nameErrorTarget : null); + firstInvalid ||= nameInput; + ok = false; + } + + const amountInput = this.hasAmountInputTarget ? this.amountInputTarget : null; + const amountValue = amountInput ? parseFloat(amountInput.value) : NaN; + if (amountInput && (!Number.isFinite(amountValue) || amountValue <= 0)) { + this.showFieldError(amountInput, this.hasAmountErrorTarget ? this.amountErrorTarget : null); + firstInvalid ||= amountInput; + ok = false; + } + + if (!this.linkedAccountCheckboxTargets.some((cb) => cb.checked)) { + if (this.hasAccountsErrorTarget) this.accountsErrorTarget.classList.remove("hidden"); + ok = false; + } + + if (firstInvalid) firstInvalid.focus(); return ok; } + showFieldError(input, errorEl) { + if (input) input.classList.add(...this.constructor.INVALID_INPUT_CLASSES); + if (errorEl) errorEl.classList.remove("hidden"); + } + + clearFieldError(input, errorEl) { + if (input) input.classList.remove(...this.constructor.INVALID_INPUT_CLASSES); + if (errorEl) errorEl.classList.add("hidden"); + } + + amountChanged() { + if (this.hasAmountInputTarget) { + this.clearFieldError(this.amountInputTarget, this.hasAmountErrorTarget ? this.amountErrorTarget : null); + } + } + refreshSubmitState() { if (!this.hasFooterRightButtonTarget) return; const anyChecked = this.linkedAccountCheckboxTargets.some((cb) => cb.checked); @@ -233,15 +270,6 @@ export default class extends Controller { } } - flashLinkedAccountsRequired() { - const first = this.linkedAccountCheckboxTargets[0]; - if (first) { - first.focus(); - first.classList.add("ring-2", "ring-destructive"); - setTimeout(() => first.classList.remove("ring-2", "ring-destructive"), 1200); - } - } - #monthsBetween(from, to) { return (to - from) / (1000 * 60 * 60 * 24 * 30.44); } diff --git a/app/views/savings_goals/_form_stepper.html.erb b/app/views/savings_goals/_form_stepper.html.erb index 65f440390..7fff8b75b 100644 --- a/app/views/savings_goals/_form_stepper.html.erb +++ b/app/views/savings_goals/_form_stepper.html.erb @@ -1,7 +1,7 @@ <%# locals: (savings_goal:, linkable_accounts:) %>
- <% if savings_goal.errors.any? %> + <% if savings_goal.errors[:base].any? %> <%= render "shared/form_errors", model: savings_goal %> <% end %> @@ -38,13 +38,17 @@ class: "flex-1", data: { savings_goal_stepper_target: "nameInput", action: "input->savings-goal-stepper#nameChanged" } %>
+
- <%= f.money_field :target_amount, - label: t("savings_goals.form_stepper.step1.fields.target_amount"), - hide_currency: true, - amount_data: { savings_goal_stepper_target: "amountInput" } %> +
+ <%= f.money_field :target_amount, + label: t("savings_goals.form_stepper.step1.fields.target_amount"), + hide_currency: true, + amount_data: { savings_goal_stepper_target: "amountInput", action: "input->savings-goal-stepper#amountChanged" } %> + +
<%= f.date_field :target_date, label: t("savings_goals.form_stepper.step1.fields.target_date") %>
@@ -80,6 +84,7 @@ <% end %> +
diff --git a/config/locales/views/savings_goals/en.yml b/config/locales/views/savings_goals/en.yml index 13eacd129..eb5f68ea3 100644 --- a/config/locales/views/savings_goals/en.yml +++ b/config/locales/views/savings_goals/en.yml @@ -185,6 +185,10 @@ en: continue: Continue back: Back submit: Create goal + errors: + name_required: Please give your goal a name. + amount_required: Set a target amount greater than zero. + accounts_required: Select at least one funding account. step1: label: Goal details heading: What are you saving for?