feat: bang edit and delete
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useTransition, useRef, useCallback } from "react";
|
import { useState, useTransition, useRef, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -11,8 +12,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import type { BangStatsDto } from "../server/queries";
|
import { Button } from "@/components/ui/button";
|
||||||
import { addBang } from "../server/actions";
|
import type { BangStatsDto, RecentBangDto } from "../server/queries";
|
||||||
|
import { addBang, deleteBang, updateBang } from "../server/actions";
|
||||||
|
|
||||||
// ─── Celebration engine ───────────────────────────────────────────────────────
|
// ─── Celebration engine ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -301,14 +303,7 @@ export function BangWidget({ stats, maxRecentBangs }: Props) {
|
|||||||
Last {Math.min(stats.recent.length, maxRecentBangs)}
|
Last {Math.min(stats.recent.length, maxRecentBangs)}
|
||||||
</p>
|
</p>
|
||||||
{stats.recent.map((bang) => (
|
{stats.recent.map((bang) => (
|
||||||
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
|
<RecentBangRow key={bang.id} bang={bang} />
|
||||||
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
|
|
||||||
{bang.recordedByName && (
|
|
||||||
<span className="max-w-[40%] truncate text-xs text-[var(--ink-mute)]">
|
|
||||||
{bang.recordedByName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -320,6 +315,142 @@ export function BangWidget({ stats, maxRecentBangs }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RecentBangRow({ bang }: { bang: RecentBangDto }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [dateValue, setDateValue] = useState(bang.occurredOn);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function handleEditOpenChange(open: boolean) {
|
||||||
|
setEditOpen(open);
|
||||||
|
if (open) {
|
||||||
|
setDateValue(bang.occurredOn);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateBang({ id: bang.id, occurredOn: dateValue });
|
||||||
|
setEditOpen(false);
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong. Try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await deleteBang({ id: bang.id });
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong. Try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/row flex items-center justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
{bang.recordedByName && (
|
||||||
|
<span className="max-w-[5rem] truncate text-xs text-[var(--ink-mute)] sm:max-w-[6rem]">
|
||||||
|
{bang.recordedByName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Edit bang on ${bang.occurredOn}`}
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Delete bang on ${bang.occurredOn}`}
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={editOpen} onOpenChange={handleEditOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit bang</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
id={`edit-bang-${bang.id}`}
|
||||||
|
onSubmit={handleEditSubmit}
|
||||||
|
className="flex flex-col gap-3 pt-1"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={`edit-bang-date-${bang.id}`} className="text-sm font-medium">
|
||||||
|
Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`edit-bang-date-${bang.id}`}
|
||||||
|
type="date"
|
||||||
|
value={dateValue}
|
||||||
|
onChange={(e) => setDateValue(e.target.value)}
|
||||||
|
className="input input-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && editOpen && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
</form>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form={`edit-bang-${bang.id}`}
|
||||||
|
disabled={isPending}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDeleteOpen(open);
|
||||||
|
if (!open) setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete bang?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-[var(--ink-mute)]">
|
||||||
|
Remove the bang from {formatBangDate(bang.occurredOn)}? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{error && deleteOpen && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="btn btn-sm bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{isPending ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function todayValue(): string {
|
function todayValue(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ const bangsManifest: ModuleManifest = {
|
|||||||
resolveUrl: () => "/",
|
resolveUrl: () => "/",
|
||||||
renderActivity: (entry) => {
|
renderActivity: (entry) => {
|
||||||
const date = entry.payload?.occurredOn as string | undefined;
|
const date = entry.payload?.occurredOn as string | undefined;
|
||||||
return date ? `Recorded a bang on ${date}` : "Recorded a bang";
|
const dateLabel = date ? ` on ${date}` : "";
|
||||||
|
if (entry.action === "create") return `Recorded a bang${dateLabel}`;
|
||||||
|
if (entry.action === "delete") return `Deleted a bang${dateLabel}`;
|
||||||
|
if (entry.action === "update") return `Updated a bang${dateLabel}`;
|
||||||
|
return `Recorded a bang${dateLabel}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -7,11 +8,19 @@ import { getCurrentSession } from "@/lib/session";
|
|||||||
import { logActivity } from "@/modules/_core/activity";
|
import { logActivity } from "@/modules/_core/activity";
|
||||||
import { bangEvents } from "../schema";
|
import { bangEvents } from "../schema";
|
||||||
|
|
||||||
|
const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
|
||||||
|
|
||||||
const addBangInput = z.object({
|
const addBangInput = z.object({
|
||||||
occurredOn: z
|
occurredOn: dateString.optional(),
|
||||||
.string()
|
});
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
|
|
||||||
.optional(),
|
const updateBangInput = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
occurredOn: dateString,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBangInput = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function todayString(): string {
|
function todayString(): string {
|
||||||
@@ -48,3 +57,63 @@ export async function addBang(input: z.input<typeof addBangInput> = {}) {
|
|||||||
|
|
||||||
return bang;
|
return bang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateBang(input: z.input<typeof updateBangInput>) {
|
||||||
|
const parsed = updateBangInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessBang(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [bang] = await db
|
||||||
|
.update(bangEvents)
|
||||||
|
.set({ occurredOn: parsed.occurredOn })
|
||||||
|
.where(eq(bangEvents.id, parsed.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!bang) throw new Error("Bang was not updated");
|
||||||
|
|
||||||
|
await logActivity({
|
||||||
|
entityType: "bangs.event",
|
||||||
|
entityId: bang.id,
|
||||||
|
action: "update",
|
||||||
|
payload: { occurredOn: parsed.occurredOn },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/d/[slug]", "page");
|
||||||
|
|
||||||
|
return bang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBang(input: z.input<typeof deleteBangInput>) {
|
||||||
|
const parsed = deleteBangInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessBang(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ occurredOn: bangEvents.occurredOn })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(eq(bangEvents.id, parsed.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
await logActivity({
|
||||||
|
entityType: "bangs.event",
|
||||||
|
entityId: parsed.id,
|
||||||
|
action: "delete",
|
||||||
|
payload: existing ? { occurredOn: existing.occurredOn } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.delete(bangEvents).where(eq(bangEvents.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/d/[slug]", "page");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanAccessBang(bangId: string, householdId: string) {
|
||||||
|
const [bang] = await db
|
||||||
|
.select({ id: bangEvents.id })
|
||||||
|
.from(bangEvents)
|
||||||
|
.where(and(eq(bangEvents.id, bangId), eq(bangEvents.householdId, householdId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!bang) throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function ensureSignedIn(page: Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
const devLogin = page.getByRole("button", { name: "Dev login" });
|
||||||
|
if (await devLogin.isVisible().catch(() => false)) {
|
||||||
|
await devLogin.click();
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"));
|
||||||
|
}
|
||||||
|
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBangDate(iso: string): string {
|
||||||
|
const [year, month, day] = iso.split("-").map(Number);
|
||||||
|
const d = new Date(year!, month! - 1, day!);
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bangWidget(page: Page) {
|
||||||
|
return page.locator('[data-slot="card"]').filter({
|
||||||
|
has: page.getByRole("heading", { name: "Bang Counter" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("bangs happy path", async ({ page }) => {
|
||||||
|
const originalDate = "2026-03-10";
|
||||||
|
const editedDate = "2026-04-20";
|
||||||
|
|
||||||
|
await ensureSignedIn(page);
|
||||||
|
|
||||||
|
const widget = bangWidget(page);
|
||||||
|
await expect(widget).toBeVisible();
|
||||||
|
|
||||||
|
await widget.getByRole("button", { name: "Add Bang" }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Record a bang" })).toBeVisible();
|
||||||
|
await page.getByLabel("Date").fill(originalDate);
|
||||||
|
await page.getByRole("button", { name: "Record it" }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Record a bang" })).toBeHidden();
|
||||||
|
|
||||||
|
const originalLabel = formatBangDate(originalDate);
|
||||||
|
await expect(widget.getByText(originalLabel, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await widget.getByRole("button", { name: `Edit bang on ${originalDate}` }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit bang" })).toBeVisible();
|
||||||
|
await page.getByLabel("Date").fill(editedDate);
|
||||||
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Edit bang" })).toBeHidden();
|
||||||
|
|
||||||
|
const editedLabel = formatBangDate(editedDate);
|
||||||
|
await expect(widget.getByText(editedLabel, { exact: true })).toBeVisible();
|
||||||
|
await expect(widget.getByText(originalLabel, { exact: true })).toBeHidden();
|
||||||
|
|
||||||
|
await widget.getByRole("button", { name: `Delete bang on ${editedDate}` }).click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Delete bang?" })).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Delete bang?" })
|
||||||
|
.getByRole("button", { name: "Delete" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole("dialog", { name: "Delete bang?" })).toBeHidden();
|
||||||
|
|
||||||
|
await expect(widget.getByText(editedLabel, { exact: true })).toBeHidden();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user