Merge pull request #1605 from useautumn/charlie/migration-ui-cleanup
chore: cleanup migration ui
This commit is contained in:
@@ -96,6 +96,7 @@ export const useMigrationsQuery = () => {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
only?: string[];
|
only?: string[];
|
||||||
concurrency?: number;
|
concurrency?: number;
|
||||||
|
lazy_run?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await axiosInstance.post<{
|
const { data } = await axiosInstance.post<{
|
||||||
migration_id: string;
|
migration_id: string;
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ export function MigrationListTable() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasRows = table.getRowModel().rows.length > 0;
|
|
||||||
|
|
||||||
const getRowHref = (row: Migration) =>
|
const getRowHref = (row: Migration) =>
|
||||||
pushPage({ path: `/migrations/${row.id}` });
|
pushPage({ path: `/migrations/${row.id}` });
|
||||||
|
|
||||||
if (!isLoading && !hasRows) {
|
if (!isLoading && migrations.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
type="migrations"
|
type="migrations"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function useRealtimeSubscriptions({
|
|||||||
dry_run: dryRun,
|
dry_run: dryRun,
|
||||||
limit,
|
limit,
|
||||||
only,
|
only,
|
||||||
|
lazy_run: true,
|
||||||
});
|
});
|
||||||
if (result.trigger_run_id && result.public_access_token) {
|
if (result.trigger_run_id && result.public_access_token) {
|
||||||
setSubscriptions((prev) => [
|
setSubscriptions((prev) => [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
CaretLeftIcon,
|
CaretLeftIcon,
|
||||||
CaretRightIcon,
|
CaretRightIcon,
|
||||||
|
CheckIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
ListMagnifyingGlassIcon,
|
ListMagnifyingGlassIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
@@ -23,6 +24,7 @@ import { Table } from "@/components/general/table";
|
|||||||
import { Badge } from "@/components/v2/badges/Badge";
|
import { Badge } from "@/components/v2/badges/Badge";
|
||||||
import { Button } from "@/components/v2/buttons/Button";
|
import { Button } from "@/components/v2/buttons/Button";
|
||||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||||
|
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -165,6 +167,13 @@ export function MigrationLiveView({
|
|||||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||||
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
|
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
|
||||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||||
|
const [sample, setSample] = useState({
|
||||||
|
open: false,
|
||||||
|
mode: "limit" as "limit" | "select",
|
||||||
|
limit: "10",
|
||||||
|
customerIds: [] as string[],
|
||||||
|
running: null as "dry" | "live" | null,
|
||||||
|
});
|
||||||
const { cancelRun, isCanceling } = useMigrationsQuery();
|
const { cancelRun, isCanceling } = useMigrationsQuery();
|
||||||
|
|
||||||
const debouncedSetSearch = useMemo(
|
const debouncedSetSearch = useMemo(
|
||||||
@@ -384,7 +393,7 @@ export function MigrationLiveView({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="default"
|
size="default"
|
||||||
className="rounded-r-none"
|
className="rounded-r-none border-r-0"
|
||||||
onClick={() => setIsRunDialogOpen(true)}
|
onClick={() => setIsRunDialogOpen(true)}
|
||||||
isLoading={isRunning}
|
isLoading={isRunning}
|
||||||
>
|
>
|
||||||
@@ -407,10 +416,10 @@ export function MigrationLiveView({
|
|||||||
Dry Run All
|
Dry Run All
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => triggerRun({ dryRun: false, limit: 10 })}
|
onClick={() => setSample((s) => ({ ...s, open: true }))}
|
||||||
>
|
>
|
||||||
<PlayIcon size={14} weight="fill" />
|
<PlayIcon size={14} weight="fill" />
|
||||||
Sample (10)
|
Run Sample
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -502,6 +511,167 @@ export function MigrationLiveView({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={sample.open}
|
||||||
|
onOpenChange={(open) => setSample((s) => ({ ...s, open }))}
|
||||||
|
>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Run Sample</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Run the migration on a subset of customers.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex border-b border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSample((s) => ({ ...s, mode: "limit" }))
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||||
|
sample.mode === "limit"
|
||||||
|
? "border-primary text-t1"
|
||||||
|
: "border-transparent text-t3 hover:text-t2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
By count
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSample((s) => ({ ...s, mode: "select" }))
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 pb-2 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||||
|
sample.mode === "select"
|
||||||
|
? "border-primary text-t1"
|
||||||
|
: "border-transparent text-t3 hover:text-t2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Select customers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[220px]">
|
||||||
|
{sample.mode === "limit" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-t3">
|
||||||
|
Number of customers
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={sample.limit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSample((s) => ({
|
||||||
|
...s,
|
||||||
|
limit: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
<SampleCustomerPreview
|
||||||
|
customers={enrichedCustomers}
|
||||||
|
limit={Number(sample.limit) || 0}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
{Math.min(
|
||||||
|
Number(sample.limit) || 0,
|
||||||
|
enrichedCustomers.filter((c) => !c._event).length,
|
||||||
|
)}{" "}
|
||||||
|
customers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-t3">
|
||||||
|
Select customers to run
|
||||||
|
</label>
|
||||||
|
<SampleCustomerPicker
|
||||||
|
customers={enrichedCustomers}
|
||||||
|
selectedIds={sample.customerIds}
|
||||||
|
onChange={(ids) =>
|
||||||
|
setSample((s) => ({ ...s, customerIds: ids }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="sm:flex-col gap-2">
|
||||||
|
<ShortcutButton
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
isLoading={sample.running === "dry"}
|
||||||
|
disabled={
|
||||||
|
sample.running !== null ||
|
||||||
|
(sample.mode === "limit"
|
||||||
|
? !sample.limit || Number(sample.limit) < 1
|
||||||
|
: sample.customerIds.length === 0)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setSample((s) => ({ ...s, running: "dry" }));
|
||||||
|
if (sample.mode === "limit") {
|
||||||
|
const topIds = enrichedCustomers
|
||||||
|
.filter((c) => !c._event)
|
||||||
|
.slice(0, Number(sample.limit))
|
||||||
|
.map((c) => c.id ?? c.internal_id);
|
||||||
|
await triggerRun({
|
||||||
|
dryRun: true,
|
||||||
|
only: topIds,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await triggerRun({
|
||||||
|
dryRun: true,
|
||||||
|
only: sample.customerIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSample((s) => ({ ...s, running: null, open: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EyeIcon size={14} />
|
||||||
|
Dry Run{" "}
|
||||||
|
{sample.mode === "limit"
|
||||||
|
? `(${sample.limit || 0})`
|
||||||
|
: `(${sample.customerIds.length})`}
|
||||||
|
</ShortcutButton>
|
||||||
|
<ShortcutButton
|
||||||
|
className="w-full"
|
||||||
|
metaShortcut="enter"
|
||||||
|
isLoading={sample.running === "live"}
|
||||||
|
disabled={
|
||||||
|
sample.running !== null ||
|
||||||
|
(sample.mode === "limit"
|
||||||
|
? !sample.limit || Number(sample.limit) < 1
|
||||||
|
: sample.customerIds.length === 0)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setSample((s) => ({ ...s, running: "live" }));
|
||||||
|
if (sample.mode === "limit") {
|
||||||
|
await triggerRun({
|
||||||
|
dryRun: false,
|
||||||
|
limit: Number(sample.limit),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await triggerRun({
|
||||||
|
dryRun: false,
|
||||||
|
only: sample.customerIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSample((s) => ({ ...s, running: null, open: false }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayIcon size={14} weight="fill" />
|
||||||
|
Run Live{" "}
|
||||||
|
{sample.mode === "limit"
|
||||||
|
? `(${sample.limit || 0})`
|
||||||
|
: `(${sample.customerIds.length})`}
|
||||||
|
</ShortcutButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</StepIndicator>
|
</StepIndicator>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -596,3 +766,127 @@ export function MigrationLiveView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SampleCustomerPreview({
|
||||||
|
customers,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
customers: CustomerRow[];
|
||||||
|
limit: number;
|
||||||
|
}) {
|
||||||
|
const unrun = customers.filter((c) => !c._event);
|
||||||
|
const previewed = unrun.slice(0, limit);
|
||||||
|
if (limit === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="h-48 overflow-y-auto rounded-xl border border-border mt-1.5">
|
||||||
|
{previewed.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-t4">
|
||||||
|
No customers to preview
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
previewed.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.internal_id}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-t1">
|
||||||
|
{c.name || c.id || c.internal_id}
|
||||||
|
</span>
|
||||||
|
{c.email && (
|
||||||
|
<span className="text-xs text-t4 truncate max-w-32">
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SampleCustomerPicker({
|
||||||
|
customers,
|
||||||
|
selectedIds,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
customers: CustomerRow[];
|
||||||
|
selectedIds: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return customers;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return customers.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name?.toLowerCase().includes(q) ||
|
||||||
|
c.id?.toLowerCase().includes(q) ||
|
||||||
|
c.email?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [customers, search]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
onChange(
|
||||||
|
selectedIds.includes(id)
|
||||||
|
? selectedIds.filter((v) => v !== id)
|
||||||
|
: [...selectedIds, id],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search customers..."
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="h-48 overflow-y-auto rounded-xl border border-border">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-t4">
|
||||||
|
No customers found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => {
|
||||||
|
const isSelected = selectedIds.includes(c.id ?? c.internal_id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.internal_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.id ?? c.internal_id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-muted/50 cursor-pointer transition-colors",
|
||||||
|
isSelected && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-4 rounded border flex items-center justify-center shrink-0",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary"
|
||||||
|
: "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<CheckIcon size={10} className="text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate text-t1">
|
||||||
|
{c.name || c.id || c.internal_id}
|
||||||
|
</span>
|
||||||
|
{c.email && (
|
||||||
|
<span className="text-xs text-t4 truncate max-w-32">
|
||||||
|
{c.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-t3">
|
||||||
|
{selectedIds.length} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
PencilSimpleIcon,
|
PencilSimpleIcon,
|
||||||
|
PlusIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||||
import { ActionCard } from "../shared/ActionCard";
|
import { ActionCard } from "../shared/ActionCard";
|
||||||
|
import { DASHED_BUTTON_CLASS } from "../shared/AddButton";
|
||||||
import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks";
|
import { AutumnMark, StripeMark } from "../shared/BillingScopeMarks";
|
||||||
import { AddPlansSection } from "./AddPlanOpForm";
|
import { AddPlansSection } from "./AddPlanOpForm";
|
||||||
import { UpdatePlanOpForm } from "./UpdatePlanOpForm";
|
import { UpdatePlanOpForm } from "./UpdatePlanOpForm";
|
||||||
@@ -184,45 +186,41 @@ export function OperationsForm({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t mt-3 pt-3 flex flex-col gap-2">
|
<div className="border-t mt-3 pt-3">
|
||||||
<span className="text-sm font-medium text-t1">Add Operation</span>
|
<DropdownMenu>
|
||||||
<div className="flex gap-3">
|
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
|
||||||
<ActionCard
|
<PlusIcon size={10} />
|
||||||
icon={
|
Add Operation
|
||||||
<PencilSimpleIcon
|
</DropdownMenuTrigger>
|
||||||
size={20}
|
<DropdownMenuContent
|
||||||
weight="duotone"
|
align="start"
|
||||||
className="text-t3 shrink-0"
|
className="w-(--anchor-width)"
|
||||||
/>
|
>
|
||||||
}
|
<DropdownMenuItem
|
||||||
heading="Update Plan"
|
closeOnClick
|
||||||
subheading="Modify existing customer plans"
|
|
||||||
onClick={() =>
|
|
||||||
setOperations([...operations, DEFAULT_UPDATE_PLAN])
|
|
||||||
}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
{!hasAddPlans && (
|
|
||||||
<ActionCard
|
|
||||||
icon={
|
|
||||||
<PackageIcon
|
|
||||||
size={20}
|
|
||||||
weight="duotone"
|
|
||||||
className="text-t3 shrink-0"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
heading="Add Plan"
|
|
||||||
subheading="Assign a new plan to customers"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setOperations([
|
setOperations([...operations, DEFAULT_UPDATE_PLAN])
|
||||||
...operations,
|
|
||||||
{ type: "add_plan", plan_id: "" },
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
className="flex-1"
|
>
|
||||||
/>
|
<PencilSimpleIcon size={14} weight="duotone" />
|
||||||
)}
|
Update Plan
|
||||||
</div>
|
</DropdownMenuItem>
|
||||||
|
{!hasAddPlans && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
closeOnClick
|
||||||
|
onClick={() =>
|
||||||
|
setOperations([
|
||||||
|
...operations,
|
||||||
|
{ type: "add_plan", plan_id: "" },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PackageIcon size={14} weight="duotone" />
|
||||||
|
Add Plan
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,18 +42,21 @@ import {
|
|||||||
import { RemoveItemRows } from "./RemoveItemRows";
|
import { RemoveItemRows } from "./RemoveItemRows";
|
||||||
|
|
||||||
function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) {
|
function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) {
|
||||||
const { products } = useProductsQuery();
|
const { products } = useProductsQuery({ allVersions: true });
|
||||||
|
|
||||||
const targetPlanId =
|
const targetIds = extractPlanIds(planFilter.plan_id);
|
||||||
typeof planFilter.plan_id === "string" && planFilter.plan_id
|
const idSet = new Set(targetIds);
|
||||||
? planFilter.plan_id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const matchingProducts = targetPlanId
|
const matchingProducts =
|
||||||
? products.filter((p) => p.id === targetPlanId)
|
idSet.size > 0 ? products.filter((p) => idSet.has(p.id)) : [];
|
||||||
: [];
|
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
return matchingProducts
|
return matchingProducts
|
||||||
|
.filter((p) => {
|
||||||
|
if (seen.has(p.version)) return false;
|
||||||
|
seen.add(p.version);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
value: String(p.version),
|
value: String(p.version),
|
||||||
label: `v${p.version}`,
|
label: `v${p.version}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user