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:
co-authored by
CarinaWolli
parent
17ab088af8
commit
5a59bb86cd
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+131
@@ -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>
|
||||
);
|
||||
}
|
||||
+173
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user