feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
aligned with the editor's chip styling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,9 @@ import { Component, computed, input } from '@angular/core';
|
||||
border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||
}
|
||||
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
|
||||
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
.chip::before{content:'\\7B';opacity:0.6;font-weight:700}
|
||||
.chip::after{content:'\\7D';opacity:0.6;font-weight:700}
|
||||
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { readBlock, renderInto } from './rich-text-dom';
|
||||
import { adjacentChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||
|
||||
@@ -48,4 +48,70 @@ describe('rich-text DOM boundary', () => {
|
||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||
});
|
||||
|
||||
it('round-trips bullet and numbered lists mixed with paragraphs', () => {
|
||||
const block: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'Intro' }] },
|
||||
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'twee' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'eerst' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'dan' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'Slot' }] },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
});
|
||||
|
||||
it('groups consecutive same-kind list lines into one <ul>/<ol>', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(
|
||||
root,
|
||||
{
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'a' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'b' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'c' }], list: 'number' },
|
||||
],
|
||||
},
|
||||
labelFor,
|
||||
);
|
||||
expect(root.querySelectorAll('ul').length).toBe(1);
|
||||
expect(root.querySelectorAll('ul > li').length).toBe(2);
|
||||
expect(root.querySelectorAll('ol > li').length).toBe(1);
|
||||
});
|
||||
|
||||
it('adjacentChip finds a chip next to a collapsed caret so Backspace/Delete can remove it', () => {
|
||||
// <p>aa{chip}bb</p>
|
||||
const p = document.createElement('p');
|
||||
const before = document.createTextNode('aa');
|
||||
const chip = document.createElement('span');
|
||||
chip.dataset['phKey'] = 'naam';
|
||||
const after = document.createTextNode('bb');
|
||||
p.append(before, chip, after);
|
||||
|
||||
// Backspace with caret just after the chip (start of "bb") → the chip.
|
||||
expect(adjacentChip(after, 0, -1)).toBe(chip);
|
||||
// Delete with caret just before the chip (end of "aa") → the chip.
|
||||
expect(adjacentChip(before, before.length, 1)).toBe(chip);
|
||||
// Element-level caret between chip and "bb" (offset 2), Backspace → the chip.
|
||||
expect(adjacentChip(p, 2, -1)).toBe(chip);
|
||||
// Caret mid-text → nothing to remove.
|
||||
expect(adjacentChip(after, 1, -1)).toBeNull();
|
||||
// At the text edge but the sibling is not a chip → null.
|
||||
expect(adjacentChip(before, 0, -1)).toBeNull();
|
||||
});
|
||||
|
||||
it('marks auto-resolvable vs manual chips with data-auto for styling', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(
|
||||
root,
|
||||
{ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }, { type: 'placeholder', key: 'reden' }] }] },
|
||||
labelFor,
|
||||
(key) => key === 'naam',
|
||||
);
|
||||
const chips = root.querySelectorAll('.rte-chip');
|
||||
expect((chips[0] as HTMLElement).dataset['auto']).toBe('true');
|
||||
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||
@@ -14,35 +14,71 @@ import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||
|
||||
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
|
||||
export function renderInto(
|
||||
root: HTMLElement,
|
||||
block: RichTextBlock,
|
||||
labelFor: (key: string) => string,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): void {
|
||||
const doc = root.ownerDocument;
|
||||
root.replaceChildren();
|
||||
for (const para of block.paragraphs) {
|
||||
const p = doc.createElement('p');
|
||||
p.className = 'rte-para';
|
||||
if (para.nodes.length === 0) {
|
||||
p.appendChild(doc.createElement('br')); // keep the empty line focusable
|
||||
const paras = block.paragraphs;
|
||||
let i = 0;
|
||||
while (i < paras.length) {
|
||||
const para = paras[i];
|
||||
if (para.list) {
|
||||
// Group consecutive lines of the same list kind into one <ul>/<ol>.
|
||||
const listEl = doc.createElement(para.list === 'bullet' ? 'ul' : 'ol');
|
||||
listEl.className = 'rte-list';
|
||||
while (i < paras.length && paras[i].list === para.list) {
|
||||
const li = doc.createElement('li');
|
||||
fillLine(li, paras[i], labelFor, doc, autoFor);
|
||||
listEl.appendChild(li);
|
||||
i++;
|
||||
}
|
||||
root.appendChild(listEl);
|
||||
} else {
|
||||
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
||||
const p = doc.createElement('p');
|
||||
p.className = 'rte-para';
|
||||
fillLine(p, para, labelFor, doc, autoFor);
|
||||
root.appendChild(p);
|
||||
i++;
|
||||
}
|
||||
root.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill one line element (<p> or <li>) with a paragraph's nodes; empty lines keep a
|
||||
focusable <br>. Shared by paragraph and list-item rendering. */
|
||||
function fillLine(
|
||||
el: HTMLElement,
|
||||
para: Paragraph,
|
||||
labelFor: (key: string) => string,
|
||||
doc: Document,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): void {
|
||||
if (para.nodes.length === 0) {
|
||||
el.appendChild(doc.createElement('br'));
|
||||
} else {
|
||||
for (const node of para.nodes) el.appendChild(renderNode(node, labelFor, doc, autoFor));
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
|
||||
export function createChip(doc: Document, key: string, label: string): HTMLElement {
|
||||
live caret insertion). `data-auto` distinguishes auto-resolvable vs manual fields
|
||||
for styling; it is a render hint only and is ignored on read-back. */
|
||||
export function createChip(doc: Document, key: string, label: string, auto = false): HTMLElement {
|
||||
const span = doc.createElement('span');
|
||||
span.dataset['phKey'] = key;
|
||||
span.dataset['auto'] = String(auto);
|
||||
span.setAttribute('contenteditable', 'false');
|
||||
span.className = 'rte-chip';
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document, autoFor?: (key: string) => boolean): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
||||
let el: Node = doc.createTextNode(node.text);
|
||||
// Nest marks in a canonical order so read-back is deterministic.
|
||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||
@@ -54,21 +90,37 @@ function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc:
|
||||
}
|
||||
|
||||
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||
const paragraphs: { nodes: RichTextNode[] }[] = [];
|
||||
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const blockEls = Array.from(root.children).filter(
|
||||
(c) => c.tagName === 'P' || c.tagName === 'DIV' || c.tagName === 'UL' || c.tagName === 'OL',
|
||||
);
|
||||
const containers = blockEls.length ? blockEls : [root];
|
||||
for (const el of containers) {
|
||||
const kids = Array.from(el.childNodes);
|
||||
const nodes: RichTextNode[] = [];
|
||||
// A lone <br> is the empty-line filler, not a content line break.
|
||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||
for (const child of kids) collect(child, [], nodes);
|
||||
// ponytail: only top-level lists are read as lists; a nested <ul> inside an <li>
|
||||
// flattens into its item's text (fine for this POC's flat lists).
|
||||
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
||||
const list = el.tagName === 'UL' ? 'bullet' : 'number';
|
||||
for (const li of Array.from(el.children).filter((c) => c.tagName === 'LI')) {
|
||||
paragraphs.push({ ...readLine(li as HTMLElement), list });
|
||||
}
|
||||
} else {
|
||||
paragraphs.push(readLine(el as HTMLElement));
|
||||
}
|
||||
paragraphs.push({ nodes });
|
||||
}
|
||||
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
/** Read one line element (<p>, <div>, or <li>) into a paragraph's nodes. */
|
||||
function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
|
||||
const kids = Array.from(el.childNodes);
|
||||
const nodes: RichTextNode[] = [];
|
||||
// A lone <br> is the empty-line filler, not a content line break.
|
||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||
for (const child of kids) collect(child, [], nodes);
|
||||
}
|
||||
return { nodes };
|
||||
}
|
||||
|
||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? '';
|
||||
@@ -91,6 +143,32 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||
}
|
||||
|
||||
function isChip(node: Node | null | undefined): node is HTMLElement {
|
||||
return !!node && node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset?.['phKey'] != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder chip immediately adjacent to a collapsed caret in the given direction
|
||||
* (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false,
|
||||
* which some browsers won't delete on Backspace; the editor uses this to remove them itself.
|
||||
*/
|
||||
export function adjacentChip(container: Node, offset: number, direction: -1 | 1): HTMLElement | null {
|
||||
let sibling: Node | null | undefined;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
// Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first).
|
||||
if (direction === -1) {
|
||||
if (offset > 0) return null;
|
||||
sibling = container.previousSibling;
|
||||
} else {
|
||||
if (offset < (container.textContent?.length ?? 0)) return null;
|
||||
sibling = container.nextSibling;
|
||||
}
|
||||
} else {
|
||||
sibling = direction === -1 ? container.childNodes[offset - 1] : container.childNodes[offset];
|
||||
}
|
||||
return isChip(sibling) ? sibling : null;
|
||||
}
|
||||
|
||||
function markOf(el: HTMLElement): Mark | null {
|
||||
switch (el.tagName) {
|
||||
case 'STRONG':
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||
export interface PlaceholderOption {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
// Auto-resolvable fields are filled server-side at send; manual fields need a value.
|
||||
// Drives the chip's styling so the two read apart at a glance.
|
||||
readonly autoResolvable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,13 +34,20 @@ export interface PlaceholderOption {
|
||||
}
|
||||
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||
/* Placeholder chips inside the editor: one neutral highlight (the read-only
|
||||
preview distinguishes auto/manual/error via app-placeholder-chip). */
|
||||
.rte-editable .rte-chip{
|
||||
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;white-space:nowrap;
|
||||
.rte-editable :is(ul,ol){margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||
/* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)
|
||||
vs manual (yellow, still needs a value). The read-only preview adds error/warning states.
|
||||
Chips are created imperatively (createChip) inside contenteditable, so they never receive
|
||||
Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.
|
||||
Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
:host ::ng-deep .rte-chip{
|
||||
border:1px dashed var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.3em;white-space:nowrap;
|
||||
}
|
||||
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
|
||||
:host ::ng-deep .rte-chip[data-auto='true']{background:var(--rhc-color-cool-grey-100)}
|
||||
:host ::ng-deep .rte-chip[data-auto='false']{background:var(--rhc-color-geel-100)}
|
||||
:host ::ng-deep .rte-chip::before{content:'\\7B';opacity:0.6;font-weight:700;margin-inline-end:0.1em}
|
||||
:host ::ng-deep .rte-chip::after{content:'\\7D';opacity:0.6;font-weight:700;margin-inline-start:0.1em}
|
||||
`],
|
||||
template: `
|
||||
@if (editable()) {
|
||||
@@ -45,6 +55,9 @@ export interface PlaceholderOption {
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
|
||||
@if (placeholders().length) {
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<label>
|
||||
@@ -59,7 +72,7 @@ export interface PlaceholderOption {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
|
||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()" (keydown)="onKeydown($event)"
|
||||
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||
`,
|
||||
})
|
||||
@@ -77,11 +90,14 @@ export class RichTextEditorComponent {
|
||||
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||
bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);
|
||||
numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);
|
||||
|
||||
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||
private lastEmitted = '';
|
||||
|
||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||
private autoFor = (key: string) => this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;
|
||||
|
||||
constructor() {
|
||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||
@@ -92,7 +108,7 @@ export class RichTextEditorComponent {
|
||||
if (!el) return;
|
||||
const serialized = JSON.stringify(content);
|
||||
if (serialized === this.lastEmitted) return;
|
||||
renderInto(el, content, this.labelFor);
|
||||
renderInto(el, content, this.labelFor, this.autoFor);
|
||||
this.lastEmitted = serialized;
|
||||
});
|
||||
}
|
||||
@@ -115,11 +131,51 @@ export class RichTextEditorComponent {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Bullet / numbered lists via execCommand — same deprecated-but-universal path as
|
||||
bold/italic (ponytail-noted on `format`); readBlock reads the resulting <ul>/<ol>. */
|
||||
protected list(kind: 'bullet' | 'number') {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour
|
||||
is consistent across browsers rather than relying on the native handler. */
|
||||
protected onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
this.deleteAdjacentChip(e);
|
||||
return;
|
||||
}
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
||||
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as 'bold' | 'italic' | 'underline' | undefined;
|
||||
if (!cmd) return;
|
||||
e.preventDefault();
|
||||
this.format(cmd);
|
||||
}
|
||||
|
||||
/** Backspace/Delete next to a contenteditable=false chip removes it ourselves —
|
||||
browsers otherwise leave these atomic chips undeletable. Selections fall through. */
|
||||
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return;
|
||||
const chip = adjacentChip(range.startContainer, range.startOffset, e.key === 'Backspace' ? -1 : 1);
|
||||
if (!chip) return;
|
||||
e.preventDefault();
|
||||
chip.remove();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected insert(key: string) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!key || !el) return;
|
||||
el.focus();
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
Reference in New Issue
Block a user