add delete resource modal (#280)

This commit is contained in:
KM Koushik
2025-10-25 05:37:16 +11:00
committed by GitHub
parent f1e63b6c46
commit 374f173a09
8 changed files with 332 additions and 594 deletions
@@ -1,57 +1,31 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react"; import { Campaign } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Campaign } from "@prisma/client";
const campaignSchema = z.object({
name: z.string(),
});
export const DeleteCampaign: React.FC<{ export const DeleteCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string }; campaign: Partial<Campaign> & { id: string };
}> = ({ campaign }) => { }> = ({ campaign }) => {
const [open, setOpen] = useState(false);
const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation(); const deleteCampaignMutation = api.campaign.deleteCampaign.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const campaignForm = useForm<z.infer<typeof campaignSchema>>({ const campaignSchema = z
resolver: zodResolver(campaignSchema), .object({
}); confirmation: z
.string()
.min(1, "Please type the campaign name to confirm"),
})
.refine((data) => data.confirmation === campaign.name, {
message: "Campaign name does not match",
path: ["confirmation"],
});
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) { async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
if (values.name !== campaign.name) {
campaignForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteCampaignMutation.mutate( deleteCampaignMutation.mutate(
{ {
campaignId: campaign.id, campaignId: campaign.id,
@@ -59,77 +33,26 @@ export const DeleteCampaign: React.FC<{
{ {
onSuccess: () => { onSuccess: () => {
utils.campaign.getCampaigns.invalidate(); utils.campaign.getCampaigns.invalidate();
setOpen(false);
toast.success(`Campaign deleted`); toast.success(`Campaign deleted`);
}, },
}, },
); );
} }
const name = campaignForm.watch("name");
return ( return (
<Dialog <DeleteResource
open={open} title="Delete Campaign"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={campaign.name || ""}
> schema={campaignSchema}
<DialogTrigger asChild> isLoading={deleteCampaignMutation.isPending}
onConfirm={onCampaignDelete}
trigger={
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent"> <Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red/80" /> <Trash2 className="h-[18px] w-[18px] text-red/80" />
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete Campaign"
<DialogHeader> />
<DialogTitle>Delete Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
{campaign.name}
</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...campaignForm}>
<form
onSubmit={campaignForm.handleSubmit(onCampaignDelete)}
className="space-y-4"
>
<FormField
control={campaignForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteCampaignMutation.isPending || campaign.name !== name
}
>
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,57 +1,29 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react"; import { Contact } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Contact } from "@prisma/client";
const contactSchema = z.object({
email: z.string().email(),
});
export const DeleteContact: React.FC<{ export const DeleteContact: React.FC<{
contact: Partial<Contact> & { id: string; contactBookId: string }; contact: Partial<Contact> & { id: string; contactBookId: string };
}> = ({ contact }) => { }> = ({ contact }) => {
const [open, setOpen] = useState(false);
const deleteContactMutation = api.contacts.deleteContact.useMutation(); const deleteContactMutation = api.contacts.deleteContact.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const contactForm = useForm<z.infer<typeof contactSchema>>({ const contactSchema = z
resolver: zodResolver(contactSchema), .object({
}); confirmation: z.string().email("Please enter a valid email address"),
})
.refine((data) => data.confirmation === contact.email, {
message: "Email does not match",
path: ["confirmation"],
});
async function onContactDelete(values: z.infer<typeof contactSchema>) { async function onContactDelete(values: z.infer<typeof contactSchema>) {
if (values.email !== contact.email) {
contactForm.setError("email", {
message: "Email does not match",
});
return;
}
deleteContactMutation.mutate( deleteContactMutation.mutate(
{ {
contactId: contact.id, contactId: contact.id,
@@ -60,7 +32,6 @@ export const DeleteContact: React.FC<{
{ {
onSuccess: () => { onSuccess: () => {
utils.contacts.contacts.invalidate(); utils.contacts.contacts.invalidate();
setOpen(false);
toast.success(`Contact deleted`); toast.success(`Contact deleted`);
}, },
onError: (e) => { onError: (e) => {
@@ -70,70 +41,20 @@ export const DeleteContact: React.FC<{
); );
} }
const email = contactForm.watch("email");
return ( return (
<Dialog <DeleteResource
open={open} title="Delete Contact"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={contact.email || ""}
> schema={contactSchema}
<DialogTrigger asChild> isLoading={deleteContactMutation.isPending}
onConfirm={onContactDelete}
trigger={
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" /> <Trash2 className="h-4 w-4 text-red/80" />
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete Contact"
<DialogHeader> />
<DialogTitle>Delete Contact</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
{contact.email}
</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...contactForm}>
<form
onSubmit={contactForm.handleSubmit(onContactDelete)}
className="space-y-4"
>
<FormField
control={contactForm.control}
name="email"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.email ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteContactMutation.isPending || contact.email !== email
}
>
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,60 +1,34 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react"; import { ContactBook } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { ContactBook } from "@prisma/client";
const contactBookSchema = z.object({
name: z.string(),
});
export const DeleteContactBook: React.FC<{ export const DeleteContactBook: React.FC<{
contactBook: Partial<ContactBook> & { id: string }; contactBook: Partial<ContactBook> & { id: string };
}> = ({ contactBook }) => { }> = ({ contactBook }) => {
const [open, setOpen] = useState(false);
const deleteContactBookMutation = const deleteContactBookMutation =
api.contacts.deleteContactBook.useMutation(); api.contacts.deleteContactBook.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({ const contactBookSchema = z
resolver: zodResolver(contactBookSchema), .object({
}); confirmation: z
.string()
.min(1, "Please type the contact book name to confirm"),
})
.refine((data) => data.confirmation === contactBook.name, {
message: "Contact book name does not match",
path: ["confirmation"],
});
async function onContactBookDelete( async function onContactBookDelete(
values: z.infer<typeof contactBookSchema>, values: z.infer<typeof contactBookSchema>,
) { ) {
if (values.name !== contactBook.name) {
contactBookForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteContactBookMutation.mutate( deleteContactBookMutation.mutate(
{ {
contactBookId: contactBook.id, contactBookId: contactBook.id,
@@ -62,80 +36,26 @@ export const DeleteContactBook: React.FC<{
{ {
onSuccess: () => { onSuccess: () => {
utils.contacts.getContactBooks.invalidate(); utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success(`Contact book deleted`); toast.success(`Contact book deleted`);
}, },
}, },
); );
} }
const name = contactBookForm.watch("name");
return ( return (
<Dialog <DeleteResource
open={open} title="Delete Contact Book"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={contactBook.name || ""}
> schema={contactBookSchema}
<DialogTrigger asChild> isLoading={deleteContactBookMutation.isPending}
onConfirm={onContactBookDelete}
trigger={
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent "> <Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" /> <Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete Contact Book"
<DialogHeader> />
<DialogTitle>Delete Contact Book</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
{contactBook.name}
</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...contactBookForm}>
<form
onSubmit={contactBookForm.handleSubmit(onContactBookDelete)}
className="space-y-4"
>
<FormField
control={contactBookForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Contact book name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteContactBookMutation.isPending ||
contactBook.name !== name
}
>
{deleteContactBookMutation.isPending
? "Deleting..."
: "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,57 +1,31 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react";
import { ApiKey } from "@prisma/client"; import { ApiKey } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
const apiKeySchema = z.object({
name: z.string(),
});
export const DeleteApiKey: React.FC<{ export const DeleteApiKey: React.FC<{
apiKey: Partial<ApiKey> & { id: number }; apiKey: Partial<ApiKey> & { id: number };
}> = ({ apiKey }) => { }> = ({ apiKey }) => {
const [open, setOpen] = useState(false);
const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation(); const deleteApiKeyMutation = api.apiKey.deleteApiKey.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({ const apiKeySchema = z
resolver: zodResolver(apiKeySchema), .object({
}); confirmation: z
.string()
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) { .min(1, "Please type the API key name to confirm"),
if (values.name !== apiKey.name) { })
apiKeyForm.setError("name", { .refine((data) => data.confirmation === apiKey.name, {
message: "Name does not match", message: "API key name does not match",
}); path: ["confirmation"],
return; });
}
async function onApiKeyDelete(values: z.infer<typeof apiKeySchema>) {
deleteApiKeyMutation.mutate( deleteApiKeyMutation.mutate(
{ {
id: apiKey.id, id: apiKey.id,
@@ -59,75 +33,26 @@ export const DeleteApiKey: React.FC<{
{ {
onSuccess: () => { onSuccess: () => {
utils.apiKey.invalidate(); utils.apiKey.invalidate();
setOpen(false);
toast.success(`API key deleted`); toast.success(`API key deleted`);
}, },
}, },
); );
} }
const name = apiKeyForm.watch("name");
return ( return (
<Dialog <DeleteResource
open={open} title="Delete API key"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={apiKey.name || ""}
> schema={apiKeySchema}
<DialogTrigger asChild> isLoading={deleteApiKeyMutation.isPending}
onConfirm={onApiKeyDelete}
trigger={
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" /> <Trash2 className="h-4 w-4 text-red/80" />
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete API key"
<DialogHeader> />
<DialogTitle>Delete API key</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{apiKey.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...apiKeyForm}>
<form
onSubmit={apiKeyForm.handleSubmit(onDomainDelete)}
className="space-y-4"
>
<FormField
control={apiKeyForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteApiKeyMutation.isPending || apiKey.name !== name
}
>
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,60 +1,28 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react";
import { Domain } from "@prisma/client"; import { Domain } from "@prisma/client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const domainSchema = z.object({
domain: z.string(),
});
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => { export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const deleteDomainMutation = api.domain.deleteDomain.useMutation(); const deleteDomainMutation = api.domain.deleteDomain.useMutation();
const domainForm = useForm<z.infer<typeof domainSchema>>({
resolver: zodResolver(domainSchema),
});
const utils = api.useUtils(); const utils = api.useUtils();
const router = useRouter(); const router = useRouter();
async function onDomainDelete(values: z.infer<typeof domainSchema>) { const domainSchema = z
if (values.domain !== domain.name) { .object({
domainForm.setError("domain", { confirmation: z.string().min(1, "Please type the domain name to confirm"),
message: "Domain name does not match", })
}); .refine((data) => data.confirmation === domain.name, {
return; message: "Domain name does not match",
} path: ["confirmation"],
});
async function onDomainDelete(values: z.infer<typeof domainSchema>) {
deleteDomainMutation.mutate( deleteDomainMutation.mutate(
{ {
id: domain.id, id: domain.id,
@@ -62,7 +30,6 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
{ {
onSuccess: () => { onSuccess: () => {
utils.domain.domains.invalidate(); utils.domain.domains.invalidate();
setOpen(false);
toast.success(`Domain ${domain.name} deleted`); toast.success(`Domain ${domain.name} deleted`);
router.replace("/domains"); router.replace("/domains");
}, },
@@ -71,61 +38,19 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
} }
return ( return (
<Dialog <DeleteResource
open={open} title="Delete domain"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={domain.name}
> schema={domainSchema}
<DialogTrigger asChild> isLoading={deleteDomainMutation.isPending}
onConfirm={onDomainDelete}
trigger={
<Button variant="destructive" className="w-[150px]" size="sm"> <Button variant="destructive" className="w-[150px]" size="sm">
Delete domain Delete domain
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete domain"
<DialogHeader> />
<DialogTitle>Delete domain</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{domain.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<Form {...domainForm}>
<form
onSubmit={domainForm.handleSubmit(onDomainDelete)}
className="space-y-4"
>
<FormField
control={domainForm.control}
name="domain"
render={({ field, formState }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="subdomain.example.com" {...field} />
</FormControl>
{formState.errors.domain ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={deleteDomainMutation.isPending}
>
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,57 +1,31 @@
"use client"; "use client";
import { Button } from "@usesend/ui/src/button"; import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input"; import { DeleteResource } from "~/components/DeleteResource";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react"; import { api } from "~/trpc/react";
import React, { useState } from "react"; import { Template } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster"; import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Template } from "@prisma/client";
const templateSchema = z.object({
name: z.string(),
});
export const DeleteTemplate: React.FC<{ export const DeleteTemplate: React.FC<{
template: Partial<Template> & { id: string }; template: Partial<Template> & { id: string };
}> = ({ template }) => { }> = ({ template }) => {
const [open, setOpen] = useState(false);
const deleteTemplateMutation = api.template.deleteTemplate.useMutation(); const deleteTemplateMutation = api.template.deleteTemplate.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const templateForm = useForm<z.infer<typeof templateSchema>>({ const templateSchema = z
resolver: zodResolver(templateSchema), .object({
}); confirmation: z
.string()
.min(1, "Please type the template name to confirm"),
})
.refine((data) => data.confirmation === template.name, {
message: "Template name does not match",
path: ["confirmation"],
});
async function onTemplateDelete(values: z.infer<typeof templateSchema>) { async function onTemplateDelete(values: z.infer<typeof templateSchema>) {
if (values.name !== template.name) {
templateForm.setError("name", {
message: "Name does not match",
});
return;
}
deleteTemplateMutation.mutate( deleteTemplateMutation.mutate(
{ {
templateId: template.id, templateId: template.id,
@@ -59,77 +33,26 @@ export const DeleteTemplate: React.FC<{
{ {
onSuccess: () => { onSuccess: () => {
utils.template.getTemplates.invalidate(); utils.template.getTemplates.invalidate();
setOpen(false);
toast.success(`Template deleted`); toast.success(`Template deleted`);
}, },
}, },
); );
} }
const name = templateForm.watch("name");
return ( return (
<Dialog <DeleteResource
open={open} title="Delete Template"
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)} resourceName={template.name || ""}
> schema={templateSchema}
<DialogTrigger asChild> isLoading={deleteTemplateMutation.isPending}
onConfirm={onTemplateDelete}
trigger={
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent"> <Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red/80" /> <Trash2 className="h-[18px] w-[18px] text-red/80" />
</Button> </Button>
</DialogTrigger> }
<DialogContent> confirmLabel="Delete Template"
<DialogHeader> />
<DialogTitle>Delete Template</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
{template.name}
</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Form {...templateForm}>
<form
onSubmit={templateForm.handleSubmit(onTemplateDelete)}
className="space-y-4"
>
<FormField
control={templateForm.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="destructive"
disabled={
deleteTemplateMutation.isPending || template.name !== name
}
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
); );
}; };
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Copy, Check } from "lucide-react";
import React, { useState, type ReactNode } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const defaultSchema = z.object({
confirmation: z.string(),
});
type ConfirmationValues = {
confirmation: string;
};
type ConfirmationSchema = z.ZodType<
ConfirmationValues,
z.ZodTypeDef,
ConfirmationValues
>;
export interface DeleteResourceProps<
Schema extends ConfirmationSchema = typeof defaultSchema,
> {
title: string;
resourceName: string;
descriptionBody?: ReactNode | string;
confirmLabel?: string;
isLoading?: boolean;
// eslint-disable-next-line no-unused-vars
onConfirm: (values: z.infer<Schema>) => void | Promise<void>;
open?: boolean;
// eslint-disable-next-line no-unused-vars
onOpenChange?: (open: boolean) => void;
schema?: Schema;
trigger?: React.ReactNode;
children?: React.ReactNode;
}
export const DeleteResource = <
Schema extends ConfirmationSchema = typeof defaultSchema,
>({
title,
resourceName,
descriptionBody,
confirmLabel = "Delete",
isLoading = false,
onConfirm,
open: controlledOpen,
onOpenChange,
schema: providedSchema,
trigger,
children,
}: DeleteResourceProps<Schema>) => {
const [internalOpen, setInternalOpen] = useState(false);
const setOpen = onOpenChange || setInternalOpen;
const schema = (providedSchema ?? defaultSchema) as Schema;
const form = useForm<z.infer<Schema>>({
resolver: zodResolver(schema),
});
const [copied, setCopied] = useState(false);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(resourceName);
setCopied(true);
// Reset copied state after 2 seconds
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (err) {
console.error("Failed to copy: ", err);
}
};
const handleSubmit = async (values: z.infer<Schema>) => {
await onConfirm(values);
};
const defaultDescription = (
<>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{resourceName}</span>? You
can't reverse this.
</>
);
return (
<Dialog
open={controlledOpen !== undefined ? controlledOpen : internalOpen}
onOpenChange={setOpen}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{descriptionBody || defaultDescription}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
Type{" "}
<div
className="px-1 py-0.5 font-mono border rounded flex gap-1 items-center cursor-pointer hover:bg-muted/30 transition-colors"
onClick={copyToClipboard}
>
<code className="text-sm">{resourceName}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
copyToClipboard();
}}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
below
</div>
<FormField
control={form.control}
name={"confirmation" as FieldPath<z.infer<Schema>>}
render={({ field, formState }) => (
<FormItem>
<FormControl>
<div className="relative">
<Input placeholder={`${resourceName}`} {...field} />
</div>
</FormControl>
{formState.errors.confirmation ? (
<FormMessage />
) : (
<FormDescription className="text-transparent">
.
</FormDescription>
)}
</FormItem>
)}
/>
</div>
{children}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={isLoading}>
{isLoading ? "Deleting..." : confirmLabel}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default DeleteResource;
+3 -3
View File
@@ -11,14 +11,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
); );
} },
); );
Input.displayName = "Input"; Input.displayName = "Input";