mirror of
https://github.com/zadam/trilium.git
synced 2026-09-12 19:50:20 +05:00
client/collections/board: add outline integration
This commit is contained in:
parent
3fba2c2088
commit
5bccc1e121
@ -71,6 +71,19 @@ export interface NoteContextDataMap {
|
||||
annotations: PdfAnnotationInfo[];
|
||||
scrollToAnnotation(annotationId: string, pageNumber: number): void;
|
||||
};
|
||||
/** Published by a board, so the right pane can list its columns and scroll the board to one. */
|
||||
boardColumns: {
|
||||
/** The columns in the order the board draws them. */
|
||||
columns: {
|
||||
/** What identifies the column, which is the value its cards carry. */
|
||||
value: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
/** How many cards it holds, counting what an active filter leaves in. */
|
||||
count: number;
|
||||
}[];
|
||||
scrollToColumn(column: string): void;
|
||||
};
|
||||
saveState: {
|
||||
state: SaveState;
|
||||
};
|
||||
|
||||
@ -3530,6 +3530,8 @@
|
||||
"sort": "Sort",
|
||||
"sort-manually": "Manually",
|
||||
"default-card-order": "Default card order",
|
||||
"columns-title": "Columns",
|
||||
"columns-empty": "This board has no columns yet.",
|
||||
"column-width": "Column width",
|
||||
"drop-as-first-item": "Drop as the first item",
|
||||
"drop-as-last-item": "Drop as the last item",
|
||||
|
||||
@ -2501,3 +2501,44 @@ describe("how wide the board draws its columns", () => {
|
||||
expect(removeLabel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the columns as the right pane lists them", () => {
|
||||
it("names each column, gives it its icon and counts the cards it holds", () => {
|
||||
const board = buildNote({ title: "Board" });
|
||||
const cards = new Map([
|
||||
[ "", [ { note: { noteId: "a" } }, { note: { noteId: "b" } } ] ],
|
||||
[ "To Do", [ { note: { noteId: "c" } } ] ]
|
||||
]) as unknown as ColumnMap;
|
||||
const { api } = createApi(
|
||||
{
|
||||
columns: [
|
||||
{ value: "", displayName: "Unsorted" },
|
||||
{ value: "To Do", icon: "bx bx-star" },
|
||||
{ value: "Done" }
|
||||
]
|
||||
},
|
||||
[ "", "To Do", "Done" ], board, "status", cards);
|
||||
|
||||
expect(api.getColumnOutline([ "", "To Do", "Done" ])).toEqual([
|
||||
{ value: "", title: "Unsorted", icon: "bx bxs-inbox", count: 2 },
|
||||
{ value: "To Do", title: "To Do", icon: "bx bx-star", count: 1 },
|
||||
// A column the board draws no cards for still stands, and counts none.
|
||||
{ value: "Done", title: "Done", icon: DEFAULT_COLUMN_ICON, count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
/** A relation board keys its columns by note id, which says nothing to a reader on its own. */
|
||||
it("names a relation board's columns by the notes they point at", () => {
|
||||
const target = buildNote({ title: "Alice", "#iconClass": "bx bx-user" });
|
||||
const board = buildNote({ title: "Board" });
|
||||
const { api } = createApi({}, [ "", target.noteId ], board, "~assignee");
|
||||
|
||||
expect(api.getColumnOutline([ "", target.noteId, "missing" ])).toEqual([
|
||||
{ value: "", title: "board_view.inbox", icon: "bx bxs-inbox", count: 0 },
|
||||
// The note's own icon, as `FNote` gives it, class and all.
|
||||
{ value: target.noteId, title: "Alice", icon: "tn-icon bx bx-user", count: 0 },
|
||||
// A note the cache has not got: the value is all there is to go on.
|
||||
{ value: "missing", title: "missing", icon: DEFAULT_COLUMN_ICON, count: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -601,6 +601,24 @@ export default class BoardApi {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns as something outside the board lists them: what each is called, the icon it
|
||||
* shows and how many cards it holds. The right pane's outline is drawn from this.
|
||||
*
|
||||
* A relation board keys its columns by note id and names them with the note's own title, which
|
||||
* is what its headings show; the id says nothing to a reader on its own.
|
||||
*/
|
||||
getColumnOutline(columns: string[]) {
|
||||
return columns.map(column => ({
|
||||
value: column,
|
||||
title: this.isRelationMode && column !== INBOX_COLUMN
|
||||
? froca.getNoteFromCache(column)?.title ?? column
|
||||
: this.getColumnTitle(column),
|
||||
icon: this.getColumnIcon(column) ?? DEFAULT_COLUMN_ICON,
|
||||
count: this.byColumn?.get(column)?.length ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon a column heading shows, for anything else that stands in for the column.
|
||||
*
|
||||
@ -608,7 +626,9 @@ export default class BoardApi {
|
||||
* `NoteLink` puts in the heading — and `setColumnIcon` is not offered there.
|
||||
*/
|
||||
getColumnIcon(column: string) {
|
||||
if (this.isRelationMode) {
|
||||
// The inbox stands for the cards carrying no value at all, so there is no note behind it
|
||||
// even on a relation board, where every other column is one.
|
||||
if (this.isRelationMode && column !== INBOX_COLUMN) {
|
||||
return froca.getNoteFromCache(column)?.getIcon();
|
||||
}
|
||||
|
||||
|
||||
@ -211,6 +211,14 @@ function columnIcons(container: HTMLElement) {
|
||||
.map(el => [ ...el.classList ].filter(name => name.startsWith("bx")).join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in note context, with the two calls every mounted board makes of one: it publishes its
|
||||
* columns into the context for the right pane to list, and clears them as it goes.
|
||||
*/
|
||||
function contextStub(context: object) {
|
||||
return { setContextData: () => {}, clearContextData: () => {}, ...context };
|
||||
}
|
||||
|
||||
function columnTitles(container: HTMLElement) {
|
||||
return [ ...container.querySelectorAll(".board-column h3 .title") ].map(el => el.textContent);
|
||||
}
|
||||
@ -795,7 +803,7 @@ describe("A board in a tab the reader is not looking at", () => {
|
||||
const otherTab = {};
|
||||
let shown: object = tab;
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { getMainContext: () => tab } });
|
||||
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
|
||||
const previousTabManager = appContext.tabManager;
|
||||
appContext.tabManager = { getActiveMainContext: () => shown } as never;
|
||||
|
||||
@ -862,7 +870,7 @@ describe("A board in a tab the reader is not looking at", () => {
|
||||
// One tab, and the board is in a pane of it that does not hold the focus.
|
||||
const tab = {};
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { getMainContext: () => tab } });
|
||||
Object.assign(host, { noteContext: contextStub({ getMainContext: () => tab }) });
|
||||
const previousTabManager = appContext.tabManager;
|
||||
appContext.tabManager = { getActiveMainContext: () => tab } as never;
|
||||
|
||||
@ -4234,7 +4242,7 @@ describe("Board properties from the note menu", () => {
|
||||
});
|
||||
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: { ntxId, isActive: () => true } });
|
||||
Object.assign(host, { noteContext: contextStub({ ntxId, isActive: () => true }) });
|
||||
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
@ -4415,3 +4423,89 @@ describe("how wide the board draws its columns", () => {
|
||||
return board;
|
||||
}
|
||||
});
|
||||
|
||||
describe("what the board hands the right pane", () => {
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
saved.length = 0;
|
||||
if (container) {
|
||||
render(null, container);
|
||||
container.remove();
|
||||
container = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane lists the columns of whichever board is on show, so the board publishes them into
|
||||
* the context it belongs to rather than the pane reading the board's own state.
|
||||
*/
|
||||
it("publishes its columns, scrolls to one on request, and takes them back", async () => {
|
||||
const published: { key: string, value: unknown }[] = [];
|
||||
const cleared: string[] = [];
|
||||
const note = buildNote({
|
||||
title: "Board",
|
||||
"#collection": "",
|
||||
"#viewType": "board",
|
||||
children: [
|
||||
{ title: "First", "#status": "To Do" },
|
||||
{ title: "Second", "#status": "Done" },
|
||||
{ title: "Third", "#status": "Done" }
|
||||
]
|
||||
});
|
||||
|
||||
const host = new Component();
|
||||
Object.assign(host, { noteContext: {
|
||||
setContextData: (key: string, value: unknown) => published.push({ key, value }),
|
||||
clearContextData: (key: string) => cleared.push(key)
|
||||
} });
|
||||
|
||||
const mountPoint = document.createElement("div");
|
||||
container = mountPoint;
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<ParentComponent.Provider value={host}>
|
||||
<Harness
|
||||
note={note}
|
||||
noteIds={[ ...note.getChildNoteIds() ]}
|
||||
initialConfig={{ columns: [ { value: "To Do" }, { value: "Done" } ] }}
|
||||
/>
|
||||
</ParentComponent.Provider>,
|
||||
mountPoint
|
||||
);
|
||||
});
|
||||
await act(async () => { await flush(); });
|
||||
|
||||
const outline = published.at(-1);
|
||||
expect(outline?.key).toBe("boardColumns");
|
||||
const { columns, scrollToColumn } = outline?.value as {
|
||||
columns: { value: string, title: string, count: number }[],
|
||||
scrollToColumn: (column: string) => void
|
||||
};
|
||||
expect(columns.map(({ title, count }) => `${title}:${count}`))
|
||||
.toEqual([ "To Do:1", "Done:2" ]);
|
||||
|
||||
// What a press on one of the pane's entries does. Both are recorded rather than watched
|
||||
// for: happy-dom scrolls nothing, and a dialog another spec left standing holds the focus.
|
||||
const scrolled: (string | undefined)[] = [];
|
||||
const focused: (string | undefined)[] = [];
|
||||
for (const element of mountPoint.querySelectorAll<HTMLElement>(".board-column")) {
|
||||
element.scrollIntoView = () => scrolled.push(element.dataset.column);
|
||||
const heading = element.querySelector<HTMLElement>("h3");
|
||||
if (heading) {
|
||||
heading.focus = () => focused.push(element.dataset.column);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToColumn("Done");
|
||||
expect(scrolled).toEqual([ "Done" ]);
|
||||
// The heading takes the focus with it, so the board's own keys carry on from there.
|
||||
expect(focused).toEqual([ "Done" ]);
|
||||
|
||||
// A board that is no longer on show leaves nothing behind for the pane to list.
|
||||
act(() => { render(null, mountPoint); });
|
||||
expect(cleared).toContain("boardColumns");
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,7 +32,7 @@ import FormTextArea from "../../react/FormTextArea";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import {
|
||||
useContextualShortcutHints, useNoteContext, useNoteLabelBoolean, useNoteLabelWithDefault,
|
||||
useNoteTypeOptions, useTrackedElement, useTriliumEvent
|
||||
useNoteTypeOptions, useSetContextData, useTrackedElement, useTriliumEvent
|
||||
} from "../../react/hooks";
|
||||
import Icon from "../../react/Icon";
|
||||
import NoteAutocomplete from "../../react/NoteAutocomplete";
|
||||
@ -631,6 +631,24 @@ export default function BoardView({
|
||||
/** Until when a column move can still be settling, which is when `useFlip` slides columns. */
|
||||
const columnMovedUntil = useRef(0);
|
||||
|
||||
// What the right pane lists the board as, and what a press on one of its entries does.
|
||||
//
|
||||
// Held still between renders: the pane redraws its list whenever this changes identity, and a
|
||||
// board renders on every step of a drag. `storedColumns` is among what it is held against
|
||||
// because the api reads the config in place, so nothing else here changes when it does.
|
||||
const outline = useMemo(() => ({
|
||||
columns: api.getColumnOutline(shownColumns),
|
||||
scrollToColumn: (column: string) => {
|
||||
const element = columnElement(containerRef.current, column);
|
||||
element?.scrollIntoView({ inline: "start", block: "nearest", behavior: "smooth" });
|
||||
// The heading takes the focus, so the board's own keys carry on from the column the
|
||||
// reader picked. Scrolled first, and without moving anything itself: focus landing on
|
||||
// its own would jump the board to the column the scroll is already easing towards.
|
||||
element?.querySelector<HTMLElement>("h3")?.focus({ preventScroll: true });
|
||||
}
|
||||
}), [ api, shownColumns, byColumn, storedColumns, isInRelationMode ]);
|
||||
useSetContextData(noteContext, "boardColumns", outline);
|
||||
|
||||
// Neither the creation dates the tie-break needs nor the targets of a sorted relation come
|
||||
// with the board. Both are fetched here, and `sortRevision` redraws it once they land.
|
||||
useEffect(() => {
|
||||
@ -970,13 +988,8 @@ export default function BoardView({
|
||||
if (!isMobile()) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const columns = containerRef.current?.querySelectorAll<HTMLElement>(".board-column");
|
||||
for (const element of columns ?? []) {
|
||||
if (element.dataset.column === column) {
|
||||
element.scrollIntoView({ inline: "center", block: "nearest" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
columnElement(containerRef.current, column)
|
||||
?.scrollIntoView({ inline: "center", block: "nearest" });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -1415,6 +1428,15 @@ function closeGaps(container: HTMLElement | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The element a column is drawn in, for the two things that scroll the board to one. */
|
||||
function columnElement(container: HTMLElement | null, column: string) {
|
||||
for (const element of container?.querySelectorAll<HTMLElement>(".board-column") ?? []) {
|
||||
if (element.dataset.column === column) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findRefreshReason(loadResults: LoadResults, statusAttribute: string, noteIds: string[], parentNoteId: string): string | null {
|
||||
// A card moved between columns.
|
||||
if (loadResults.getAttributeRows().some(attr => attr.name === statusAttribute && noteIds.includes(attr.noteId ?? ""))) {
|
||||
|
||||
30
apps/client/src/widgets/sidebar/BoardColumns.css
Normal file
30
apps/client/src/widgets/sidebar/BoardColumns.css
Normal file
@ -0,0 +1,30 @@
|
||||
.board-columns-list ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.25em 0.4em;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-entry:hover {
|
||||
background-color: var(--hover-item-background-color);
|
||||
}
|
||||
|
||||
.board-columns-list .board-column-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.board-columns-list .no-columns {
|
||||
padding: 0.4em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
83
apps/client/src/widgets/sidebar/BoardColumns.spec.tsx
Normal file
83
apps/client/src/widgets/sidebar/BoardColumns.spec.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NoteContextDataMap } from "../../components/note_context";
|
||||
import options from "../../services/options";
|
||||
import BoardColumns from "./BoardColumns";
|
||||
|
||||
// The board publishes its columns through the note context, which needs the whole app around it,
|
||||
// so the panel is handed them directly here.
|
||||
const shown = vi.hoisted(() => ({
|
||||
board: null as NoteContextDataMap["boardColumns"] | null
|
||||
}));
|
||||
vi.mock("../../services/i18n", () => ({ t: (key: string) => key }));
|
||||
vi.mock("../react/hooks", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../react/hooks")>()),
|
||||
useGetContextData: () => shown.board
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// The widget stands in a RightPanelWidget, which reads which panels the user collapsed.
|
||||
options.set("rightPaneCollapsedItems", JSON.stringify([]));
|
||||
shown.board = {
|
||||
columns: [
|
||||
{ value: "", title: "Inbox", icon: "bx bxs-inbox", count: 2 },
|
||||
{ value: "To Do", title: "To Do", icon: "bx bx-circle", count: 7 },
|
||||
{ value: "Done", title: "Done", icon: "bx bx-check", count: 0 }
|
||||
],
|
||||
scrollToColumn: vi.fn()
|
||||
};
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => render(null, container));
|
||||
container.remove();
|
||||
shown.board = null;
|
||||
});
|
||||
|
||||
describe("BoardColumns", () => {
|
||||
it("lists the columns in the board's own order, each with its icon and how many it holds", () => {
|
||||
renderPanel();
|
||||
|
||||
const entries = [ ...container.querySelectorAll(".board-column-entry") ];
|
||||
expect(entries.map((entry) => entry.querySelector(".board-column-name")?.textContent))
|
||||
.toEqual([ "Inbox", "To Do", "Done" ]);
|
||||
expect(entries.map((entry) => entry.querySelector(".badge")?.textContent))
|
||||
.toEqual([ "2", "7", "0" ]);
|
||||
expect(entries[1].querySelector("span.bx-circle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks the board to scroll to the column that was pressed, by mouse or by keyboard", () => {
|
||||
renderPanel();
|
||||
const entries = [ ...container.querySelectorAll<HTMLElement>(".board-column-entry") ];
|
||||
|
||||
entries[2].click();
|
||||
expect(shown.board?.scrollToColumn).toHaveBeenCalledWith("Done");
|
||||
|
||||
act(() => {
|
||||
entries[0].dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
// The inbox is named by the empty value, which is what it is identified by throughout.
|
||||
expect(shown.board?.scrollToColumn).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
|
||||
it("says so for a board with no columns at all", () => {
|
||||
shown.board = { columns: [], scrollToColumn: vi.fn() };
|
||||
renderPanel();
|
||||
|
||||
expect(container.querySelector(".board-column-entry")).toBeNull();
|
||||
expect(container.querySelector(".no-columns")?.textContent)
|
||||
.toBe("board_view.columns-empty");
|
||||
});
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
act(() => render(<BoardColumns />, container));
|
||||
}
|
||||
51
apps/client/src/widgets/sidebar/BoardColumns.tsx
Normal file
51
apps/client/src/widgets/sidebar/BoardColumns.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import "./BoardColumns.css";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import SimpleBadge from "../react/Badge";
|
||||
import { useGetContextData } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
import RightPanelWidget from "./RightPanelWidget";
|
||||
|
||||
/**
|
||||
* The board's columns, in the order it draws them, so that one of them can be reached on a board
|
||||
* too wide to see at once. The board publishes the list and the scrolling; this widget draws it.
|
||||
*/
|
||||
export default function BoardColumns() {
|
||||
const data = useGetContextData("boardColumns");
|
||||
const columns = data?.columns ?? [];
|
||||
|
||||
return (
|
||||
<RightPanelWidget id="board-columns" title={t("board_view.columns-title")} grow>
|
||||
<div className="board-columns-list">
|
||||
{columns.length > 0 ? (
|
||||
<ol>
|
||||
{columns.map(column => (
|
||||
<li key={column.value}>
|
||||
{/* The row is the scroll target, reachable by keyboard as the
|
||||
other lists of the pane are. */}
|
||||
<span
|
||||
className="board-column-entry"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => data?.scrollToColumn(column.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
data?.scrollToColumn(column.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon={column.icon} />
|
||||
<span className="board-column-name">{column.title}</span>
|
||||
<SimpleBadge title={column.count} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="no-columns">{t("board_view.columns-empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</RightPanelWidget>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ import { PaneMode, usePaneMode, usePeekDismiss } from "../react/peek_pane";
|
||||
import LegacyRightPanelWidget from "../right_panel_widget";
|
||||
import AttributeList from "./AttributeList";
|
||||
import Backlinks from "./Backlinks";
|
||||
import BoardColumns from "./BoardColumns";
|
||||
import ChatHighlightsList from "./ChatHighlightsList";
|
||||
import HighlightsList from "./HighlightsList";
|
||||
import NoteMap from "./NoteMap";
|
||||
@ -203,6 +204,8 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
|
||||
// Published by the LLM chat; drives the chat highlights widget's visibility (only shown once
|
||||
// the chat has at least one highlight).
|
||||
const chatHighlights = useGetContextData("chatHighlights");
|
||||
// Published by a board, which is what says the note is being shown as one.
|
||||
const boardColumns = useGetContextData("boardColumns");
|
||||
// Subscribe to the AI toggle so the LLM chat is added/removed reactively without a page reload.
|
||||
const [ aiEnabled ] = useTriliumOptionBool("aiEnabled");
|
||||
const isPdf = noteType === "file" && noteMime === "application/pdf";
|
||||
@ -268,6 +271,11 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent):
|
||||
enabled: (noteType === "text" || !!note?.isMarkdown()) && highlightsList.length > 0,
|
||||
tab: "outline"
|
||||
},
|
||||
{
|
||||
el: <BoardColumns />,
|
||||
enabled: (boardColumns?.columns.length ?? 0) > 0,
|
||||
tab: "outline"
|
||||
},
|
||||
{
|
||||
el: <ChatHighlightsList />,
|
||||
enabled: noteType === "llmChat" && (chatHighlights?.highlights.length ?? 0) > 0,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user