feat(savings_goals/new): inline per-field error state in stepper

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.
This commit is contained in:
Guillem Arias
2026-05-11 15:04:01 +02:00
parent bcae1afc24
commit c3bf6a157f
3 changed files with 64 additions and 27 deletions

View File

@@ -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);
}

View File

@@ -1,7 +1,7 @@
<%# locals: (savings_goal:, linkable_accounts:) %>
<div data-controller="savings-goal-stepper">
<% 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" } %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="nameError"><%= t("savings_goals.form_stepper.errors.name_required") %></p>
</div>
<div class="grid grid-cols-2 gap-3">
<%= 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" } %>
<div>
<%= 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" } %>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="amountError"><%= t("savings_goals.form_stepper.errors.amount_required") %></p>
</div>
<%= f.date_field :target_date,
label: t("savings_goals.form_stepper.step1.fields.target_date") %>
</div>
@@ -80,6 +84,7 @@
</div>
<% end %>
</div>
<p class="hidden mt-1.5 text-xs text-destructive" data-savings-goal-stepper-target="accountsError"><%= t("savings_goals.form_stepper.errors.accounts_required") %></p>
</div>
<details class="border border-subdued rounded-lg group">

View File

@@ -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?