feat: blocklist table (#24459)

* feat: blocklist table

* feat: blocklist table

* refactor: feedback

* chore: add select

* UI improvements

* chore: remove un unsed

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Udit Takkar
2025-10-15 11:20:49 +00:00
committed by GitHub
co-authored by CarinaWolli
parent 17ab088af8
commit 5a59bb86cd
30 changed files with 2209 additions and 24 deletions
@@ -0,0 +1,207 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { DataTableWrapper, DataTableToolbar, useDataTable } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog";
import { showToast } from "@calcom/ui/components/toast";
import { BlocklistEntryDetailsSheet } from "./components/blocklist-entry-details-sheet";
import { CreateBlocklistEntryModal } from "./components/create-blocklist-entry-modal";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistTableProps {
permissions?: {
canRead: boolean;
canCreate: boolean;
canDelete: boolean;
};
}
export function BlocklistTable({ permissions }: BlocklistTableProps) {
const { t } = useLocale();
const { limit, offset, searchTerm } = useDataTable();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDetailsSheet, setShowDetailsSheet] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<BlocklistEntry | null>(null);
const [entryToDelete, setEntryToDelete] = useState<BlocklistEntry | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { data, isPending } = trpc.viewer.organizations.listWatchlistEntries.useQuery(
{
limit,
offset,
searchTerm,
},
{
placeholderData: keepPreviousData,
}
);
const utils = trpc.useUtils();
const deleteWatchlistEntry = trpc.viewer.organizations.deleteWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_deleted"), "success");
setShowDeleteDialog(false);
setEntryToDelete(null);
},
onError: (error) => {
showToast(error.message, "error");
},
});
const handleDelete = (entry: BlocklistEntry) => {
setEntryToDelete(entry);
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (entryToDelete) {
deleteWatchlistEntry.mutate({ id: entryToDelete.id });
}
};
const handleViewDetails = (entry: BlocklistEntry) => {
setSelectedEntry(entry);
setShowDetailsSheet(true);
};
const totalRowCount = data?.meta?.totalRowCount ?? 0;
const flatData = useMemo<BlocklistEntry[]>(() => data?.rows ?? [], [data]);
const columns = useMemo<ColumnDef<BlocklistEntry>[]>(
() => [
{
id: "value",
header: t("value"),
accessorKey: "value",
enableHiding: false,
cell: ({ row }) => <span className="text-emphasis">{row.original.value}</span>,
},
{
id: "type",
header: t("type"),
accessorKey: "type",
size: 100,
cell: ({ row }) => (
<Badge variant="blue">{row.original.type === "EMAIL" ? t("email") : t("domain")}</Badge>
),
},
{
id: "createdBy",
header: t("blocked_by"),
size: 180,
cell: ({ row }) => {
const audit = row.original.audits?.[0] as
| { changedByUserId: number | null }
| {
changedByUser?: { id: number; email: string; name: string | null } | undefined;
changedByUserId: number | null;
}
| undefined;
const email =
(audit && "changedByUser" in audit ? audit.changedByUser?.email : undefined) ?? undefined;
return <span className="text-default">{email ?? "—"}</span>;
},
},
{
id: "actions",
header: "",
size: 120,
enableHiding: false,
enableSorting: false,
enableResizing: false,
cell: ({ row }) => {
const entry = row.original;
return (
<div className="flex items-center justify-end">
<ButtonGroup combined containerProps={{ className: "border-default" }}>
<Button
color="secondary"
variant="icon"
StartIcon="eye"
onClick={() => handleViewDetails(entry)}
tooltip={t("view")}
/>
{permissions?.canDelete && (
<Button
color="destructive"
variant="icon"
StartIcon="trash"
onClick={() => handleDelete(entry)}
tooltip={t("delete")}
/>
)}
</ButtonGroup>
</div>
);
},
},
],
[t, permissions?.canDelete]
);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
pageCount: Math.ceil(totalRowCount / limit),
});
return (
<>
<DataTableWrapper
table={table}
isPending={isPending}
variant="default"
paginationMode="standard"
totalRowCount={totalRowCount}>
<div className="flex items-center justify-between">
<DataTableToolbar.SearchBar />
<div className="flex items-center gap-2">
{permissions?.canCreate && (
<Button color="primary" StartIcon="plus" onClick={() => setShowCreateModal(true)}>
{t("create_block_entry")}
</Button>
)}
</div>
</div>
</DataTableWrapper>
<CreateBlocklistEntryModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
<BlocklistEntryDetailsSheet
entry={selectedEntry}
isOpen={showDetailsSheet}
onClose={() => {
setShowDetailsSheet(false);
setSelectedEntry(null);
}}
/>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_blocklist_entry")}
confirmBtnText={t("delete")}
onConfirm={confirmDelete}>
{t("delete_blocklist_entry_confirmation", { value: entryToDelete?.value })}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,131 @@
"use client";
import { format } from "date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@calcom/ui/components/badge";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { SkeletonText } from "@calcom/ui/components/skeleton";
type BlocklistEntry = RouterOutputs["viewer"]["organizations"]["listWatchlistEntries"]["rows"][number];
interface BlocklistEntryDetailsSheetProps {
entry: BlocklistEntry | null;
isOpen: boolean;
onClose: () => void;
}
export function BlocklistEntryDetailsSheet({ entry, isOpen, onClose }: BlocklistEntryDetailsSheetProps) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.organizations.getWatchlistEntryDetails.useQuery(
{ id: entry?.id ?? "" },
{
enabled: !!entry?.id && isOpen,
}
);
const getActionVariant = (action: string) => {
switch (action) {
case "BLOCK":
return "red";
case "REPORT":
return "orange";
case "ALERT":
return "blue";
default:
return "gray";
}
};
return (
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>{t("blocklist_entry_details")}</SheetTitle>
</SheetHeader>
{isLoading ? (
<div className="mt-6 space-y-4">
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-10 w-full" />
<SkeletonText className="h-6 w-32" />
<SkeletonText className="h-20 w-full" />
</div>
) : data?.entry ? (
<div className="mt-6">
<div className="space-y-4">
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("type")}</label>
<Badge variant="blue" size="lg">
{data.entry.type === "EMAIL" ? t("email") : t("domain")}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("value")}</label>
<p className="text-emphasis text-base">{data.entry.value}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("action")}</label>
<Badge variant={getActionVariant(data.entry.action)} size="lg">
{t(data.entry.action.toLowerCase())}
</Badge>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("description")}</label>
<p className="text-default">{data.entry.description || t("no_description_provided")}</p>
</div>
<div>
<label className="text-default mb-1 block text-sm font-medium">{t("created_by")}</label>
<p className="text-default">{data.auditHistory[0]?.changedByUser?.email || "—"}</p>
</div>
</div>
<div className="border-subtle my-6 border-t" />
<div>
<h3 className="text-emphasis mb-4 font-semibold">{t("audit_history")}</h3>
{data.auditHistory.length > 0 ? (
<div className="space-y-0">
{data.auditHistory.map((audit, index) => (
<div
key={audit.id}
className={`py-3 ${
index !== data.auditHistory.length - 1 ? "border-subtle border-b" : ""
}`}>
<div className="mb-1 flex items-start justify-between">
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("by")}:</span>
<span className="text-default text-sm">{audit.changedByUser?.email || "—"}</span>
</div>
<span className="text-muted text-xs">
{format(new Date(audit.changedAt), "MMM d, h:mm a")}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted text-xs">{t("added")}:</span>
<span className="text-emphasis text-sm">{audit.value}</span>
</div>
</div>
))}
</div>
) : (
<p className="text-muted text-sm">{t("no_audit_history")}</p>
)}
</div>
</div>
) : (
<div className="text-muted mt-8 text-center">{t("blocklist_entry_not_found")}</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,173 @@
"use client";
import { Controller, useForm } from "react-hook-form";
import { domainWithAtRegex, emailRegex } from "@calcom/lib/emailSchema";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WatchlistType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Input, Select, Label, TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
interface CreateBlocklistEntryModalProps {
isOpen: boolean;
onClose: () => void;
}
interface FormData {
type: WatchlistType;
value: string;
description?: string;
}
export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEntryModalProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const {
control,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<FormData>({
defaultValues: {
type: WatchlistType.EMAIL,
value: "",
description: "",
},
});
const watchType = watch("type");
const createWatchlistEntry = trpc.viewer.organizations.createWatchlistEntry.useMutation({
onSuccess: async () => {
await utils.viewer.organizations.listWatchlistEntries.invalidate();
showToast(t("blocklist_entry_created"), "success");
onClose();
reset();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: FormData) => {
createWatchlistEntry.mutate({
type: data.type,
value: data.value,
description: data.description,
});
};
const validateValue = (value: string) => {
if (!value) return t("required");
if (watchType === WatchlistType.EMAIL) {
if (!emailRegex.test(value)) {
return t("invalid_email_address");
}
} else if (watchType === WatchlistType.DOMAIN) {
if (!domainWithAtRegex.test(value)) {
return t("invalid_domain_format");
}
}
return true;
};
const typeOptions = [
{ label: t("email"), value: WatchlistType.EMAIL },
{ label: t("domain"), value: WatchlistType.DOMAIN },
];
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent enableOverflow>
<DialogHeader title={t("create_block_entry")} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
<div>
<Label htmlFor="type" className="text-emphasis mb-2 block text-sm font-medium">
{t("what_would_you_like_to_block")} <span className="text-destructive">*</span>
</Label>
<Controller
name="type"
control={control}
rules={{ required: t("field_required") }}
render={({ field }) => (
<Select
{...field}
options={typeOptions}
onChange={(option) => {
if (option) {
field.onChange(option.value);
setValue("value", "");
}
}}
value={typeOptions.find((opt) => opt.value === field.value)}
/>
)}
/>
{errors.type && <p className="text-destructive mt-1 text-sm">{errors.type.message}</p>}
</div>
<div>
<Label htmlFor="value" className="text-emphasis mb-2 block text-sm font-medium">
{watchType === WatchlistType.EMAIL ? t("email_address") : t("domain_name")}{" "}
<span className="text-destructive">*</span>
</Label>
<Controller
name="value"
control={control}
rules={{
required: t("field_required"),
validate: validateValue,
}}
render={({ field }) => (
<Input
{...field}
placeholder={watchType === WatchlistType.EMAIL ? "user@example.com" : "@spammer.com"}
/>
)}
/>
{errors.value && <p className="text-destructive mt-1 text-sm">{errors.value.message}</p>}
</div>
<div>
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
</Label>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextArea {...field} placeholder={t("reason_for_adding_to_blocklist")} rows={3} />
)}
/>
</div>
</div>
<DialogFooter className="mt-6">
<Button
type="button"
color="secondary"
onClick={onClose}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={isSubmitting || createWatchlistEntry.isPending}
disabled={isSubmitting || createWatchlistEntry.isPending}>
{t("create_entry")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}