feat(web): success toast on confirmed create/delete
Adds a small top-centered <Toast> that fires only when the Server
Action returns { ok: true } (i.e., the DB write actually succeeded).
Auto-dismisses after 3s.
Wires both create dialogs (CreateAccountDialog, CreateUserDialog) with
an onSuccess callback that the table parent uses to push the toast,
and the delete confirm-flow does the same. Inline-edit success stays
quiet (no toast) — only add/delete trigger it, per the requested
scope.
This commit is contained in:
parent
e3ac94cada
commit
eebbcb3db2
@ -7,6 +7,7 @@ import { deleteAccount, updateAccount } from "@/app/actions";
|
|||||||
import EditableCell from "./editable-cell";
|
import EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
import CreateAccountDialog from "./create-account-dialog";
|
import CreateAccountDialog from "./create-account-dialog";
|
||||||
|
import Toast, { type ToastMessage } from "./toast";
|
||||||
|
|
||||||
type Props = { initial: Acc[]; prefixPattern: string };
|
type Props = { initial: Acc[]; prefixPattern: string };
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@ -71,6 +72,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||||||
|
|
||||||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||||
initial,
|
initial,
|
||||||
@ -113,7 +115,9 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
const result = await deleteAccount(deleteTarget);
|
const result = await deleteAccount(deleteTarget);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
const deleted = deleteTarget;
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
||||||
} else {
|
} else {
|
||||||
setDeleteError(result.error);
|
setDeleteError(result.error);
|
||||||
}
|
}
|
||||||
@ -290,9 +294,14 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|||||||
<CreateAccountDialog
|
<CreateAccountDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={(name) =>
|
||||||
|
setToast({ type: "success", message: `Account ${name} created` })
|
||||||
|
}
|
||||||
prefixPattern={prefixPattern}
|
prefixPattern={prefixPattern}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@ -7,10 +7,11 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
|||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: (username: string) => void;
|
||||||
prefixPattern?: string;
|
prefixPattern?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateAccountDialog({ open, onClose, prefixPattern }: Props) {
|
export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPattern }: Props) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@ -35,14 +36,16 @@ export default function CreateAccountDialog({ open, onClose, prefixPattern }: Pr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const trimmedUsername = username.trim();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createAccount({
|
const result = await createAccount({
|
||||||
username: username.trim(),
|
username: trimmedUsername,
|
||||||
password,
|
password,
|
||||||
status,
|
status,
|
||||||
link,
|
link,
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
onSuccess?.(trimmedUsername);
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|||||||
@ -7,9 +7,10 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
|||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: (fUsername: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateUserDialog({ open, onClose }: Props) {
|
export default function CreateUserDialog({ open, onClose, onSuccess }: Props) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [fUsername, setFUsername] = useState("");
|
const [fUsername, setFUsername] = useState("");
|
||||||
@ -33,14 +34,16 @@ export default function CreateUserDialog({ open, onClose }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const trimmedFUsername = fUsername.trim();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createUser({
|
const result = await createUser({
|
||||||
f_username: fUsername.trim(),
|
f_username: trimmedFUsername,
|
||||||
f_password: fPassword,
|
f_password: fPassword,
|
||||||
t_username: tUsername.trim(),
|
t_username: tUsername.trim(),
|
||||||
t_password: tPassword,
|
t_password: tPassword,
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
onSuccess?.(trimmedFUsername);
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
|
|||||||
57
web/components/toast.tsx
Normal file
57
web/components/toast.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export type ToastMessage = {
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple top-centered toast. Auto-dismisses after `durationMs` (default
|
||||||
|
* 3s). Owners hold the ToastMessage state; this component reads it and
|
||||||
|
* calls onDismiss when the timer fires (or when the toast object
|
||||||
|
* changes — useEffect's cleanup clears any in-flight timer).
|
||||||
|
*/
|
||||||
|
export default function Toast({
|
||||||
|
toast,
|
||||||
|
onDismiss,
|
||||||
|
durationMs = 3000,
|
||||||
|
}: {
|
||||||
|
toast: ToastMessage | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
durationMs?: number;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const id = setTimeout(onDismiss, durationMs);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [toast, onDismiss, durationMs]);
|
||||||
|
|
||||||
|
if (!toast) return null;
|
||||||
|
|
||||||
|
const styles =
|
||||||
|
toast.type === "success"
|
||||||
|
? "bg-emerald-50 text-emerald-800 ring-emerald-200"
|
||||||
|
: "bg-red-50 text-red-800 ring-red-200";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 transform px-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-full px-4 py-2 shadow-sm ring-1 ${styles}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-current/10 text-xs font-bold"
|
||||||
|
>
|
||||||
|
{toast.type === "success" ? "✓" : "!"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { deleteUser, updateUser } from "@/app/actions";
|
|||||||
import EditableCell from "./editable-cell";
|
import EditableCell from "./editable-cell";
|
||||||
import ConfirmDialog from "./confirm-dialog";
|
import ConfirmDialog from "./confirm-dialog";
|
||||||
import CreateUserDialog from "./create-user-dialog";
|
import CreateUserDialog from "./create-user-dialog";
|
||||||
|
import Toast, { type ToastMessage } from "./toast";
|
||||||
|
|
||||||
type Props = { initial: User[]; prefixPattern: string };
|
type Props = { initial: User[]; prefixPattern: string };
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@ -84,6 +85,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||||||
|
|
||||||
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
||||||
initial,
|
initial,
|
||||||
@ -144,7 +146,9 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
const result = await deleteUser(deleteTarget);
|
const result = await deleteUser(deleteTarget);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
const deleted = deleteTarget;
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setToast({ type: "success", message: `User ${deleted} deleted` });
|
||||||
} else {
|
} else {
|
||||||
setDeleteError(result.error);
|
setDeleteError(result.error);
|
||||||
}
|
}
|
||||||
@ -180,7 +184,15 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
|
No users yet. Click <span className="font-medium text-zinc-700">Add</span> to create one manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateUserDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={(name) =>
|
||||||
|
setToast({ type: "success", message: `User ${name} created` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -332,7 +344,15 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateUserDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateUserDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={(name) =>
|
||||||
|
setToast({ type: "success", message: `User ${name} created` })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user