From 62728a042bcb124f72ef846f0522172102b8928c Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Tue, 24 Feb 2026 05:00:44 -0800 Subject: [PATCH 1/7] feat: show error dialog when server actions fail Server actions previously failed silently. Now all actions return { error } on failure, surfaced via a native modal matching the existing SiteFormDialog style. Closes #56 Co-Authored-By: Claude Opus 4.6 --- src/app/incidents/[id]/actions.ts | 4 +- src/app/incidents/[id]/page.tsx | 5 ++- src/app/layout.tsx | 7 +++- src/app/settings/actions.ts | 7 ++-- src/app/settings/invite-actions.ts | 9 +++-- src/app/settings/page.tsx | 13 ++++--- src/app/sites/actions.ts | 12 ++++-- src/components/ActionForm.tsx | 26 +++++++++++++ src/components/AddContactForm.tsx | 10 ++++- src/components/ErrorDialog.tsx | 62 ++++++++++++++++++++++++++++++ src/components/SiteFormDialog.tsx | 20 +++++++--- src/components/SiteList.tsx | 6 ++- 12 files changed, 150 insertions(+), 31 deletions(-) create mode 100644 src/components/ActionForm.tsx create mode 100644 src/components/ErrorDialog.tsx diff --git a/src/app/incidents/[id]/actions.ts b/src/app/incidents/[id]/actions.ts index c30678b..814723a 100644 --- a/src/app/incidents/[id]/actions.ts +++ b/src/app/incidents/[id]/actions.ts @@ -15,7 +15,7 @@ export async function resolveIncident(incidentId: string) { redirect("/login") } - await supabase + const { error } = await supabase .from("incidents") .update({ status: "resolved", @@ -23,6 +23,8 @@ export async function resolveIncident(incidentId: string) { }) .eq("id", incidentId) + if (error) return { error: `Failed to resolve incident: ${error.message}` } + revalidatePath(`/incidents/${incidentId}`) revalidatePath("/") } diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx index d708242..9fc4900 100644 --- a/src/app/incidents/[id]/page.tsx +++ b/src/app/incidents/[id]/page.tsx @@ -5,6 +5,7 @@ import type { Incident, Site, Check } from "@/lib/supabase/types" import CheckLogTable from "@/components/CheckLogTable" import { LocalDateTime } from "@/components/LocalTime" import { resolveIncident } from "./actions" +import ActionForm from "@/components/ActionForm" export const revalidate = 0 @@ -140,7 +141,7 @@ export default async function IncidentDetailPage({ {isOpen && user && (
-
+ - +
)} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0e91410..9a51953 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next" import "./globals.css" import Header from "@/components/Header" +import ErrorDialogProvider from "@/components/ErrorDialog" export const metadata: Metadata = { title: "Site Status", @@ -15,8 +16,10 @@ export default function RootLayout({ return ( -
- {children} + +
+ {children} + ) diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 8953a38..cb1ae19 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -22,12 +22,12 @@ export async function addContact(formData: FormData) { if (!webhookUrl || !label) return if (!SLACK_WEBHOOK_REGEX.test(webhookUrl)) return const { error } = await supabase.from("contacts").insert({ type: "slack", webhook_url: webhookUrl, label }) - if (error) console.error("Failed to insert Slack contact:", error) + if (error) return { error: `Failed to add Slack contact: ${error.message}` } } else { const email = formData.get("contact_email") as string if (!email) return const { error } = await supabase.from("contacts").insert({ type: "email", email }) - if (error) console.error("Failed to insert email contact:", error) + if (error) return { error: `Failed to add email contact: ${error.message}` } } revalidatePath("/settings") @@ -41,6 +41,7 @@ export async function deleteContact(contactId: string) { if (!user) redirect("/login") - await supabase.from("contacts").delete().eq("id", contactId) + const { error } = await supabase.from("contacts").delete().eq("id", contactId) + if (error) return { error: `Failed to remove contact: ${error.message}` } revalidatePath("/settings") } diff --git a/src/app/settings/invite-actions.ts b/src/app/settings/invite-actions.ts index 74ac4d0..a4fa8f6 100644 --- a/src/app/settings/invite-actions.ts +++ b/src/app/settings/invite-actions.ts @@ -31,7 +31,7 @@ export async function sendInvitation(formData: FormData) { token, }) - if (error) return + if (error) return { error: `Failed to create invitation: ${error.message}` } const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org" const inviteLink = `${appUrl}/invite/${token}` @@ -48,8 +48,8 @@ export async function sendInvitation(formData: FormData) {

