mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
ccf56b8dbb
commit
7986e59147
8 files changed
+213
-7
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"\)\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user