Files
sure/app/javascript/controllers/savings_goal_stepper_controller.js
Guillem Arias 8a9f4b1a67 fix(savings): DS conformance pass on stepper, ring, card, status pill
- StatusPill: use functional `text-success` / `text-warning` tokens with
  matching icon colors and `px-2 py-1`, mirroring
  `app/views/budget_categories/_budget_category.html.erb:29-43`.
- ProgressRing: rework center text to match `_budget_donut.html.erb`
  (small "Saved" label, `text-3xl font-medium` headline, "of $X"
  underline). Stroke color now derives from goal.status (yellow when
  behind, blue on track, green reached, gray for no-date).
- GoalCard bar: track height + transition match budget category bar
  (`h-1.5`, `transition-all duration-500`, `inline-size`).
- Index/show layouts: render page header inline (`<h1>` + actions). The
  default application layout doesn't yield `:page_actions`, so the
  CTA + kebab menu wouldn't appear when emitted via `content_for`.
- Stepper review summary: target the actual form inputs by `name`
  rather than relying on the `data-target` Stimulus attribute, since
  `money_field` puts the attribute on the wrapper. Step 1 validation
  scoped to the step 1 panel.
- Demo generator: filter Depository accounts via
  `where(accountable_type: "Depository")` — Rails delegated_type
  generates the `depository?` predicate, not a `.depository` scope.
2026-05-11 11:39:13 +02:00

130 lines
4.0 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
// 2-step modal stepper for creating a savings goal.
//
// Single <form> with two panels. Step 1 collects identity (name, amount,
// date, color, notes). Step 2 collects ≥1 linked depository accounts and
// optionally an initial contribution. Submit button stays disabled until at
// least one linked account is selected. Step state lives entirely in the
// DOM — no half-records.
export default class extends Controller {
static targets = [
"step1Panel",
"step2Panel",
"step1Indicator",
"step2Indicator",
"step1Field",
"nameField",
"targetAmountField",
"linkedAccountCheckbox",
"initialContributionAmount",
"initialContributionAccountSelect",
"reviewPanel",
"reviewName",
"reviewAmount",
"reviewDate",
"reviewAccounts",
"submitButton",
];
next(event) {
event?.preventDefault?.();
if (!this.validateStep1()) return;
this.step1PanelTarget.classList.add("hidden");
this.step2PanelTarget.classList.remove("hidden");
this.markStepActive(2);
this.updateReview();
this.refreshSubmitState();
}
back(event) {
event?.preventDefault?.();
this.step2PanelTarget.classList.add("hidden");
this.step1PanelTarget.classList.remove("hidden");
this.markStepActive(1);
}
linkedAccountChanged() {
this.refreshAccountSelect();
this.refreshSubmitState();
this.updateReview();
}
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;
}
});
return ok;
}
refreshSubmitState() {
const anyChecked = this.linkedAccountCheckboxTargets.some((cb) => cb.checked);
this.submitButtonTarget.disabled = !anyChecked;
}
refreshAccountSelect() {
if (!this.hasInitialContributionAccountSelectTarget) return;
const select = this.initialContributionAccountSelectTarget;
const previous = select.value;
select.innerHTML = "";
const blank = document.createElement("option");
blank.value = "";
blank.textContent = select.dataset.blankLabel || "—";
select.appendChild(blank);
this.linkedAccountCheckboxTargets
.filter((cb) => cb.checked)
.forEach((cb) => {
const opt = document.createElement("option");
opt.value = cb.value;
opt.textContent = cb.dataset.accountName || cb.value;
select.appendChild(opt);
});
if ([...select.options].some((o) => o.value === previous)) {
select.value = previous;
}
}
updateReview() {
if (!this.hasReviewPanelTarget) return;
if (this.hasReviewNameTarget) {
const nameInput = this.element.querySelector('input[name="savings_goal[name]"]');
this.reviewNameTarget.textContent = nameInput?.value || "—";
}
if (this.hasReviewAmountTarget) {
const amountInput = this.element.querySelector('input[name="savings_goal[target_amount]"]');
this.reviewAmountTarget.textContent = amountInput?.value || "—";
}
if (this.hasReviewDateTarget) {
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
this.reviewDateTarget.textContent = dateInput?.value || "—";
}
if (this.hasReviewAccountsTarget) {
const names = this.linkedAccountCheckboxTargets
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.accountName || cb.value);
this.reviewAccountsTarget.textContent = names.length ? names.join(", ") : "—";
}
}
markStepActive(stepNumber) {
if (this.hasStep1IndicatorTarget) {
this.step1IndicatorTarget.classList.toggle("text-primary", stepNumber === 1);
}
if (this.hasStep2IndicatorTarget) {
this.step2IndicatorTarget.classList.toggle("text-primary", stepNumber === 2);
}
}
}