From 7986e591471fdbdeb4bc4ee96e17dd8afee5cd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:32:17 +0200 Subject: [PATCH] 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. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../mood/__tests__/mood-qol-fixes.test.tsx | 104 ++++++++++++++++++ src/components/mood/mood-form.tsx | 92 ++++++++++++++-- 8 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 src/components/mood/__tests__/mood-qol-fixes.test.tsx diff --git a/messages/de.json b/messages/de.json index d8a9503f7..0a306575b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 975794b60..d185f1009 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 1afa93443..0249166c8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index ee84af92a..2b5898a33 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 24b597227..02ac12673 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 60d533b4b..fb5f3e33a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ę.", diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx new file mode 100644 index 000000000..f026d8032 --- /dev/null +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -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. `` — the delete mutation surfaces a toast on failure + * (it previously had no `onError`, so a rejected DELETE was silent). + * 2. `` — Reset confirms first when the form holds typed + * input, and resets immediately when pristine. + * 3. `` — 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( + + + + + , + ); +} + +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(/ { + 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"\)\)/, + ); + }); +}); diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 38ac2d570..6f31d611c 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -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(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) { - + {t("mood.formReset")} @@ -342,18 +377,37 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) { {/* v1.8.5 (C1) — free-text note. */}
-