workers: Bot-Leads behält den Tastaturfokus beim Blättern und nach „Alle anzeigen“, aria-current nur in der Einzelansicht
Endprüfung P6 (C5, W1): Beim Blättern hängt die Seitenzeile beim Laden aus, und „Alle anzeigen“ wird durch den Raumfilter ersetzt; der Fokus fiel jeweils auf <body>. - Blättern: gemerkt wird der gedrückte Knopf; nach dem Laden bekommt er den Fokus zurück, ist er gesperrt (erste oder letzte Seite), der andere. Raum oder Suche bedient der Nutzer inzwischen, geschieht nichts; scheitert die Anfrage, gibt es keine Seitenzeile und nichts geschieht. - „Alle anzeigen“: der Raumfilter bekommt den Fokus, sobald die Seite bot aus der Adresse genommen hat. Nicht beim Einhängen (am Handy öffnete das die Auswahl) und nicht bei einem anderen Weg zur Liste (Zurück-Taste). - aria-current nur mit bot (single) und passender Kennung; Zeilen ohne Kennung teilen sich keinen key mehr. - Tests: botleadList.test.mjs (Fokus-Attrappe über __fakeRef, Mutationsprobe für die Reset-Zeilen), botleadsView.test.mjs (Quelltext-Probe); der Prüfstand prüft zusätzlich den Fokus nach „Alle anzeigen“. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78c8e3fbc2
commit
ed5dfa9c0e
4 files changed
+174
-6
No files matched your search
@@ -2006,6 +2006,7 @@ async function botleads(browser, base) {
|
|||||||
await blSettled(page)
|
await blSettled(page)
|
||||||
check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0)
|
check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0)
|
||||||
metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page)
|
metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page)
|
||||||
|
check('botleads: nach „Alle anzeigen“ liegt der Tastaturfokus auf dem Raumfilter (nicht auf <body>)', metrics.botleadsFocusAfterAlleAnzeigen.tag === 'SELECT' && !metrics.botleadsFocusAfterAlleAnzeigen.onBody, metrics.botleadsFocusAfterAlleAnzeigen)
|
||||||
await topOfPage(page)
|
await topOfPage(page)
|
||||||
await shoot(page, 'botleads-nach-alle-anzeigen.png')
|
await shoot(page, 'botleads-nach-alle-anzeigen.png')
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
|||||||
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
|
const [result, setResult] = useState({ key: null, items: [], total: 0, pageSize: undefined, error: null })
|
||||||
const timerRef = useRef(null)
|
const timerRef = useRef(null)
|
||||||
const seqRef = useRef(0)
|
const seqRef = useRef(0)
|
||||||
|
// Tastaturfokus: Wer „Alle anzeigen“ oder „Zurück“/„Weiter“ drückt, verliert den Knopf mit dem Fokus (Einzelansicht und Seitenzeile werden ausgetauscht oder
|
||||||
|
// hängen beim Laden aus); ohne Zutun fiele der Fokus auf <body>, an den Seitenanfang. Gemerkt wird nur, was der Nutzer als Letztes bedient hat.
|
||||||
|
const selectRef = useRef(null) // Raumfilter: Ziel nach „Alle anzeigen“
|
||||||
|
const clearedRef = useRef(false) // „Alle anzeigen“ gedrückt: erst dann (nie schon beim Einhängen, das öffnete am Handy die Auswahl) bekommt der Raumfilter den Fokus
|
||||||
|
const prevRef = useRef(null)
|
||||||
|
const nextRef = useRef(null)
|
||||||
|
const pagedRef = useRef('') // 'prev' oder 'next': welcher Blätterknopf zuletzt gedrückt wurde; nach dem Laden bekommt die Seitenzeile den Fokus zurück
|
||||||
|
|
||||||
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
|
const key = JSON.stringify(single ? [botId] : [view.room, view.q, view.page])
|
||||||
|
|
||||||
@@ -53,18 +60,49 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
|||||||
|
|
||||||
useEffect(() => () => window.clearTimeout(timerRef.current), [])
|
useEffect(() => () => window.clearTimeout(timerRef.current), [])
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
clearedRef.current = true
|
||||||
|
onClearBot()
|
||||||
|
}
|
||||||
|
const onPage = (dir) => {
|
||||||
|
pagedRef.current = dir
|
||||||
|
setView((v) => ({ ...v, page: v.page + (dir === 'next' ? 1 : -1) }))
|
||||||
|
}
|
||||||
const onRoom = (e) => {
|
const onRoom = (e) => {
|
||||||
const room = e.target.value
|
const room = e.target.value
|
||||||
|
pagedRef.current = '' // wer den Raum wählt, hat den Blätterknopf verlassen: der Fokus springt nicht zurück
|
||||||
setView((v) => ({ ...v, room, page: 1 }))
|
setView((v) => ({ ...v, room, page: 1 }))
|
||||||
}
|
}
|
||||||
const onSearch = (e) => {
|
const onSearch = (e) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
|
pagedRef.current = ''
|
||||||
setDraft(value)
|
setDraft(value)
|
||||||
window.clearTimeout(timerRef.current)
|
window.clearTimeout(timerRef.current)
|
||||||
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
|
timerRef.current = window.setTimeout(() => setView((v) => ({ ...v, q: value.trim(), page: 1 })), SEARCH_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = result.key !== key
|
const loading = result.key !== key
|
||||||
|
|
||||||
|
// Die Einzelansicht ist weg (die Seite hat bot aus der Adresse genommen): der Raumfilter ersetzt den gedrückten Knopf und bekommt den Fokus.
|
||||||
|
// Ohne den Knopf (Zurück-Taste des Browsers) bleibt der Fokus, wo er ist.
|
||||||
|
useEffect(() => {
|
||||||
|
const wanted = clearedRef.current
|
||||||
|
clearedRef.current = false
|
||||||
|
if (wanted && !single) selectRef.current?.focus()
|
||||||
|
}, [single])
|
||||||
|
|
||||||
|
// Die neue Seite ist da und die Seitenzeile wieder eingehängt: der Fokus geht an den gedrückten Knopf, ist er gesperrt (erste oder letzte Seite) an den anderen.
|
||||||
|
// Scheiterte die Anfrage, gibt es keine Seitenzeile (die Refs sind null) und nichts geschieht.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
const dir = pagedRef.current
|
||||||
|
pagedRef.current = ''
|
||||||
|
if (dir === '') return
|
||||||
|
const [first, second] = dir === 'next' ? [nextRef.current, prevRef.current] : [prevRef.current, nextRef.current]
|
||||||
|
const target = first && !first.disabled ? first : second
|
||||||
|
target?.focus()
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
const rows = result.items.map((item) => botleadRow(item))
|
const rows = result.items.map((item) => botleadRow(item))
|
||||||
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
|
const info = pageInfo({ total: result.total, page: view.page, pageSize: result.pageSize })
|
||||||
const filtered = view.room !== '' || view.q !== ''
|
const filtered = view.room !== '' || view.q !== ''
|
||||||
@@ -86,8 +124,8 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
|||||||
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
|
<tr>{COLUMNS.map((name) => <th key={name} scope="col">{name}</th>)}</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row, index) => (
|
||||||
<tr key={row.id} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={row.id === botId ? 'true' : undefined}>
|
<tr key={row.id || `ohne-id-${index}`} className={row.gekuerzt ? 'wk-bl-cut' : undefined} aria-current={single && row.id === botId ? 'true' : undefined}>
|
||||||
<td>
|
<td>
|
||||||
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
|
<div className="cell-title">{row.title}{row.gekuerzt && <span className="wk-bl-tag">gekürzt</span>}</div>
|
||||||
{row.place && <div className="cell-sub">{row.place}</div>}
|
{row.place && <div className="cell-sub">{row.place}</div>}
|
||||||
@@ -116,11 +154,11 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
|||||||
{single ? (
|
{single ? (
|
||||||
<div className="toolbar wk-bl-single">
|
<div className="toolbar wk-bl-single">
|
||||||
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
|
<span className="wk-hint">Einzelner Eintrag aus dem Ereignisstrom</span>
|
||||||
<button type="button" className="btn" onClick={onClearBot}>Alle anzeigen</button>
|
<button type="button" className="btn" onClick={onClear}>Alle anzeigen</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom}>
|
<select className="select" aria-label="Raum" value={view.room} onChange={onRoom} ref={selectRef}>
|
||||||
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
{ROOM_FILTERS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
<input className="input search" type="search" aria-label="Betriebe suchen" placeholder="Name, Ort oder Grund" maxLength={SEARCH_MAX} value={draft} onChange={onSearch} />
|
||||||
@@ -132,8 +170,8 @@ export default function BotLeadList({ api, bot, onClearBot }) {
|
|||||||
<span>{info.label}</span>
|
<span>{info.label}</span>
|
||||||
{info.pages > 1 && (
|
{info.pages > 1 && (
|
||||||
<span className="wk-bl-pager">
|
<span className="wk-bl-pager">
|
||||||
<button type="button" className="btn" disabled={!info.hasPrev} onClick={() => setView((v) => ({ ...v, page: v.page - 1 }))}>Zurück</button>
|
<button type="button" className="btn" ref={prevRef} disabled={!info.hasPrev} onClick={() => onPage('prev')}>Zurück</button>
|
||||||
<button type="button" className="btn" disabled={!info.hasNext} onClick={() => setView((v) => ({ ...v, page: v.page + 1 }))}>Weiter</button>
|
<button type="button" className="btn" ref={nextRef} disabled={!info.hasNext} onClick={() => onPage('next')}>Weiter</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ function fakeApi() {
|
|||||||
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
|
return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DOM-Attrappe für den Fokus (Muster workersPultUi.test.mjs): ref an select und den Blätterknöpfen liefert __fakeRef. Ein Element zählt den Aufruf von focus() nur,
|
||||||
|
// solange es im Baum steht; React setzt ref.current beim Abbau auf null, dann wäre ref.current?.focus() wirkungslos. Der Schlüssel ist „select“ oder die Beschriftung des Knopfs.
|
||||||
|
function fakeFocus() {
|
||||||
|
const f = { focused: [], tree: () => null, made: new Map() }
|
||||||
|
const keyOf = (tag, props) => (tag === 'button' ? String(props.children) : tag)
|
||||||
|
const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0
|
||||||
|
globalThis.__fakeRef = (tag, props) => {
|
||||||
|
const key = keyOf(tag, props)
|
||||||
|
if (!f.made.has(key)) f.made.set(key, { key, props, get disabled() { return this.props.disabled === true }, focus() { if (live(tag, key)) f.focused.push(key) } })
|
||||||
|
f.made.get(key).props = props
|
||||||
|
return f.made.get(key)
|
||||||
|
}
|
||||||
|
f.attach = (view) => { f.tree = () => view.tree() }
|
||||||
|
f.restore = () => { delete globalThis.__fakeRef }
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
const ITEM = {
|
const ITEM = {
|
||||||
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
|
id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82,
|
||||||
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
|
reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum',
|
||||||
@@ -326,3 +343,109 @@ test('nach dem Abbau setzt eine späte Antwort nichts mehr, ein offener Zeitgebe
|
|||||||
await settle()
|
await settle()
|
||||||
assert.equal(ui.lateUpdates(), 0)
|
assert.equal(ui.lateUpdates(), 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Blättern (C5): nach dem Laden der neuen Seite bekommt die Seitenzeile den Tastaturfokus zurück; ist der Knopf gesperrt, der andere', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const dom = fakeFocus()
|
||||||
|
const ui = await start(api)
|
||||||
|
dom.attach(ui)
|
||||||
|
try {
|
||||||
|
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
assert.deepEqual(dom.focused, []) // nicht schon beim ersten Laden
|
||||||
|
await ui.click('Weiter')
|
||||||
|
assert.deepEqual(dom.focused, []) // während der Anfrage ist die Seitenzeile weg
|
||||||
|
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2 von 3
|
||||||
|
assert.deepEqual(dom.focused, ['Weiter'])
|
||||||
|
await ui.click('Weiter')
|
||||||
|
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 3 von 3: „Weiter“ ist gesperrt ⇒ „Zurück“
|
||||||
|
assert.deepEqual(dom.focused, ['Weiter', 'Zurück'])
|
||||||
|
await ui.click('Zurück')
|
||||||
|
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 2
|
||||||
|
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück'])
|
||||||
|
await ui.click('Zurück')
|
||||||
|
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 }) // Seite 1: „Zurück“ ist gesperrt ⇒ „Weiter“
|
||||||
|
assert.deepEqual(dom.focused, ['Weiter', 'Zurück', 'Zurück', 'Weiter'])
|
||||||
|
} finally { dom.restore(); ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Blättern (C5): der Fokus springt nicht, wenn der Nutzer inzwischen Raum oder Suche bedient hat oder die Anfrage scheitert', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const dom = fakeFocus()
|
||||||
|
const ui = await start(api)
|
||||||
|
dom.attach(ui)
|
||||||
|
try {
|
||||||
|
await ui.answer(0, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
await ui.click('Weiter')
|
||||||
|
await ui.pick('handwerk') // während die Seite lädt, wechselt der Raum
|
||||||
|
await ui.answer(1, { items: [ITEM], total: 120, pageSize: 50 }) // späte Antwort der älteren Anfrage
|
||||||
|
await ui.answer(2, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
await ui.click('Weiter')
|
||||||
|
await ui.type('bäckerei') // die Suche wird getippt, bevor die Seite da ist
|
||||||
|
await ui.answer(3, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
// Raum und Suche selbst verschieben den Fokus nie
|
||||||
|
ui.fireTimers(); await settle()
|
||||||
|
await ui.answer(4, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
// scheitert die Anfrage, gibt es keine Seitenzeile zum Fokussieren; die Merkung bleibt nicht für später hängen
|
||||||
|
await ui.click('Weiter')
|
||||||
|
await ui.fail(5, new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'))
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
await ui.pick('laden')
|
||||||
|
await ui.answer(6, { items: [ITEM], total: 120, pageSize: 50 })
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
} finally { dom.restore(); ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Alle anzeigen (W1): nach dem Verlassen der Einzelansicht bekommt der Raumfilter den Fokus, nicht beim Einhängen und nicht bei einem anderen Weg zur Liste', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const dom = fakeFocus()
|
||||||
|
let cleared = 0
|
||||||
|
const ui = await start(api, { bot: 'p1', onClearBot: () => { cleared += 1 } })
|
||||||
|
dom.attach(ui)
|
||||||
|
try {
|
||||||
|
await ui.answer(0, { items: [ITEM], total: 1 })
|
||||||
|
assert.deepEqual(dom.focused, []) // nicht beim Einhängen (das öffnete am Handy die Tastatur oder die Auswahl)
|
||||||
|
await ui.click('Alle anzeigen')
|
||||||
|
assert.equal(cleared, 1)
|
||||||
|
assert.deepEqual(dom.focused, []) // der Knopf steht noch, bis die Seite bot aus der Adresse nimmt
|
||||||
|
ui.setProps({ bot: '' }); await settle()
|
||||||
|
assert.deepEqual(dom.focused, ['select'])
|
||||||
|
assert.equal(ui.select().props['aria-label'], 'Raum')
|
||||||
|
// ein weiterer Wechsel in die Einzelansicht und zurück ohne den Knopf (Zurück-Taste des Browsers): kein Fokussprung
|
||||||
|
ui.setProps({ bot: 'p2' }); await settle()
|
||||||
|
ui.setProps({ bot: '' }); await settle()
|
||||||
|
assert.deepEqual(dom.focused, ['select'])
|
||||||
|
} finally { dom.restore(); ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Alle anzeigen (W1): ohne bot (Liste von Anfang an) bleibt der Fokus unberührt', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const dom = fakeFocus()
|
||||||
|
const ui = await start(api)
|
||||||
|
dom.attach(ui)
|
||||||
|
try {
|
||||||
|
await ui.answer(0, { items: [ITEM], total: 1 })
|
||||||
|
ui.setProps({ bot: 'p1', onClearBot: () => {} }); await settle()
|
||||||
|
ui.setProps({ bot: '' }); await settle()
|
||||||
|
assert.deepEqual(dom.focused, [])
|
||||||
|
} finally { dom.restore(); ui.end() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aria-current (C5): nur die Zeile mit der Kennung aus bot, nie eine Zeile ohne Kennung', async () => {
|
||||||
|
const api = fakeApi()
|
||||||
|
const ui = await start(api)
|
||||||
|
try {
|
||||||
|
// Liste (bot leer): eine Zeile ohne Kennung (botleadRow macht daraus '') gilt nicht als „aktuell“
|
||||||
|
await ui.answer(0, { items: [{ ...ITEM, id: undefined }, { ...PRAXIS, id: 5 }, ITEM], total: 3 })
|
||||||
|
assert.equal(ui.rows().length, 3)
|
||||||
|
assert.deepEqual(ui.rows().map((r) => r.props['aria-current']), [undefined, undefined, undefined])
|
||||||
|
} finally { ui.end() }
|
||||||
|
const api2 = fakeApi()
|
||||||
|
const one = await start(api2, { bot: 'p1', onClearBot: () => {} })
|
||||||
|
try {
|
||||||
|
await one.answer(0, { items: [{ ...ITEM, id: '' }, ITEM, PRAXIS], total: 3 })
|
||||||
|
assert.deepEqual(one.rows().map((r) => r.props['aria-current']), [undefined, 'true', undefined])
|
||||||
|
} finally { one.end() }
|
||||||
|
})
|
||||||
@@ -96,3 +96,9 @@ test('BotLeadList.jsx: Links nur aus der Zeilen-Ableitung, extern mit rel noopen
|
|||||||
// kein Polling
|
// kein Polling
|
||||||
assert.doesNotMatch(src, /setInterval/)
|
assert.doesNotMatch(src, /setInterval/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('BotLeadList.jsx: Zeilen ohne Kennung teilen sich keinen key (Ausweichwert aus der Position), aria-current nur in der Einzelansicht', () => {
|
||||||
|
const src = read('src/components/workers/BotLeadList.jsx')
|
||||||
|
assert.match(src, /key=\{row\.id \|\| /)
|
||||||
|
assert.match(src, /aria-current=\{single && row\.id === botId \?/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user