Allow selecting problems (#1568)
Fixes #672 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Add selectable problem rows with Select all/Clear all and Fix N selected, and update tests to cover selection behavior. > > - **UI (Problems panel)**: > - Add checkbox selection for each problem row (`ProblemItem`) with row click-to-toggle, `data-testid="problem-row"`, and accessibility attributes. > - Introduce selection state in `_Problems` with auto-select-all on report load; provide Select all / Clear all controls. > - Change Fix button to operate on selected problems only, showing dynamic label `Fix N problem(s)` and disabled when none selected. > - Wire `RecheckButton` to clear selection before rechecking; minor hover style tweaks; add `Checkbox` component. > - **E2E Tests**: > - New test: selecting specific problems and fixing only selected; add snapshots for prompt content. > - Update manual edit tests (React/Vite, Next.js) to assert Fix button enabled/disabled and counts; remove old ARIA snapshots. > - Minor import addition for `Timeout` and related expectations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8324e26f9d2d265e7e0d1f1b7538e2a8db40f674. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { test, testSkipIfWindows } from "./helpers/test_helper";
|
import { test, testSkipIfWindows, Timeout } from "./helpers/test_helper";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -83,6 +83,72 @@ export default App;
|
|||||||
await po.snapshotMessages({ replaceDumpPath: true });
|
await po.snapshotMessages({ replaceDumpPath: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testSkipIfWindows(
|
||||||
|
"problems - select specific problems and fix",
|
||||||
|
async ({ po }) => {
|
||||||
|
await po.setUp();
|
||||||
|
await po.importApp(MINIMAL_APP);
|
||||||
|
|
||||||
|
// Create multiple TS errors in one file
|
||||||
|
const appPath = await po.getCurrentAppPath();
|
||||||
|
const badFilePath = path.join(appPath, "src", "bad-file.tsx");
|
||||||
|
fs.writeFileSync(
|
||||||
|
badFilePath,
|
||||||
|
`const App = () => <div>Minimal imported app</div>;
|
||||||
|
nonExistentFunction1();
|
||||||
|
nonExistentFunction2();
|
||||||
|
nonExistentFunction3();
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await po.ensurePnpmInstall();
|
||||||
|
|
||||||
|
// Trigger creation of problems and open problems panel
|
||||||
|
// await po.sendPrompt("tc=create-ts-errors");
|
||||||
|
await po.selectPreviewMode("problems");
|
||||||
|
await po.clickRecheckProblems();
|
||||||
|
|
||||||
|
// Initially, all selected: button shows Fix X problems and Clear all is visible
|
||||||
|
const fixButton = po.page.getByTestId("fix-all-button");
|
||||||
|
await expect(fixButton).toBeVisible();
|
||||||
|
await expect(fixButton).toContainText(/Fix \d+ problems/);
|
||||||
|
|
||||||
|
// Click first two rows to toggle off (deselect)
|
||||||
|
const rows = po.page.getByTestId("problem-row");
|
||||||
|
const rowCount = await rows.count();
|
||||||
|
expect(rowCount).toBeGreaterThan(2);
|
||||||
|
await rows.nth(0).click();
|
||||||
|
await rows.nth(1).click();
|
||||||
|
|
||||||
|
// Button should update to reflect remaining selected
|
||||||
|
await expect(fixButton).toContainText(/Fix 1 problem/);
|
||||||
|
|
||||||
|
// Clear all should switch to Select all when none selected
|
||||||
|
// Deselect remaining rows
|
||||||
|
for (let i = 2; i < rowCount; i++) {
|
||||||
|
await rows.nth(i).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectButton = po.page.getByRole("button", {
|
||||||
|
name: /Select all/,
|
||||||
|
});
|
||||||
|
await expect(selectButton).toHaveText("Select all");
|
||||||
|
|
||||||
|
// Select all, then fix selected
|
||||||
|
await selectButton.click();
|
||||||
|
// Unselect the second row
|
||||||
|
await rows.nth(1).click();
|
||||||
|
await expect(fixButton).toContainText(/Fix 2 problems/);
|
||||||
|
|
||||||
|
await fixButton.click();
|
||||||
|
await po.waitForChatCompletion();
|
||||||
|
await po.snapshotServerDump("last-message");
|
||||||
|
await po.snapshotMessages({ replaceDumpPath: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testSkipIfWindows("problems - manual edit (react/vite)", async ({ po }) => {
|
testSkipIfWindows("problems - manual edit (react/vite)", async ({ po }) => {
|
||||||
await po.setUp({ enableAutoFixProblems: true });
|
await po.setUp({ enableAutoFixProblems: true });
|
||||||
await po.sendPrompt("tc=1");
|
await po.sendPrompt("tc=1");
|
||||||
@@ -101,13 +167,15 @@ export default App;
|
|||||||
await po.clickTogglePreviewPanel();
|
await po.clickTogglePreviewPanel();
|
||||||
|
|
||||||
await po.selectPreviewMode("problems");
|
await po.selectPreviewMode("problems");
|
||||||
await po.clickRecheckProblems();
|
const fixButton = po.page.getByTestId("fix-all-button");
|
||||||
await po.snapshotProblemsPane();
|
await expect(fixButton).toBeEnabled({ timeout: Timeout.LONG });
|
||||||
|
await expect(fixButton).toContainText(/Fix 1 problem/);
|
||||||
|
|
||||||
fs.unlinkSync(badFilePath);
|
fs.unlinkSync(badFilePath);
|
||||||
|
|
||||||
await po.clickRecheckProblems();
|
await po.clickRecheckProblems();
|
||||||
await po.snapshotProblemsPane();
|
await expect(fixButton).toBeDisabled({ timeout: Timeout.LONG });
|
||||||
|
await expect(fixButton).toContainText(/Fix 0 problems/);
|
||||||
});
|
});
|
||||||
|
|
||||||
testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => {
|
testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => {
|
||||||
@@ -129,11 +197,13 @@ testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => {
|
|||||||
await po.clickTogglePreviewPanel();
|
await po.clickTogglePreviewPanel();
|
||||||
|
|
||||||
await po.selectPreviewMode("problems");
|
await po.selectPreviewMode("problems");
|
||||||
await po.clickRecheckProblems();
|
const fixButton = po.page.getByTestId("fix-all-button");
|
||||||
await po.snapshotProblemsPane();
|
await expect(fixButton).toBeEnabled({ timeout: Timeout.LONG });
|
||||||
|
await expect(fixButton).toContainText(/Fix 1 problem/);
|
||||||
|
|
||||||
fs.unlinkSync(badFilePath);
|
fs.unlinkSync(badFilePath);
|
||||||
|
|
||||||
await po.clickRecheckProblems();
|
await po.clickRecheckProblems();
|
||||||
await po.snapshotProblemsPane();
|
await expect(fixButton).toBeDisabled({ timeout: Timeout.LONG });
|
||||||
|
await expect(fixButton).toContainText(/Fix 0 problems/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
- img
|
|
||||||
- text: 1 error
|
|
||||||
- button "Run checks":
|
|
||||||
- img
|
|
||||||
- button "Fix All":
|
|
||||||
- img
|
|
||||||
- img
|
|
||||||
- img
|
|
||||||
- text: src/bad-file.tsx 2:3
|
|
||||||
- paragraph: Cannot find name 'nonExistentFunction'.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- paragraph: No problems found
|
|
||||||
- img
|
|
||||||
- button "Run checks":
|
|
||||||
- img
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
- img
|
|
||||||
- text: 1 error
|
|
||||||
- button "Run checks":
|
|
||||||
- img
|
|
||||||
- button "Fix All":
|
|
||||||
- img
|
|
||||||
- img
|
|
||||||
- img
|
|
||||||
- text: src/bad-file.tsx 2:1
|
|
||||||
- paragraph: Cannot find name 'nonExistentFunction'.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
- paragraph: No problems found
|
|
||||||
- img
|
|
||||||
- button "Run checks":
|
|
||||||
- img
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
- paragraph: "Fix these 2 TypeScript compile-time errors:"
|
||||||
|
- list:
|
||||||
|
- listitem: src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304)
|
||||||
|
- code: const App = () => <div>Minimal imported app</div>; nonExistentFunction1(); // <-- TypeScript compiler error here nonExistentFunction2();
|
||||||
|
- list:
|
||||||
|
- listitem: src/bad-file.tsx:4:1 - Cannot find name 'nonExistentFunction3'. (TS2304)
|
||||||
|
- code: nonExistentFunction2(); nonExistentFunction3(); // <-- TypeScript compiler error here
|
||||||
|
- paragraph: Please fix all errors in a concise way.
|
||||||
|
- img
|
||||||
|
- text: bad-file.ts
|
||||||
|
- button "Edit":
|
||||||
|
- img
|
||||||
|
- img
|
||||||
|
- text: "src/bad-file.ts Summary: Fix 2 errors and introduce a new error."
|
||||||
|
- paragraph: "[[dyad-dump-path=*]]"
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- img
|
||||||
|
- text: less than a minute ago
|
||||||
|
- button "Retry":
|
||||||
|
- img
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
===
|
||||||
|
role: user
|
||||||
|
message: Fix these 2 TypeScript compile-time errors:
|
||||||
|
|
||||||
|
1. src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304)
|
||||||
|
```
|
||||||
|
const App = () => <div>Minimal imported app</div>;
|
||||||
|
nonExistentFunction1(); // <-- TypeScript compiler error here
|
||||||
|
nonExistentFunction2();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. src/bad-file.tsx:4:1 - Cannot find name 'nonExistentFunction3'. (TS2304)
|
||||||
|
```
|
||||||
|
nonExistentFunction2();
|
||||||
|
nonExistentFunction3(); // <-- TypeScript compiler error here
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
Please fix all errors in a concise way.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Problem, ProblemReport } from "@/ipc/ipc_types";
|
import { Problem, ProblemReport } from "@/ipc/ipc_types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||||
@@ -20,11 +21,26 @@ import { showError } from "@/lib/toast";
|
|||||||
|
|
||||||
interface ProblemItemProps {
|
interface ProblemItemProps {
|
||||||
problem: Problem;
|
problem: Problem;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProblemItem = ({ problem }: ProblemItemProps) => {
|
const ProblemItem = ({ problem, checked, onToggle }: ProblemItemProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 p-3 border-b border-border hover:bg-[var(--background-darkest)] transition-colors">
|
<div
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="cursor-pointer flex items-start gap-3 p-3 border-b border-border hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lightest)] transition-colors"
|
||||||
|
data-testid="problem-row"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-0.5"
|
||||||
|
aria-label="Select problem"
|
||||||
|
/>
|
||||||
<div className="flex-shrink-0 mt-0.5">
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
<XCircle size={16} className="text-red-500" />
|
<XCircle size={16} className="text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -56,6 +72,7 @@ interface RecheckButtonProps {
|
|||||||
| "ghost"
|
| "ghost"
|
||||||
| "link";
|
| "link";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
onBeforeRecheck?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RecheckButton = ({
|
const RecheckButton = ({
|
||||||
@@ -63,11 +80,15 @@ const RecheckButton = ({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
className = "h-7 px-3 text-xs",
|
className = "h-7 px-3 text-xs",
|
||||||
|
onBeforeRecheck,
|
||||||
}: RecheckButtonProps) => {
|
}: RecheckButtonProps) => {
|
||||||
const { checkProblems, isChecking } = useCheckProblems(appId);
|
const { checkProblems, isChecking } = useCheckProblems(appId);
|
||||||
const [showingFeedback, setShowingFeedback] = useState(false);
|
const [showingFeedback, setShowingFeedback] = useState(false);
|
||||||
|
|
||||||
const handleRecheck = async () => {
|
const handleRecheck = async () => {
|
||||||
|
if (onBeforeRecheck) {
|
||||||
|
onBeforeRecheck();
|
||||||
|
}
|
||||||
setShowingFeedback(true);
|
setShowingFeedback(true);
|
||||||
|
|
||||||
const res = await checkProblems();
|
const res = await checkProblems();
|
||||||
@@ -102,23 +123,24 @@ const RecheckButton = ({
|
|||||||
interface ProblemsSummaryProps {
|
interface ProblemsSummaryProps {
|
||||||
problemReport: ProblemReport;
|
problemReport: ProblemReport;
|
||||||
appId: number;
|
appId: number;
|
||||||
|
selectedCount: number;
|
||||||
|
onClearAll: () => void;
|
||||||
|
onFixSelected: () => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => {
|
const ProblemsSummary = ({
|
||||||
const { streamMessage } = useStreamChat();
|
problemReport,
|
||||||
|
appId,
|
||||||
|
selectedCount,
|
||||||
|
onClearAll,
|
||||||
|
onFixSelected,
|
||||||
|
onSelectAll,
|
||||||
|
}: ProblemsSummaryProps) => {
|
||||||
const { problems } = problemReport;
|
const { problems } = problemReport;
|
||||||
const totalErrors = problems.length;
|
const totalErrors = problems.length;
|
||||||
const [selectedChatId] = useAtom(selectedChatIdAtom);
|
|
||||||
|
|
||||||
const handleFixAll = () => {
|
// Keep stream hook mounted; actual fix action is provided via onFixSelected
|
||||||
if (!selectedChatId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
streamMessage({
|
|
||||||
prompt: createProblemFixPrompt(problemReport),
|
|
||||||
chatId: selectedChatId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (problems.length === 0) {
|
if (problems.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -148,16 +170,36 @@ const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RecheckButton appId={appId} />
|
<RecheckButton appId={appId} onBeforeRecheck={onClearAll} />
|
||||||
|
{selectedCount === 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onSelectAll}
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
Select all
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleFixAll}
|
onClick={onFixSelected}
|
||||||
className="h-7 px-3 text-xs"
|
className="h-7 px-3 text-xs"
|
||||||
data-testid="fix-all-button"
|
data-testid="fix-all-button"
|
||||||
|
disabled={selectedCount === 0}
|
||||||
>
|
>
|
||||||
<Wrench size={14} className="mr-1" />
|
<Wrench size={14} className="mr-1" />
|
||||||
Fix All
|
{`Fix ${selectedCount} ${selectedCount === 1 ? "problem" : "problems"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +217,20 @@ export function Problems() {
|
|||||||
export function _Problems() {
|
export function _Problems() {
|
||||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||||
const { problemReport } = useCheckProblems(selectedAppId);
|
const { problemReport } = useCheckProblems(selectedAppId);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
const problemKey = (p: Problem) =>
|
||||||
|
`${p.file}:${p.line}:${p.column}:${p.code}`;
|
||||||
|
const { streamMessage } = useStreamChat();
|
||||||
|
const [selectedChatId] = useAtom(selectedChatIdAtom);
|
||||||
|
|
||||||
|
// Whenever the problems pane is shown or the report updates, select all problems
|
||||||
|
useEffect(() => {
|
||||||
|
if (problemReport?.problems?.length) {
|
||||||
|
setSelectedKeys(new Set(problemReport.problems.map(problemKey)));
|
||||||
|
} else {
|
||||||
|
setSelectedKeys(new Set());
|
||||||
|
}
|
||||||
|
}, [problemReport]);
|
||||||
|
|
||||||
if (!selectedAppId) {
|
if (!selectedAppId) {
|
||||||
return (
|
return (
|
||||||
@@ -200,21 +256,65 @@ export function _Problems() {
|
|||||||
<p className="text-sm text-muted-foreground max-w-md mb-4">
|
<p className="text-sm text-muted-foreground max-w-md mb-4">
|
||||||
Run checks to scan your app for TypeScript errors and other problems.
|
Run checks to scan your app for TypeScript errors and other problems.
|
||||||
</p>
|
</p>
|
||||||
<RecheckButton appId={selectedAppId} />
|
<RecheckButton
|
||||||
|
appId={selectedAppId}
|
||||||
|
onBeforeRecheck={() => setSelectedKeys(new Set())}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ProblemsSummary problemReport={problemReport} appId={selectedAppId} />
|
<ProblemsSummary
|
||||||
|
problemReport={problemReport}
|
||||||
|
appId={selectedAppId}
|
||||||
|
selectedCount={
|
||||||
|
[...selectedKeys].filter((key) =>
|
||||||
|
problemReport.problems.some((p) => problemKey(p) === key),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
onClearAll={() => setSelectedKeys(new Set())}
|
||||||
|
onSelectAll={() =>
|
||||||
|
setSelectedKeys(
|
||||||
|
new Set(problemReport.problems.map((p) => problemKey(p))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onFixSelected={() => {
|
||||||
|
if (!selectedChatId) return;
|
||||||
|
const selectedProblems = problemReport.problems.filter((p) =>
|
||||||
|
selectedKeys.has(problemKey(p)),
|
||||||
|
);
|
||||||
|
const subsetReport: ProblemReport = { problems: selectedProblems };
|
||||||
|
streamMessage({
|
||||||
|
prompt: createProblemFixPrompt(subsetReport),
|
||||||
|
chatId: selectedChatId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{problemReport.problems.map((problem, index) => (
|
{problemReport.problems.map((problem) => {
|
||||||
<ProblemItem
|
const selKey = problemKey(problem);
|
||||||
key={`${problem.file}-${problem.line}-${problem.column}-${index}`}
|
const checked = selectedKeys.has(selKey);
|
||||||
problem={problem}
|
return (
|
||||||
/>
|
<ProblemItem
|
||||||
))}
|
key={selKey}
|
||||||
|
problem={problem}
|
||||||
|
checked={checked}
|
||||||
|
onToggle={() => {
|
||||||
|
setSelectedKeys((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(selKey)) {
|
||||||
|
next.delete(selKey);
|
||||||
|
} else {
|
||||||
|
next.add(selKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user