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 ConfirmDialog from "./confirm-dialog";
|
||||
import CreateAccountDialog from "./create-account-dialog";
|
||||
import Toast, { type ToastMessage } from "./toast";
|
||||
|
||||
type Props = { initial: Acc[]; prefixPattern: string };
|
||||
type SortDir = "asc" | "desc";
|
||||
@ -71,6 +72,7 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||||
|
||||
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
||||
initial,
|
||||
@ -113,7 +115,9 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
const result = await deleteAccount(deleteTarget);
|
||||
setDeleting(false);
|
||||
if (result.ok) {
|
||||
const deleted = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
||||
} else {
|
||||
setDeleteError(result.error);
|
||||
}
|
||||
@ -290,9 +294,14 @@ export default function AccountsTable({ initial, prefixPattern }: Props) {
|
||||
<CreateAccountDialog
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSuccess={(name) =>
|
||||
setToast({ type: "success", message: `Account ${name} created` })
|
||||
}
|
||||
prefixPattern={prefixPattern}
|
||||
/>
|
||||
|
||||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onCancel={() => {
|
||||
|
||||
@ -7,10 +7,11 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (username: string) => void;
|
||||
prefixPattern?: string;
|
||||
};
|
||||
|
||||
export default function CreateAccountDialog({ open, onClose, prefixPattern }: Props) {
|
||||
export default function CreateAccountDialog({ open, onClose, onSuccess, prefixPattern }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
@ -35,14 +36,16 @@ export default function CreateAccountDialog({ open, onClose, prefixPattern }: Pr
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const trimmedUsername = username.trim();
|
||||
startTransition(async () => {
|
||||
const result = await createAccount({
|
||||
username: username.trim(),
|
||||
username: trimmedUsername,
|
||||
password,
|
||||
status,
|
||||
link,
|
||||
});
|
||||
if (result.ok) {
|
||||
onSuccess?.(trimmedUsername);
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error);
|
||||
|
||||
@ -7,9 +7,10 @@ import FormDialogShell, { Field, inputClass } from "./form-dialog-shell";
|
||||
type Props = {
|
||||
open: boolean;
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [fUsername, setFUsername] = useState("");
|
||||
@ -33,14 +34,16 @@ export default function CreateUserDialog({ open, onClose }: Props) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const trimmedFUsername = fUsername.trim();
|
||||
startTransition(async () => {
|
||||
const result = await createUser({
|
||||
f_username: fUsername.trim(),
|
||||
f_username: trimmedFUsername,
|
||||
f_password: fPassword,
|
||||
t_username: tUsername.trim(),
|
||||
t_password: tPassword,
|
||||
});
|
||||
if (result.ok) {
|
||||
onSuccess?.(trimmedFUsername);
|
||||
onClose();
|
||||
} else {
|
||||
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 ConfirmDialog from "./confirm-dialog";
|
||||
import CreateUserDialog from "./create-user-dialog";
|
||||
import Toast, { type ToastMessage } from "./toast";
|
||||
|
||||
type Props = { initial: User[]; prefixPattern: string };
|
||||
type SortDir = "asc" | "desc";
|
||||
@ -84,6 +85,7 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||||
|
||||
const [optimistic, applyOptimistic] = useOptimistic<User[], OptimisticPatch>(
|
||||
initial,
|
||||
@ -144,7 +146,9 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
const result = await deleteUser(deleteTarget);
|
||||
setDeleting(false);
|
||||
if (result.ok) {
|
||||
const deleted = deleteTarget;
|
||||
setDeleteTarget(null);
|
||||
setToast({ type: "success", message: `User ${deleted} deleted` });
|
||||
} else {
|
||||
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.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -332,7 +344,15 @@ export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
})}
|
||||
</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
|
||||
open={!!deleteTarget}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user