feat: bang edit and delete
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState, useTransition, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import confetti from "canvas-confetti";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,8 +12,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BangStatsDto } from "../server/queries";
|
||||
import { addBang } from "../server/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BangStatsDto, RecentBangDto } from "../server/queries";
|
||||
import { addBang, deleteBang, updateBang } from "../server/actions";
|
||||
|
||||
// ─── Celebration engine ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,14 +303,7 @@ export function BangWidget({ stats, maxRecentBangs }: Props) {
|
||||
Last {Math.min(stats.recent.length, maxRecentBangs)}
|
||||
</p>
|
||||
{stats.recent.map((bang) => (
|
||||
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<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>
|
||||
<RecentBangRow key={bang.id} bang={bang} />
|
||||
))}
|
||||
</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 {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
|
||||
@@ -23,7 +23,11 @@ const bangsManifest: ModuleManifest = {
|
||||
resolveUrl: () => "/",
|
||||
renderActivity: (entry) => {
|
||||
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";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
@@ -7,11 +8,19 @@ import { getCurrentSession } from "@/lib/session";
|
||||
import { logActivity } from "@/modules/_core/activity";
|
||||
import { bangEvents } from "../schema";
|
||||
|
||||
const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
|
||||
|
||||
const addBangInput = z.object({
|
||||
occurredOn: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
|
||||
.optional(),
|
||||
occurredOn: dateString.optional(),
|
||||
});
|
||||
|
||||
const updateBangInput = z.object({
|
||||
id: z.string().uuid(),
|
||||
occurredOn: dateString,
|
||||
});
|
||||
|
||||
const deleteBangInput = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
function todayString(): string {
|
||||
@@ -48,3 +57,63 @@ export async function addBang(input: z.input<typeof addBangInput> = {}) {
|
||||
|
||||
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