mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user