first commit
This commit is contained in:
572
e2e/tests/content-actions.spec.ts
Normal file
572
e2e/tests/content-actions.spec.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Content Actions E2E Tests
|
||||
*
|
||||
* Tests content lifecycle actions that go beyond basic CRUD:
|
||||
* - Schedule / unschedule for future publishing
|
||||
* - Duplicate content
|
||||
* - Soft delete (trash) and restore from trash
|
||||
* - Permanent delete
|
||||
* - Discard draft changes (revert to published version)
|
||||
*
|
||||
* Uses the seeded "posts" collection which supports drafts and revisions.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures";
|
||||
|
||||
// ---------- regex patterns ----------
|
||||
|
||||
const SCHEDULE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/schedule/;
|
||||
const DUPLICATE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/duplicate/;
|
||||
const DISCARD_DRAFT_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/discard-draft/;
|
||||
const RESTORE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/restore/;
|
||||
const PERMANENT_DELETE_API_PATTERN = /\/api\/content\/posts\/[A-Z0-9]+\/permanent/;
|
||||
|
||||
// Button/tab label patterns
|
||||
const TRASH_TAB_LABEL = /Trash/i;
|
||||
const RESTORE_LABEL = /Restore/i;
|
||||
const PERM_DELETE_LABEL = /Permanently delete/i;
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function apiHeaders(token: string, baseUrl: string) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-EmDash-Request": "1",
|
||||
Origin: baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a post via API and return its ID */
|
||||
async function createPost(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
title: string,
|
||||
slug: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/_emdash/api/content/posts`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ data: { title }, slug }),
|
||||
});
|
||||
const json: any = await res.json();
|
||||
return json.data?.item?.id ?? json.data?.id;
|
||||
}
|
||||
|
||||
/** Publish a post via API */
|
||||
async function publishPost(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/publish`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Soft-delete a post via API (move to trash) */
|
||||
async function trashPost(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clean up a post — trash then permanently delete, ignoring errors */
|
||||
async function cleanupPost(
|
||||
baseUrl: string,
|
||||
headers: Record<string, string>,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}).catch(() => {});
|
||||
await fetch(`${baseUrl}/_emdash/api/content/posts/${id}/permanent`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Schedule / Unschedule
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Schedule content", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
// Create a fresh draft post for scheduling tests
|
||||
postId = await createPost(
|
||||
baseUrl,
|
||||
headers,
|
||||
"Schedule Test Post",
|
||||
`schedule-test-${Date.now()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test("schedule a draft post for future publishing", async ({ admin, page }) => {
|
||||
await admin.goToEditContent("posts", postId);
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Verify we're on the edit page with our post
|
||||
await expect(page.locator("#field-title")).toHaveValue("Schedule Test Post");
|
||||
|
||||
// The "Schedule for later" button should be visible in the sidebar
|
||||
const scheduleButton = page.getByRole("button", { name: "Schedule for later" });
|
||||
await expect(scheduleButton).toBeVisible({ timeout: 5000 });
|
||||
await scheduleButton.click();
|
||||
|
||||
// A datetime input should appear
|
||||
const dateInput = page.getByLabel("Schedule for");
|
||||
await expect(dateInput).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Set a future date (tomorrow)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
const dateValue = tomorrow.toISOString().slice(0, 16); // datetime-local format
|
||||
await dateInput.fill(dateValue);
|
||||
|
||||
// Click the "Schedule" confirm button and wait for the API response
|
||||
const scheduleResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
SCHEDULE_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.getByRole("button", { name: "Schedule", exact: true }).click();
|
||||
await scheduleResponse;
|
||||
|
||||
// A toast confirming scheduling should appear
|
||||
await expect(page.getByRole("heading", { name: "Scheduled" })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The scheduled date info should be visible
|
||||
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// An "Unschedule" button should be visible
|
||||
await expect(page.getByRole("button", { name: "Unschedule" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("unschedule a scheduled post", async ({ admin, page }) => {
|
||||
// Schedule the post via API first
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}/schedule`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ scheduledAt: tomorrow.toISOString() }),
|
||||
});
|
||||
|
||||
await admin.goToEditContent("posts", postId);
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Verify scheduled state is shown
|
||||
await expect(page.locator("text=Scheduled for:")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click unschedule and wait for API response
|
||||
const unscheduleResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
SCHEDULE_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "DELETE" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await page.getByRole("button", { name: "Unschedule" }).click();
|
||||
await unscheduleResponse;
|
||||
|
||||
// The scheduled info should disappear
|
||||
await expect(page.locator("text=Scheduled for:")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The "Schedule for later" button should reappear
|
||||
await expect(page.getByRole("button", { name: "Schedule for later" })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Duplicate
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Duplicate content", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
let duplicateId: string | undefined;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
postId = await createPost(
|
||||
baseUrl,
|
||||
headers,
|
||||
"Duplicate Source Post",
|
||||
`dup-source-${Date.now()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPost(baseUrl, headers, postId);
|
||||
if (duplicateId) {
|
||||
await cleanupPost(baseUrl, headers, duplicateId);
|
||||
}
|
||||
});
|
||||
|
||||
test("duplicate a post from the content list", async ({ admin, page }) => {
|
||||
await admin.goToContent("posts");
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Find the row for our post and click the duplicate button
|
||||
const row = page.locator("tr", { hasText: "Duplicate Source Post" });
|
||||
await expect(row).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const duplicateResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
DUPLICATE_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "POST" &&
|
||||
(res.status() === 200 || res.status() === 201),
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await row.getByRole("button", { name: "Duplicate Duplicate Source Post" }).click();
|
||||
const response = await duplicateResponse;
|
||||
const body = await response.json();
|
||||
duplicateId = body.data?.item?.id ?? body.data?.id;
|
||||
|
||||
// Wait for the list to refresh
|
||||
await admin.waitForLoading();
|
||||
|
||||
// A copy should now appear in the list (typically with "(Copy)" suffix or similar)
|
||||
// Reload to ensure fresh data
|
||||
await page.reload();
|
||||
await admin.waitForShell();
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The duplicate should exist -- verify via API since the title pattern may vary
|
||||
expect(duplicateId).toBeTruthy();
|
||||
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${duplicateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(getRes.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Trash (soft delete) and Restore
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Trash and restore content", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
postId = await createPost(baseUrl, headers, "Trash Test Post", `trash-test-${Date.now()}`);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test("move a post to trash from the content list", async ({ admin, page }) => {
|
||||
await admin.goToContent("posts");
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Find the row and click the trash button
|
||||
const row = page.locator("tr", { hasText: "Trash Test Post" });
|
||||
await expect(row).toBeVisible({ timeout: 5000 });
|
||||
await row.getByRole("button", { name: "Move Trash Test Post to trash" }).click();
|
||||
|
||||
// A confirmation dialog should appear
|
||||
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(dialog.locator("text=Trash Test Post")).toBeVisible();
|
||||
|
||||
// Confirm the deletion
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/content/posts/${postId}`) &&
|
||||
res.request().method() === "DELETE" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Move to Trash" }).click();
|
||||
await deleteResponse;
|
||||
|
||||
// The dialog should close
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The post should no longer appear in the "All" tab
|
||||
await admin.waitForLoading();
|
||||
await expect(page.locator("tr", { hasText: "Trash Test Post" })).not.toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("restore a trashed post from the trash tab", async ({ admin, page }) => {
|
||||
// Trash the post via API first
|
||||
await trashPost(baseUrl, headers, postId);
|
||||
|
||||
await admin.goToContent("posts");
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Switch to the Trash tab
|
||||
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
|
||||
await expect(trashTab).toBeVisible({ timeout: 5000 });
|
||||
await trashTab.click();
|
||||
|
||||
// Wait for trashed items to load
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The trashed post should appear
|
||||
const trashedRow = page.locator("tr", { hasText: "Trash Test Post" });
|
||||
await expect(trashedRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the restore button
|
||||
const restoreResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
RESTORE_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await trashedRow.getByRole("button", { name: RESTORE_LABEL }).click();
|
||||
await restoreResponse;
|
||||
|
||||
// Wait for the list to refresh
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Switch back to the All tab
|
||||
const allTab = page.getByRole("tab", { name: "All" });
|
||||
await allTab.click();
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The post should be back in the main list
|
||||
await expect(page.locator("tr", { hasText: "Trash Test Post" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Permanent delete
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Permanent delete", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
postId = await createPost(baseUrl, headers, "Permanent Delete Post", `perm-del-${Date.now()}`);
|
||||
// Trash it first -- permanent delete only works on trashed items
|
||||
await trashPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test("permanently delete a trashed post", async ({ admin, page }) => {
|
||||
await admin.goToContent("posts");
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Switch to the Trash tab
|
||||
const trashTab = page.getByRole("tab", { name: TRASH_TAB_LABEL });
|
||||
await trashTab.click();
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The trashed post should appear
|
||||
const trashedRow = page.locator("tr", { hasText: "Permanent Delete Post" });
|
||||
await expect(trashedRow).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the permanent delete button (trash icon in trash view)
|
||||
await trashedRow.getByRole("button", { name: PERM_DELETE_LABEL }).click();
|
||||
|
||||
// A confirmation dialog should appear
|
||||
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Delete Permanently?" });
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(dialog.locator("text=Permanent Delete Post")).toBeVisible();
|
||||
|
||||
// Confirm permanent deletion
|
||||
const permanentDeleteResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
PERMANENT_DELETE_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "DELETE" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Delete Permanently" }).click();
|
||||
await permanentDeleteResponse;
|
||||
|
||||
// The dialog should close
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The post should disappear from the trash
|
||||
await admin.waitForLoading();
|
||||
await expect(page.locator("tr", { hasText: "Permanent Delete Post" })).not.toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify via API that the post is truly gone
|
||||
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Discard draft changes
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Discard draft changes", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
// Create and publish a post
|
||||
postId = await createPost(
|
||||
baseUrl,
|
||||
headers,
|
||||
"Published Original Title",
|
||||
`discard-draft-${Date.now()}`,
|
||||
);
|
||||
await publishPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test("discard draft reverts to the published version", async ({ admin, page }) => {
|
||||
// Navigate to the editor and make changes to create a draft
|
||||
await admin.goToEditContent("posts", postId);
|
||||
await admin.waitForLoading();
|
||||
|
||||
// Verify the published title
|
||||
const titleInput = page.locator("#field-title");
|
||||
await expect(titleInput).toHaveValue("Published Original Title");
|
||||
|
||||
// Edit the title
|
||||
await titleInput.fill("Draft Modified Title");
|
||||
|
||||
// Save to create a draft revision
|
||||
await admin.clickSave();
|
||||
await admin.waitForSaveComplete();
|
||||
|
||||
// The "Pending changes" badge should appear
|
||||
await expect(page.locator("text=Pending changes")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The "Discard changes" button should be visible
|
||||
const discardButton = page.getByRole("button", { name: "Discard changes" });
|
||||
await expect(discardButton).toBeVisible({ timeout: 5000 });
|
||||
await discardButton.click();
|
||||
|
||||
// A confirmation dialog should appear
|
||||
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Discard draft changes?" });
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Confirm discarding the draft
|
||||
const discardResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
DISCARD_DRAFT_API_PATTERN.test(res.url()) &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Discard changes" }).click();
|
||||
await discardResponse;
|
||||
|
||||
// Wait for the page to update
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The title should revert to the published version
|
||||
await expect(titleInput).toHaveValue("Published Original Title", { timeout: 10000 });
|
||||
|
||||
// The "Pending changes" badge should be gone
|
||||
await expect(page.locator("text=Pending changes")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// The "Discard changes" button should also be gone
|
||||
await expect(discardButton).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Trash from the editor
|
||||
// ==========================================================================
|
||||
|
||||
test.describe("Trash from editor", () => {
|
||||
let headers: Record<string, string>;
|
||||
let baseUrl: string;
|
||||
let postId: string;
|
||||
|
||||
test.beforeEach(async ({ admin, serverInfo }) => {
|
||||
await admin.devBypassAuth();
|
||||
baseUrl = serverInfo.baseUrl;
|
||||
headers = apiHeaders(serverInfo.token, baseUrl);
|
||||
|
||||
postId = await createPost(baseUrl, headers, "Editor Trash Post", `editor-trash-${Date.now()}`);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupPost(baseUrl, headers, postId);
|
||||
});
|
||||
|
||||
test("move a post to trash from the editor sidebar", async ({ admin, page }) => {
|
||||
await admin.goToEditContent("posts", postId);
|
||||
await admin.waitForLoading();
|
||||
|
||||
// The "Move to Trash" button should be in the sidebar
|
||||
const trashButton = page.getByRole("button", { name: "Move to Trash" });
|
||||
await expect(trashButton).toBeVisible({ timeout: 5000 });
|
||||
await trashButton.click();
|
||||
|
||||
// A confirmation dialog should appear
|
||||
const dialog = page.locator('[role="dialog"]').filter({ hasText: "Move to Trash?" });
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Confirm trashing
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/content/posts/${postId}`) &&
|
||||
res.request().method() === "DELETE" &&
|
||||
res.status() === 200,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await dialog.getByRole("button", { name: "Move to Trash" }).click();
|
||||
await deleteResponse;
|
||||
|
||||
// Should navigate back to the content list (or show a confirmation)
|
||||
// Verify the post is trashed via API
|
||||
const getRes = await fetch(`${baseUrl}/_emdash/api/content/posts/${postId}`, { headers });
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user