Click here to set your password and create your account

`.trim(), }) - } catch (err) { - console.error("Failed to send invitation email:", err) + } catch { + return { error: "Invitation created but failed to send email. The invite link is still valid." } } revalidatePath("/settings") @@ -63,6 +63,7 @@ export async function revokeInvitation(invitationId: string) { if (!user) redirect("/login") - await supabase.from("invitations").delete().eq("id", invitationId) + const { error } = await supabase.from("invitations").delete().eq("id", invitationId) + if (error) return { error: `Failed to revoke invitation: ${error.message}` } revalidatePath("/settings") } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e87ab8c..82fd070 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -4,6 +4,7 @@ import type { Contact, Invitation } from "@/lib/supabase/types" import { deleteContact } from "./actions" import AddContactForm from "@/components/AddContactForm" import { sendInvitation, revokeInvitation } from "./invite-actions" +import ActionForm from "@/components/ActionForm" import Link from "next/link" export const revalidate = 0 @@ -82,7 +83,7 @@ export default async function SettingsPage() { {contact.type === "slack" ? (contact.label || "Slack webhook") : contact.email} -
+ - +
)) )} @@ -133,7 +134,7 @@ export default async function SettingsPage() { {invitation.email} -
+ - +
)) )} -
+ Send - +
) diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts index 8e4bb95..1bb54e8 100644 --- a/src/app/sites/actions.ts +++ b/src/app/sites/actions.ts @@ -26,7 +26,8 @@ export async function addSite(formData: FormData) { const position = (maxRow?.position ?? -1) + 1 - await supabase.from("sites").insert({ name, url, position }) + const { error } = await supabase.from("sites").insert({ name, url, position }) + if (error) return { error: `Failed to add site: ${error.message}` } revalidatePath("/") } @@ -43,7 +44,8 @@ export async function editSite(siteId: string, formData: FormData) { if (!name || !url) return - await supabase.from("sites").update({ name, url }).eq("id", siteId) + const { error } = await supabase.from("sites").update({ name, url }).eq("id", siteId) + if (error) return { error: `Failed to update site: ${error.message}` } revalidatePath("/") revalidatePath(`/sites/${siteId}`) } @@ -56,7 +58,8 @@ export async function deleteSite(siteId: string) { if (!user) redirect("/login") - await supabase.from("sites").delete().eq("id", siteId) + const { error } = await supabase.from("sites").delete().eq("id", siteId) + if (error) return { error: `Failed to delete site: ${error.message}` } revalidatePath("/") } @@ -69,10 +72,11 @@ export async function reorderSites(orderedIds: string[]) { if (!user) redirect("/login") for (let i = 0; i < orderedIds.length; i++) { - await supabase + const { error } = await supabase .from("sites") .update({ position: i }) .eq("id", orderedIds[i]) + if (error) return { error: `Failed to reorder sites: ${error.message}` } } revalidatePath("/") diff --git a/src/components/ActionForm.tsx b/src/components/ActionForm.tsx new file mode 100644 index 0000000..8659c6f --- /dev/null +++ b/src/components/ActionForm.tsx @@ -0,0 +1,26 @@ +"use client" + +import { useErrorDialog } from "@/components/ErrorDialog" + +export default function ActionForm({ + action, + children, + ...props +}: { + action: (formData: FormData) => Promise<{ error: string } | undefined> + children: React.ReactNode +} & Omit, "action">) { + const { showError } = useErrorDialog() + + return ( +
{ + const result = await action(formData) + if (result?.error) showError(result.error) + }} + > + {children} +
+ ) +} diff --git a/src/components/AddContactForm.tsx b/src/components/AddContactForm.tsx index 8090d14..2110cf2 100644 --- a/src/components/AddContactForm.tsx +++ b/src/components/AddContactForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { addContact } from "@/app/settings/actions" +import { useErrorDialog } from "@/components/ErrorDialog" const inputStyle = { border: "1px solid #E8E4DF", @@ -11,11 +12,16 @@ const inputStyle = { export default function AddContactForm() { const [type, setType] = useState<"email" | "slack">("email") + const { showError } = useErrorDialog() return (
{ - await addContact(formData) - setType("email") + const result = await addContact(formData) + if (result?.error) { + showError(result.error) + } else { + setType("email") + } }} className="flex gap-2">