fix(mood): confirm form reset when dirty and show a note counter

Reset discarded typed input with no confirmation; a mis-tap silently
wiped a half-written entry. Reset now confirms first when the form holds
content (mood, tags or note) and resets immediately when pristine. The
note field also gained a live character counter so its 500-character cap
no longer truncates without feedback.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:37:23 +02:00
1 parent ccf56b8dbb
commit 7986e59147
8 files changed
+213 -7

No files matched your search

+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Alle Stimmungen anzeigen",
"actions": "Aktionen",
"formReset": "Formular zurücksetzen",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "Formular zurücksetzen?",
"formResetConfirmBody": "Damit werden die eingegebene Stimmung, Tags und Notiz gelöscht. Das lässt sich nicht rückgängig machen.",
"formResetConfirmAction": "Zurücksetzen",
"saveError": "Fehler beim Speichern",
"deleteError": "Fehler beim Löschen",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Show all moods",
"actions": "Actions",
"formReset": "Reset form",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "Reset this form?",
"formResetConfirmBody": "This clears the mood, tags and note you have entered. This cannot be undone.",
"formResetConfirmAction": "Reset",
"saveError": "Error saving",
"deleteError": "Error deleting",
"invalidTimestamp": "Please select a valid time.",
+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Mostrar todos los estados",
"actions": "Acciones",
"formReset": "Restablecer formulario",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "¿Restablecer este formulario?",
"formResetConfirmBody": "Esto borra el estado de ánimo, las etiquetas y la nota que has introducido. No se puede deshacer.",
"formResetConfirmAction": "Restablecer",
"saveError": "Error al guardar",
"deleteError": "Error al eliminar",
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Afficher toutes les humeurs",
"actions": "Actions",
"formReset": "Réinitialiser le formulaire",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "Réinitialiser ce formulaire ?",
"formResetConfirmBody": "Cela efface l’humeur, les tags et la note que vous avez saisis. Cette action est irréversible.",
"formResetConfirmAction": "Réinitialiser",
"saveError": "Erreur lors de l’enregistrement",
"deleteError": "Erreur lors de la suppression",
"invalidTimestamp": "Choisis une date et une heure valides.",
+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Mostra tutti gli umori",
"actions": "Azioni",
"formReset": "Reimposta modulo",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "Reimpostare questo modulo?",
"formResetConfirmBody": "Questo cancella umore, tag e nota inseriti. L’operazione non può essere annullata.",
"formResetConfirmAction": "Reimposta",
"saveError": "Errore durante il salvataggio",
"deleteError": "Errore durante l’eliminazione",
"invalidTimestamp": "Scegli una data e un’ora valide.",
+4
View File
@@ -489,6 +489,10 @@
"emptyResetFilter": "Pokaż wszystkie nastroje",
"actions": "Akcje",
"formReset": "Resetuj formularz",
"noteCharCount": "{count}/{max}",
"formResetConfirmTitle": "Zresetować ten formularz?",
"formResetConfirmBody": "Spowoduje to usunięcie wprowadzonego nastroju, tagów i notatki. Tej operacji nie można cofnąć.",
"formResetConfirmAction": "Resetuj",
"saveError": "Błąd zapisu",
"deleteError": "Błąd usuwania",
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
@@ -0,0 +1,104 @@
/**
* v1.11.5 — QoL-Lows coverage for the mood form + list fixes.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`,
* no `@testing-library/react`) plus source-string structural assertions
* for the interactive plumbing that an SSR mount can't exercise. The
* three fixes pinned here:
*
* 1. `<MoodList>` — the delete mutation surfaces a toast on failure
* (it previously had no `onError`, so a rejected DELETE was silent).
* 2. `<MoodForm>` — Reset confirms first when the form holds typed
* input, and resets immediately when pristine.
* 3. `<MoodForm>` — the note field shows a live character counter so
* the `maxLength` cap no longer truncates without feedback.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { MoodForm } from "../mood-form";
function renderForm(footerSlot: HTMLElement | null = null): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<MoodForm footerSlot={footerSlot} />
</I18nProvider>
</QueryClientProvider>,
);
}
const formSrc = readFileSync(
resolve(__dirname, "../mood-form.tsx"),
"utf8",
);
const listSrc = readFileSync(
resolve(__dirname, "../mood-list.tsx"),
"utf8",
);
describe("MoodForm — note character counter (v1.11.5)", () => {
it("renders the counter at 0/500 for a pristine form", () => {
const html = renderForm();
expect(html).toContain('data-testid="mood-note-counter"');
expect(html).toContain("0/500");
});
it("ties the textarea cap to the shared NOTE_MAX_LENGTH constant", () => {
expect(formSrc).toContain("const NOTE_MAX_LENGTH = 500");
expect(formSrc).toContain("maxLength={NOTE_MAX_LENGTH}");
// The counter reads off `note.length` against the same constant.
expect(formSrc).toContain('t("mood.noteCharCount"');
expect(formSrc).toMatch(/note\.length >= NOTE_MAX_LENGTH/);
});
});
describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
it("detects dirty from the content fields, not the auto-filled timestamp", () => {
// `isDirty` must exclude `moodLoggedAt` (always populated) so a
// freshly opened form does not falsely warn on Reset.
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
expect(formSrc).toContain('tagsInput.trim() !== ""');
expect(formSrc).toContain("tagKeys.length > 0");
expect(formSrc).toContain('note.trim() !== ""');
// The `isDirty` expression itself must not read `moodLoggedAt`.
const isDirtyExpr = formSrc.slice(
formSrc.indexOf("const isDirty ="),
formSrc.indexOf('note.trim() !== ""') + 'note.trim() !== ""'.length,
);
expect(isDirtyExpr).not.toContain("moodLoggedAt");
});
it("routes Reset through requestReset, which gates the confirm dialog on dirty", () => {
// The dropdown item calls `requestReset`, not `resetForm` directly.
expect(formSrc).toContain("onClick={requestReset}");
expect(formSrc).toMatch(
/function requestReset\(\) \{[\s\S]*if \(isDirty\) \{[\s\S]*setResetConfirmOpen\(true\)/,
);
// A pristine form resets immediately (nothing to lose).
expect(formSrc).toMatch(/if \(isDirty\) \{[\s\S]*\} else \{[\s\S]*resetForm\(\)/);
});
it("mounts the confirm AlertDialog wired to resetForm on the destructive action", () => {
expect(formSrc).toContain('t("mood.formResetConfirmTitle")');
expect(formSrc).toContain('t("mood.formResetConfirmBody")');
expect(formSrc).toContain('t("mood.formResetConfirmAction")');
expect(formSrc).toMatch(/<AlertDialogAction[\s\S]*onClick={resetForm}/);
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
expect(listSrc).toMatch(
/deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/,
);
});
});
+85 -7
View File
@@ -4,6 +4,16 @@ import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,6 +39,11 @@ const MOOD_LEVELS = [
{ value: "LAUSIG", score: 1, labelKey: "mood.levelLausig" },
] as const;
// v1.8.5 (C1) — note free-text limit. Mirrors the `maxLength` on the
// textarea and the server-side Zod bound; surfaced as a character counter
// so the truncation at the cap is no longer silent.
const NOTE_MAX_LENGTH = 500;
/**
* v1.4.25 W4d — curated GLP-1 side-effect chip strip. Tapping a chip
* appends the localised tag string to the existing free-text tag input
@@ -88,6 +103,15 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.11.5 — confirm before Reset wipes typed input. Only the content
// fields the user actively fills count toward "dirty"; the timestamp
// always carries an auto-populated default, so it is excluded.
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const isDirty =
mood !== "" ||
tagsInput.trim() !== "" ||
tagKeys.length > 0 ||
note.trim() !== "";
function toggleTagKey(key: string) {
setTagKeys((prev) =>
@@ -113,6 +137,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setError(null);
}
// v1.11.5 — Reset confirms first when the form holds typed input, so a
// mis-tap can't silently discard a half-written entry. A pristine form
// resets immediately (nothing to lose).
function requestReset() {
if (isDirty) {
setResetConfirmOpen(true);
} else {
resetForm();
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!mood) return;
@@ -174,7 +209,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={resetForm}>
<DropdownMenuItem onClick={requestReset}>
<RotateCcw className="mr-2 h-4 w-4" />
{t("mood.formReset")}
</DropdownMenuItem>
@@ -342,18 +377,37 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no longer
truncates silently. Turns destructive (warns) as the input
approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</Label>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={500}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
@@ -370,6 +424,30 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
)}
{footerSlot ? createPortal(footerNode, footerSlot) : footerNode}
{/* v1.11.5 — Reset confirmation. Only opened by `requestReset` when
the form is dirty; a pristine form skips it entirely. */}
<AlertDialog open={resetConfirmOpen} onOpenChange={setResetConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("mood.formResetConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("mood.formResetConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={resetForm}
>
{t("mood.formResetConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
);
}